Merge "Introduce reporting constants - Execution"
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 5e2906f..4697afc 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -48,6 +48,7 @@
 * For merged and abandoned changes the owner is added only when a human creates
   an unresolved comment.
 * Only owner, uploader, reviewers and ccs can be in the attention set.
+* The rules for service accounts are different, see link:#bots[Bots].
 
 *!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
@@ -85,7 +86,7 @@
 
 image::images/user-attention-set-reply-select.png["reply dialog section for selecting users", align="center"]
 
-=== Bots
+=== Bots [[bots]]
 
 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.
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index e1e4f62..1fde48c 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -452,7 +452,9 @@
       try (Repository repository = repoManager.openRepository(receiveEvent.project.getNameKey());
           DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
         diffFormatter.setRepository(repository);
-        diffFormatter.setDetectRenames(true);
+        // Do not detect renames; that would require reading file contents, which is slow for large
+        // files.
+        diffFormatter.setDetectRenames(false);
         // For merge commits, i.e. >1 parents, we use parent #0 by convention.
         List<DiffEntry> diffEntries =
             diffFormatter.scan(
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index bcbddb7..6eb3bc1 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -184,6 +184,12 @@
       });
   }
 
+  /** @override */
+  disconnectedCallback() {
+    this.$.cursor.unsetCursor();
+    super.disconnectedCallback();
+  }
+
   /**
    * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
    * events must be scoped to a component level (e.g. `enter`) in order to not
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index f782bbf..6e20f03 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -432,7 +432,7 @@
                 !!draftCount ||
                 !!countUnresolvedComments}
               >
-                No Comments</span
+                No comments</span
               ><gr-summary-chip
                 styleType=${SummaryChipStyles.WARNING}
                 category=${CommentTabState.DRAFTS}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 1292b26..91e0c7e 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -405,6 +405,7 @@
 
   /** @override */
   disconnectedCallback() {
+    this.$.fileCursor.unsetCursor();
     this._cancelDiffs();
     this.cancelDebouncer(DEBOUNCER_LOADING_CHANGE);
     super.disconnectedCallback();
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
deleted file mode 100644
index d8237c6..0000000
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js
+++ /dev/null
@@ -1,630 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-related-changes-list.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {resetPlugins, stubRestApi} 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('_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);
-    stubRestApi('getRelatedChanges').returns(Promise.resolve({changes: []}));
-    stubRestApi('getChangesSubmittedTogether').returns(Promise.resolve());
-    stubRestApi('getChangeCherryPicks').returns(Promise.resolve());
-    stubRestApi('getChangeConflicts').returns(Promise.resolve());
-
-    return element.reload().then(() => {
-      assert.equal(loadedStub.callCount, 4);
-    });
-  });
-
-  suite('getChangeConflicts resolves undefined', () => {
-    let element;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-
-      stubRestApi('getRelatedChanges').returns(Promise.resolve({changes: []}));
-      stubRestApi('getChangesSubmittedTogether').returns(Promise.resolve());
-      stubRestApi('getChangeCherryPicks').returns(Promise.resolve());
-      stubRestApi('getChangeConflicts').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();
-
-      stubRestApi('getRelatedChanges').returns(Promise.resolve({changes: []}));
-      stubRestApi('getChangesSubmittedTogether').returns(Promise.resolve());
-      stubRestApi('getChangeCherryPicks').returns(Promise.resolve());
-      conflictsStub = stubRestApi('getChangeConflicts').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', () => {
-      flush();
-      assert.include(element.$.submittedTogether.className, 'hidden');
-    });
-
-    test('no non-visible submitted together changes', () => {
-      element._submittedTogether = {changes: [change]};
-      flush();
-      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};
-      flush();
-      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};
-      flush();
-      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');
-    getPluginLoader().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');
-    getPluginLoader().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-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
new file mode 100644
index 0000000..3567d58
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -0,0 +1,850 @@
+/**
+ * @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 {ChangeStatus} from '../../../constants/constants';
+import '../../../test/common-test-setup-karma';
+import {
+  createChange,
+  createCommit,
+  createCommitInfoWithRequiredCommit,
+  createParsedChange,
+  createRelatedChangeAndCommitInfo,
+  createRevision,
+} from '../../../test/test-data-generators';
+import {
+  ChangeId,
+  ChangeInfo,
+  CommitId,
+  NumericChangeId,
+  PatchSetNum,
+  RelatedChangeAndCommitInfo,
+  RepoName,
+} from '../../../types/common';
+import {ParsedChangeInfo} from '../../../types/types';
+import './gr-related-changes-list';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
+import {
+  query,
+  queryAndAssert,
+  resetPlugins,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {GrRelatedChangesList} from './gr-related-changes-list';
+import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {PluginApi} from '../../../api/plugin';
+import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+const basicFixture = fixtureFromElement('gr-related-changes-list');
+
+suite('gr-related-changes-list tests', () => {
+  let element: GrRelatedChangesList;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('connected revisions', () => {
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      revisions: {
+        e3c6d60783bfdec9ebae7dcfec4662360433449e: createRevision(1),
+        '26e5e4c9c7ae31cbd876271cca281ce22b413997': createRevision(2),
+        bf7884d695296ca0c91702ba3e2bc8df0f69a907: createRevision(7),
+        b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3: createRevision(5),
+        d6bcee67570859ccb684873a85cf50b1f0e96fda: createRevision(6),
+        cc960918a7f90388f4a9e05753d0f7b90ad44546: createRevision(3),
+        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': createRevision(4),
+      },
+    };
+    let patchNum = 7 as PatchSetNum;
+    let relatedChanges: RelatedChangeAndCommitInfo[] = [
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '2cebeedfb1e80f4b872d0a13ade529e70652c0c8'
+          ),
+          parents: [
+            {
+              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd' as CommitId,
+              subject: 'subject1',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
+          ),
+          parents: [
+            {
+              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb' as CommitId,
+              subject: 'subject2',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
+          ),
+          parents: [
+            {
+              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae' as CommitId,
+              subject: 'subject3',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'b0ccb183494a8e340b8725a2dc553967d61e6dae'
+          ),
+          parents: [
+            {
+              commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907' as CommitId,
+              subject: 'subject4',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'bf7884d695296ca0c91702ba3e2bc8df0f69a907'
+          ),
+          parents: [
+            {
+              commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce' as CommitId,
+              subject: 'subject5',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '613bc4f81741a559c6667ac08d71dcc3348f73ce'
+          ),
+          parents: [
+            {
+              commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75' as CommitId,
+              subject: 'subject6',
+            },
+          ],
+        },
+      },
+    ];
+
+    let connectedChanges = element._computeConnectedRevisions(
+      change,
+      patchNum,
+      relatedChanges
+    );
+    assert.deepEqual(connectedChanges, [
+      '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+      'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+      '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+      '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+      '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+    ]);
+
+    patchNum = 4 as PatchSetNum;
+    relatedChanges = [
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '2cebeedfb1e80f4b872d0a13ade529e70652c0c8'
+          ),
+          parents: [
+            {
+              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
+          ),
+          parents: [
+            {
+              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
+          ),
+          parents: [
+            {
+              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b'
+          ),
+          parents: [
+            {
+              commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6'
+          ),
+          parents: [
+            {
+              commit: 'af815dac54318826b7f1fa468acc76349ffc588e' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'af815dac54318826b7f1fa468acc76349ffc588e'
+          ),
+          parents: [
+            {
+              commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+    ];
+
+    connectedChanges = element._computeConnectedRevisions(
+      change,
+      patchNum,
+      relatedChanges
+    );
+    assert.deepEqual(connectedChanges, [
+      'af815dac54318826b7f1fa468acc76349ffc588e',
+      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+      'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
+    ]);
+  });
+
+  test('_changesEqual', () => {
+    const change1: ChangeInfo = {
+      ...createChange(),
+      change_id: '123' as ChangeId,
+      _number: 0 as NumericChangeId,
+    };
+    const change2: ChangeInfo = {
+      ...createChange(),
+      change_id: '456' as ChangeId,
+      _number: 1 as NumericChangeId,
+    };
+    const change3: ChangeInfo = {
+      ...createChange(),
+      change_id: '123' as ChangeId,
+      _number: 2 as NumericChangeId,
+    };
+    const change4: RelatedChangeAndCommitInfo = {
+      ...createRelatedChangeAndCommitInfo(),
+      change_id: '123' as ChangeId,
+      _change_number: 1 as NumericChangeId,
+    };
+
+    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: ChangeInfo = {
+      ...createChange(),
+      change_id: '123' as ChangeId,
+      _number: 0 as NumericChangeId,
+    };
+    const change2: ChangeInfo = {
+      ...createChange(),
+      change_id: '456' as ChangeId,
+      _number: 1 as NumericChangeId,
+    };
+    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 as PatchSetNum;
+    element.change = {
+      ...createParsedChange(),
+      change_id: '123' as ChangeId,
+      status: ChangeStatus.NEW,
+    };
+    element.mergeable = true;
+    element.addEventListener('new-section-loaded', loadedStub);
+
+    return element.reload().then(() => {
+      assert.equal(loadedStub.callCount, 4);
+    });
+  });
+
+  suite('getChangeConflicts resolves undefined', () => {
+    let element: GrRelatedChangesList;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+    });
+
+    test('_conflicts are an empty array', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.mergeable = true;
+      element.reload();
+      assert.equal(element._conflicts.length, 0);
+    });
+  });
+
+  suite('get conflicts tests', () => {
+    let element: GrRelatedChangesList;
+    let conflictsStub: SinonStubbedMember<RestApiService['getChangeConflicts']>;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      conflictsStub = stubRestApi('getChangeConflicts').returns(
+        Promise.resolve(undefined)
+      );
+    });
+
+    test('request conflicts if open and mergeable', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.mergeable = true;
+      element.reload();
+      assert.isTrue(conflictsStub.called);
+    });
+
+    test('does not request conflicts if closed and mergeable', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+
+    test('does not request conflicts if open and not mergeable', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.mergeable = false;
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+
+    test('doesnt request conflicts if closed and not mergeable', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.mergeable = false;
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+  });
+
+  test('_calculateHasParent', () => {
+    const changeId = '123' as ChangeId;
+    const relatedChanges: RelatedChangeAndCommitInfo[] = [];
+
+    assert.equal(element._calculateHasParent(changeId, relatedChanges), false);
+
+    relatedChanges.push({
+      ...createRelatedChangeAndCommitInfo(),
+      change_id: '123' as ChangeId,
+    });
+    assert.equal(element._calculateHasParent(changeId, relatedChanges), false);
+
+    relatedChanges.push({
+      ...createRelatedChangeAndCommitInfo(),
+      change_id: '234' as ChangeId,
+    });
+    assert.equal(element._calculateHasParent(changeId, relatedChanges), true);
+  });
+
+  suite('hidden attribute and update event', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        project: 'foo/bar' as RepoName,
+        change_id: 'Ideadbeef' as ChangeId,
+        status: ChangeStatus.NEW,
+      },
+    ];
+    const relatedChanges: RelatedChangeAndCommitInfo[] = [
+      {
+        ...createCommitInfoWithRequiredCommit(),
+        project: 'foo/bar' as RepoName,
+        change_id: 'Ideadbeef' as ChangeId,
+        commit: {
+          ...createCommit(),
+          commit: 'deadbeef' as CommitId,
+          parents: [
+            {
+              commit: 'abc123' as CommitId,
+              subject: 'abc123',
+            },
+          ],
+          subject: 'do that thing',
+        },
+        _change_number: 12345 as NumericChangeId,
+        _revision_number: 1,
+        _current_revision_number: 1,
+        status: ChangeStatus.NEW,
+      },
+    ];
+
+    test('clear and empties', () => {
+      element._relatedResponse = {changes: relatedChanges};
+      element._submittedTogether = {
+        changes,
+        non_visible_changes: 0,
+      };
+      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(
+        {changes: []},
+        {changes: [], non_visible_changes: 0},
+        [],
+        [],
+        []
+      );
+      assert.isTrue(element.hidden);
+      assert.isFalse(updateHandler.called);
+
+      element._resultsChanged(
+        {changes: []},
+        {changes: [], non_visible_changes: 0},
+        [],
+        [],
+        changes
+      );
+      assert.isFalse(element.hidden);
+      assert.isTrue(updateHandler.called);
+      updateHandler.reset();
+
+      element._resultsChanged(
+        {changes: []},
+        {changes: [], non_visible_changes: 0},
+        [],
+        [],
+        []
+      );
+      assert.isTrue(element.hidden);
+      assert.isFalse(updateHandler.called);
+
+      element._resultsChanged(
+        {changes: []},
+        {changes, non_visible_changes: 0},
+        [],
+        [],
+        []
+      );
+      assert.isFalse(element.hidden);
+      assert.isTrue(updateHandler.called);
+      updateHandler.reset();
+
+      element._resultsChanged(
+        {changes: []},
+        {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: relatedChanges},
+          {changes: [], non_visible_changes: 0},
+          [],
+          [],
+          []
+        );
+        assert.isFalse(element.hidden);
+      });
+
+      test('submitted together', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged(
+          {changes: []},
+          {changes, non_visible_changes: 0},
+          [],
+          [],
+          []
+        );
+        assert.isFalse(element.hidden);
+      });
+
+      test('conflicts', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged(
+          {changes: []},
+          {changes: [], non_visible_changes: 0},
+          changes,
+          [],
+          []
+        );
+        assert.isFalse(element.hidden);
+      });
+
+      test('cherrypicks', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged(
+          {changes: []},
+          {changes: [], non_visible_changes: 0},
+          [],
+          changes,
+          []
+        );
+        assert.isFalse(element.hidden);
+      });
+
+      test('same topic', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged(
+          {changes: []},
+          {changes: [], non_visible_changes: 0},
+          [],
+          [],
+          changes
+        );
+        assert.isFalse(element.hidden);
+      });
+    });
+  });
+
+  test('_computeChangeURL uses GerritNav', () => {
+    const getUrlStub = sinon.stub(GerritNav, 'getUrlForChangeById');
+    element._computeChangeURL(
+      123 as NumericChangeId,
+      'abc/def' as RepoName,
+      12 as PatchSetNum
+    );
+    assert.isTrue(getUrlStub.called);
+  });
+
+  suite('submitted together changes', () => {
+    const change: ChangeInfo = {
+      ...createChange(),
+      project: 'foo/bar' as RepoName,
+      change_id: 'Ideadbeef' as ChangeId,
+      status: ChangeStatus.NEW,
+    };
+
+    test('_computeSubmittedTogetherClass', () => {
+      assert.strictEqual(
+        element._computeSubmittedTogetherClass(undefined),
+        'hidden'
+      );
+      assert.strictEqual(
+        element._computeSubmittedTogetherClass({
+          changes: [],
+          non_visible_changes: 0,
+        }),
+        'hidden'
+      );
+      assert.strictEqual(
+        element._computeSubmittedTogetherClass({
+          changes: [change],
+          non_visible_changes: 0,
+        }),
+        ''
+      );
+      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', () => {
+      flush();
+      assert.include(element.$.submittedTogether.className, 'hidden');
+    });
+
+    test('no non-visible submitted together changes', () => {
+      element._submittedTogether = {changes: [change], non_visible_changes: 0};
+      flush();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.isUndefined(query(element, '.note'));
+    });
+
+    test('no visible submitted together changes', () => {
+      // Technically this should never happen, but worth asserting the logic.
+      element._submittedTogether = {changes: [], non_visible_changes: 1};
+      flush();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.strictEqual(
+        queryAndAssert<HTMLDivElement>(element, '.note').innerText.trim(),
+        '(+ 1 non-visible change)'
+      );
+    });
+
+    test('visible and non-visible submitted together changes', () => {
+      element._submittedTogether = {changes: [change], non_visible_changes: 2};
+      flush();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.strictEqual(
+        queryAndAssert<HTMLDivElement>(element, '.note').innerText.trim(),
+        '(+ 2 non-visible changes)'
+      );
+    });
+  });
+
+  suite('gr-related-changes-list plugin tests', () => {
+    let element: GrRelatedChangesList;
+
+    setup(() => {
+      resetPlugins();
+      element = basicFixture.instantiate();
+    });
+
+    teardown(() => {
+      resetPlugins();
+    });
+
+    test('endpoint params', done => {
+      element.change = {...createParsedChange(), labels: {}};
+      interface RelatedChangesListGrEndpointDecorator
+        extends GrEndpointDecorator {
+        plugin: PluginApi;
+        change: ParsedChangeInfo;
+      }
+      let hookEl: RelatedChangesListGrEndpointDecorator;
+      let plugin: PluginApi;
+      pluginApi.install(
+        p => {
+          plugin = p;
+          plugin
+            .hook('related-changes-section')
+            .getLastAttached()
+            .then(el => (hookEl = el as RelatedChangesListGrEndpointDecorator));
+        },
+        '0.1',
+        'http://some/plugins/url1.html'
+      );
+      getPluginLoader().loadPlugins([]);
+      flush(() => {
+        assert.strictEqual(hookEl.plugin, plugin);
+        assert.strictEqual(hookEl.change, element.change);
+        done();
+      });
+    });
+  });
+
+  test('hiding and unhiding', done => {
+    element.change = {...createParsedChange(), labels: {}};
+    let hookEl: HTMLElement;
+    let plugin;
+
+    // No changes, and no plugin. The element is still hidden.
+    element._resultsChanged(
+      {changes: []},
+      {changes: [], non_visible_changes: 0},
+      [],
+      [],
+      []
+    );
+    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'
+    );
+    getPluginLoader().loadPlugins([]);
+    flush(() => {
+      // No changes, and plugin without hidden attribute. So it's visible.
+      element._resultsChanged(
+        {changes: []},
+        {changes: [], non_visible_changes: 0},
+        [],
+        [],
+        []
+      );
+      assert.isFalse(element.hidden);
+
+      // No changes, but plugin with true hidden attribute. So it's invisible.
+      hookEl.hidden = true;
+
+      element._resultsChanged(
+        {changes: []},
+        {changes: [], non_visible_changes: 0},
+        [],
+        [],
+        []
+      );
+      assert.isTrue(element.hidden);
+
+      // No changes, and plugin with false hidden attribute. So it's visible.
+      hookEl.hidden = false;
+      element._resultsChanged(
+        {changes: []},
+        {changes: [], non_visible_changes: 0},
+        [],
+        [],
+        []
+      );
+      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;
+      const change = createChange();
+      element._sameTopic = [change];
+      element._resultsChanged(
+        {changes: []},
+        {changes: [], non_visible_changes: 0},
+        [],
+        [],
+        [change]
+      );
+      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/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
index 35df51a..0ba794f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -129,6 +129,7 @@
   /** @override */
   disconnectedCallback() {
     window.removeEventListener('scroll', this._boundHandleWindowScroll);
+    this.$.cursorManager.unsetCursor();
     super.disconnectedCallback();
   }
 
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
index b66a1dd..bc517a8 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
@@ -27,7 +27,7 @@
       bottom: 1.25rem;
       border-radius: var(--border-radius);
       box-shadow: var(--elevation-level-2);
