Merge "Fix link for patchset level comment"
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/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 172e444..fe0298a 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
@@ -37,6 +37,7 @@
 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 {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {GrDisplayNameUtils} from '../../../scripts/gr-display-name-utils/gr-display-name-utils.js';
 import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
@@ -47,6 +48,9 @@
   LARGE: 1000,
 };
 
+// How many reviewers should be shown with an account-label?
+const PRIMARY_REVIEWERS_COUNT = 2;
+
 /**
  * @appliesMixin RESTClientMixin
  * @extends PolymerElement
@@ -73,6 +77,7 @@
 
       /** @type {?} */
       change: Object,
+      config: Object,
       changeURL: {
         type: String,
         computed: '_computeChangeURL(change)',
@@ -213,6 +218,45 @@
     }
   }
 
+  _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 first
+   * that are in the attention set.
+   */
+  _computeReviewers(change) {
+    if (!change || !change.reviewers || !change.reviewers.REVIEWER) return [];
+    const reviewers = [...change.reviewers.REVIEWER];
+    reviewers.sort((r1, r2) => {
+      if (this._hasAttention(r1)) return -1;
+      if (this._hasAttention(r2)) return 1;
+      return 0;
+    });
+    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 => GrDisplayNameUtils.getDisplayName(config, user))
+        .join(', ');
+  }
+
   _computeComments(unresolved_comment_count) {
     if (!unresolved_comment_count || unresolved_comment_count < 1) return '';
     return `${unresolved_comment_count} unresolved`;
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
index 36f84f7..d2ab2c6 100644
--- 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
@@ -50,6 +50,9 @@
     .reviewers {
       white-space: nowrap;
     }
+    .reviewers {
+      --account-max-length: 75px;
+    }
     .spacer {
       height: 0;
       overflow: hidden;
@@ -150,7 +153,11 @@
     class="cell owner"
     hidden$="[[isColumnHidden('Owner', visibleChangeTableColumns)]]"
   >
-    <gr-account-link account="[[change.owner]]"></gr-account-link>
+    <gr-account-link
+      show-attention
+      change="[[change]]"
+      account="[[change.owner]]"
+    ></gr-account-link>
   </td>
   <td
     class="cell assignee"
@@ -173,16 +180,22 @@
     <div>
       <template
         is="dom-repeat"
-        items="[[change.reviewers.REVIEWER]]"
+        items="[[_computePrimaryReviewers(change)]]"
         as="reviewer"
       >
         <gr-account-link
           hide-avatar=""
           hide-status=""
+          show-attention
+          change="[[change]]"
           account="[[reviewer]]"
         ></gr-account-link
-        ><!--
-       --><span class="lastChildHidden">, </span>
+        ><span class="lastChildHidden">, </span>
+      </template>
+      <template is="dom-if" if="[[_computeAdditionalReviewersCount(change)]]">
+        <span title="[[_computeAdditionalReviewersTitle(change, config)]]">
+          +[[_computeAdditionalReviewersCount(change, config)]]
+        </span>
       </template>
     </div>
   </td>
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
index 2e50cd8..4802773 100644
--- 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
@@ -135,6 +135,7 @@
             highlight$="[[_computeItemHighlight(account, change)]]"
             needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState)]]"
             change="[[change]]"
+            config="[[_config]]"
             visible-change-table-columns="[[visibleChangeTableColumns]]"
             show-number="[[showNumber]]"
             show-star="[[showStar]]"
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 1cc2316..c5d50c1 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
@@ -182,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
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..d98bac7
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
@@ -0,0 +1,180 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES 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 {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.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 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 = basicFixture.instantiate();
+    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();
+      pluginApi.install(plugin => {
+        plugin.registerStyleModule('change-metadata', 'my-plugin-style');
+      }, undefined, 'http://test.com/style.js');
+      element = createElement();
+      sandbox.stub(pluginEndpoints, 'importUrl', url => Promise.resolve());
+      pluginLoader.loadPlugins([]);
+      pluginLoader.awaitPluginsLoaded().then(() => {
+        flush(done);
+      });
+    });
+
+    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/style.js');
+      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);
+    });
+  });
+});
\ No newline at end of file
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.js
similarity index 94%
rename from polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
rename to polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
index 8e780d7..27f9b85 100644
--- 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.js
@@ -1,44 +1,30 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
 
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT 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 '../../../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 {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.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', () => {
@@ -47,15 +33,13 @@
 
   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');
+    element = basicFixture.instantiate();
+    sandbox.stub(pluginEndpoints, 'importUrl', url => Promise.resolve());
   });
 
   teardown(() => {
@@ -796,5 +780,4 @@
       });
     });
   });