-      color: var(--view-background-color);
+      color: var(--tooltip-text-color);
       left: 1.25rem;
       position: fixed;
       transform: translateY(5rem);
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index 3550b02..3e85ed7 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -101,6 +101,12 @@
     };
   }
 
+  /** @override */
+  disconnectedCallback() {
+    this.$.cursor.unsetCursor();
+    super.disconnectedCallback();
+  }
+
   close() {
     this.isHidden = true;
   }
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
index d3d2481..7e00e47 100644
--- 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
@@ -93,7 +93,7 @@
   </div>
   <gr-cursor-manager
     id="cursor"
-    index="{{index}}"
+    index="[[index]]"
     cursor-target-class="selected"
     scroll-mode="never"
     focus-on-move=""
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index 4592b0c..411dac4 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -78,7 +78,7 @@
   /**
    * The index of the current target (if any). -1 otherwise.
    */
-  @property({type: Number, notify: true})
+  @property({type: Number})
   index = -1;
 
   /**
@@ -112,12 +112,6 @@
     return this.stops.filter(isTargetable);
   }
 
-  /** @override */
-  disconnectedCallback() {
-    this.unsetCursor();
-    super.disconnectedCallback();
-  }
-
   /**
    * Move the cursor forward. Clipped to the ends of the stop list.
    *
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index f0b6959..29c6bd8 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -128,6 +128,12 @@
     };
   }
 
+  /** @override */
+  disconnectedCallback() {
+    this.$.cursor.unsetCursor();
+    super.disconnectedCallback();
+  }
+
   /**
    * Handle the up key.
    */
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index 65d6d81..c4030c9 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -69,7 +69,8 @@
   reportExecution: (id: Execution, details?: EventDetails) => {
     log(`reportExecution '${id}': ${JSON.stringify(details)}`);
   },