-});
-</script>
+});
\ No newline at end of file
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-view/gr-change-view_test.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
index faa81b6..acd16d7 100644
--- 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
@@ -279,9 +279,6 @@
 
   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');
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..2a8c418
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
@@ -0,0 +1,151 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES 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 {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.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 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 = basicFixture.instantiate();
+    setupElement(element);
+
+    // Allow the elements created by dom-repeat to be stamped.
+    flushAsynchronousOperations();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+    resetPlugins();
+  });
+
+  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();
+    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/lgtm.js');
+    element = basicFixture.instantiate();
+    setupElement(element);
+    sandbox.stub(pluginEndpoints, 'importUrl', url => Promise.resolve());
+    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();
+      });
+    });
+  });
+});
\ No newline at end of file
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/core/gr-navigation/gr-navigation.js b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
index 69e2989..24438eb 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
@@ -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
@@ -730,8 +739,14 @@
   },
 
   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,
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 4991770..c91819a 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
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 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';
@@ -65,15 +64,6 @@
     pluginEndpoints.onDetachedEndpoint(this.name, this._endpointCallBack);
   }
 
-  /**
-   * @suppress {checkTypes}
-   */
-  _import(url) {
-    return new Promise((resolve, reject) => {
-      importHref(url, resolve, reject);
-    });
-  }
-
   _initDecoration(name, plugin, slot) {
     const el = document.createElement(name);
     return this._initProperties(el, plugin,
@@ -133,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);
         });
   }
 
@@ -174,10 +164,7 @@
     pluginEndpoints.onNewEndpoint(this.name, this._endpointCallBack);
     if (this.name) {
       pluginLoader.awaitPluginsLoaded()
-          .then(() => Promise.all(
-              pluginEndpoints.getPlugins(this.name).map(
-                  pluginUrl => this._import(pluginUrl)))
-          )
+          .then(() => pluginEndpoints.getAndImportPlugins(this.name))
           .then(() =>
             pluginEndpoints
                 .getDetails(this.name)
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.js
similarity index 72%
rename from polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
rename to polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
index 890a457..17ada2c 100644
--- 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.js
@@ -1,61 +1,51 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
 
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT 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 '../../../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 {pluginEndpoints} 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 sandbox;
@@ -66,11 +56,9 @@
 
   setup(done => {
     sandbox = sinon.sandbox.create();
-    stub('gr-endpoint-decorator', {
-      _import: sandbox.stub().returns(Promise.resolve()),
-    });
     resetPlugins();
-    container = fixture('basic');
+    container = basicFixture.instantiate();
+    sandbox.stub(pluginEndpoints, 'importUrl', url => Promise.resolve());
     pluginApi.install(p => plugin = p, '0.1',
         'http://some/plugin/url.html');
     // Decoration
@@ -90,16 +78,16 @@
 
   teardown(() => {
     sandbox.restore();
+    resetPlugins();
   });
 
   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')));
-    });
+    assert.isTrue(pluginEndpoints.importUrl.calledWith(
+        new URL('http://some/plugin/url.html')
+    ));
   });
 
   test('decoration', () => {
@@ -124,7 +112,7 @@
   test('decoration with slot', () => {
     const element =
         container.querySelector('gr-endpoint-decorator[name="first"]');
-    const modules = [...dom(element).querySelectorAll('p > some-module-2')];
+    const modules = [...dom(element).querySelectorAll('some-module-2')];
     assert.equal(modules.length, 1);
     const [module] = modules;
     assert.isOk(module);
@@ -225,4 +213,3 @@
     });
   });
 });