-  trackApi: (plugin: PluginApi, object: string, method: string) => {
+  trackApi: (pluginApi: PluginApi, object: string, method: string) => {
+    const plugin = pluginApi?.getPluginName() ?? 'unknown';
     log(`trackApi '${plugin}', ${object}, ${method}`);
   },
   reportExtension: () => {},
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index ea960f0..395c9f67 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -78,6 +78,7 @@
   createConfig,
   createPreferences,
   createServerInfo,
+  createSubmittedTogetherInfo,
 } from '../test-data-generators';
 import {
   createDefaultDiffPrefs,
@@ -253,7 +254,7 @@
     return Promise.resolve([]);
   },
   getChangesSubmittedTogether(): Promise<SubmittedTogetherInfo | undefined> {
-    throw new Error('getChangesSubmittedTogether() not implemented by mock.');
+    return Promise.resolve(createSubmittedTogetherInfo());
   },
   getChangesWithSameTopic(): Promise<ChangeInfo[] | undefined> {
     return Promise.resolve([]);
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index d56e440..26547fb 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -62,6 +62,8 @@
   RequirementType,
   UrlEncodedCommentId,
   BasePatchSetNum,
+  RelatedChangeAndCommitInfo,
+  SubmittedTogetherInfo,
 } from '../types/common';
 import {
   AccountsVisibility,
@@ -201,10 +203,12 @@
   };
 }
 
-export function createCommitInfoWithRequiredCommit(): CommitInfoWithRequiredCommit {
+export function createCommitInfoWithRequiredCommit(
+  commit = 'commit'
+): CommitInfoWithRequiredCommit {
   return {
     ...createCommit(),
-    commit: 'commit' as CommitId,
+    commit: commit as CommitId,
   };
 }
 
@@ -580,3 +584,17 @@
   const threads = createCommentThreads(comments);
   return threads[0];
 }
+
+export function createRelatedChangeAndCommitInfo(): RelatedChangeAndCommitInfo {
+  return {
+    project: TEST_PROJECT_NAME,
+    commit: createCommitInfoWithRequiredCommit(),
+  };
+}
+
+export function createSubmittedTogetherInfo(): SubmittedTogetherInfo {
+  return {
+    changes: [],
+    non_visible_changes: 0,
+  };
+}