-</script>
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 f27053d..16176b3 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
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 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';
@@ -35,10 +34,6 @@
   static get properties() {
     return {
       name: String,
-      _urlsImported: {
-        type: Array,
-        value() { return []; },
-      },
       _stylesApplied: {
         type: Array,
         value() { return []; },
@@ -46,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);
@@ -79,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);
-      }
-    });
+    pluginEndpoints.getAndImportPlugins(this.name)
+        .then(() => {
+          const moduleNames = pluginEndpoints.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_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..50e08cc
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
@@ -0,0 +1,116 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES 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 {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {pluginEndpoints} 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`<gr-external-style name="foo"></gr-external-style>`
+);
+
+suite('gr-external-style integration tests', () => {
+  const TEST_URL = 'http://some/plugin/url.html';
+
+  let sandbox;
+  let element;
+  let plugin;
+
+  const installPlugin = () => {
+    if (plugin) { return; }
+    pluginApi.install(p => {
+      plugin = p;
+    }, '0.1', TEST_URL);
+  };
+
+  const createElement = () => {
+    element = basicFixture.instantiate();
+    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();
+    sandbox.stub(pluginEndpoints, 'importUrl', url => Promise.resolve());
+    sandbox.stub(pluginLoader, 'awaitPluginsLoaded')
+        .returns(Promise.resolve());
+  });
+
+  teardown(() => {
+    sandbox.restore();
+    resetPlugins();
+  });
+
+  test('imports plugin-provided module', async () => {
+    lateRegister();
+    await new Promise(flush);
+    assert.isTrue(pluginEndpoints.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(pluginEndpoints.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'));
+  });
+});
\ No newline at end of file
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
index 0727397..2c97df0 100644
--- 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
@@ -16,151 +16,201 @@
  */
 
 import {pluginLoader} from './gr-plugin-loader.js';
+import {importHref} from '../../../scripts/import-href.js';
 
 /** @constructor */
-export function GrPluginEndpoints() {
-  this._endpoints = {};
-  this._callbacks = {};
-  this._dynamicPlugins = {};
-}
-
-GrPluginEndpoints.prototype.onNewEndpoint = function(endpoint, callback) {
-  if (!this._callbacks[endpoint]) {
-    this._callbacks[endpoint] = [];
+export class GrPluginEndpoints {
+  constructor() {
+    this._endpoints = {};
+    this._callbacks = {};
+    this._dynamicPlugins = {};
+    this._importedUrls = new Set();
   }
-  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();
+  onNewEndpoint(endpoint, callback) {
+    if (!this._callbacks[endpoint]) {
+      this._callbacks[endpoint] = [];
     }
-    this._dynamicPlugins[dynamicEndpoint].add(endpoint);
+    this._callbacks[endpoint].push(callback);
   }
-  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 [];
+  onDetachedEndpoint(endpoint, callback) {
+    if (this._callbacks[endpoint]) {
+      this._callbacks[endpoint] = this._callbacks[endpoint].filter(
+          cb => cb !== callback
+      );
+    }
   }
-  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 [];
+  _getOrCreateModuleInfo(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;
+    }
   }
-  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 [];
+  /**
+   * 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, 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));
+    }
   }
-  return Array.from(new Set(modulesData.map(m => m.pluginUrl)));
-};
+
+  getDynamicEndpoints(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
+   * }>}
+   */
+  getDetails(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>}
+   */
+  getModules(name, opt_options) {
+    const modulesData = this.getDetails(name, opt_options);
+    if (!modulesData.length) {
+      return [];
+    }
+    return modulesData.map(m => m.moduleName);
+  }
+
+  /**
+   * Get plugin URLs with element and module definitions.
+   *
+   * @param {string} name Endpoint name.
+   * @param {?{
+   *   type: (string|undefined),
+   *   moduleName: (string|undefined)
+   * }} opt_options
+   * @return {!Array<!URL>}
+   */
+  getPlugins(name, opt_options) {
+    const modulesData = this.getDetails(name, opt_options);
+    if (!modulesData.length) {
+      return [];
+    }
+    return Array.from(new Set(modulesData.map(m => m.pluginUrl)));
+  }
+
+  importUrl(pluginUrl) {
+    let timerId;
+    return Promise
+        .race([
+          new Promise((resolve, reject) => {
+            this._importedUrls.add(pluginUrl.href);
+            importHref(pluginUrl, 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.
+   *
+   * @param {string} name Endpoint name.
+   * @param {?{
+   *   type: (string|undefined),
+   *   moduleName: (string|undefined)
+   * }} opt_options
+   * @return {!Array<!Promise<void>>}
+   */
+  getAndImportPlugins(name, opt_options) {
+    return Promise.all(
+        this.getPlugins(name, opt_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
 export let 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.js
similarity index 76%
rename from polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
rename to polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.js
index 3494e99..e6767bc 100644
--- 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.js
@@ -1,32 +1,22 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
 
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT 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 '../../../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 {pluginLoader} from './gr-plugin-loader.js';
@@ -68,10 +58,12 @@
         }
     );
     sandbox.stub(pluginLoader, 'arePluginsLoaded').returns(true);
+    sandbox.spy(instance, 'importUrl');
   });
 
   teardown(() => {
     sandbox.restore();
+    resetPlugins();
   });
 
   test('getDetails all', () => {
@@ -133,6 +125,14 @@
         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 = sandbox.stub();
     instance.onNewEndpoint('a-place', newModuleStub);
@@ -176,5 +176,4 @@
       },
     ]);
   });
-});
-</script>
+});
\ No newline at end of file
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 6c5546e..8e2455e 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
@@ -416,7 +416,7 @@
               () => {
                 reject(new Error(this._timeout()));
               }, PLUGIN_LOADING_TIMEOUT_MS)),
-        ]).then(() => {
+        ]).finally(() => {
           if (timerId) clearTimeout(timerId);
         });
     }
diff --git a/polygerrit-ui/app/test/tests.js b/polygerrit-ui/app/test/tests.js
index 2d795ec..df6a667 100644
--- a/polygerrit-ui/app/test/tests.js
+++ b/polygerrit-ui/app/test/tests.js
@@ -57,8 +57,6 @@
   '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-comment-list/gr-comment-list_test.html',
   'change/gr-commit-info/gr-commit-info_test.html',
@@ -79,7 +77,6 @@
   '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',
@@ -122,9 +119,7 @@
   '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',
@@ -180,7 +175,6 @@
   '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',