Add karma-runner and convert some tests to run with karma

The following changes are included in this change:
* karma-runner is added to the polygerrit-ui/package.json file
* bazel rule for karma tests is added to the polygerrit-ui/BUILD file
* The run_test.sh script is updated - it runs both deprecated
  web-component-tester(WCT) tests and karma tests
* Commands to run separately karma and wct tests are added to the top
  level package.json file
* The common-test-setup-karma.js file adds global functions to replace
  functionality provided by the WCT
* The postinstall step is added to the polygerrit-ui/package.json file
  to run 'selenium-standalone install' command.
  Note, that this is not a new command - it runs during the WCT
  install (see wct-local/scripts/postinstall.js), but sometimes it
  fails after switching between different branches. Calling it in the
  postinstall step of polygerrit-ui/package.json improves the
  situation.

Change-Id: Iaf480d22c5553d98eb7607a0e83a86b66f733caa
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
new file mode 100644
index 0000000..9e6b53a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
@@ -0,0 +1,2382 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../edit/gr-edit-constants.js';
+import './gr-change-view.js';
+import {PrimaryTab, SecondaryTab, ChangeStatus} from '../../../constants/constants.js';
+
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {GrEditConstants} from '../../edit/gr-edit-constants.js';
+import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {util} from '../../../scripts/util.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+
+import 'lodash/lodash.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+const fixture = fixtureFromElement('gr-change-view');
+
+suite('gr-change-view tests', () => {
+  const kb = KeyboardShortcutBinder;
+  kb.bindShortcut(kb.Shortcut.SEND_REPLY, 'ctrl+enter');
+  kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE, 'shift+r');
+  kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a');
+  kb.bindShortcut(kb.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's');
+  kb.bindShortcut(kb.Shortcut.UP_TO_DASHBOARD, 'u');
+  kb.bindShortcut(kb.Shortcut.EXPAND_ALL_MESSAGES, 'x');
+  kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+  kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ',');
+  kb.bindShortcut(kb.Shortcut.EDIT_TOPIC, 't');
+
+  let element;
+  let sandbox;
+  let navigateToChangeStub;
+  const TEST_SCROLL_TOP_PX = 100;
+
+  const ROBOT_COMMENTS_LIMIT = 10;
+
+  // TODO: should have a mock service to generate VALID fake data
+  const THREADS = [
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2,
+          robot_id: 'rb1',
+          id: 'ecf0b9fa_fe1a5f62',
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'ecf0b9fa_fe1a5f62_1',
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          id: '503008e2_0ab203ee',
+          path: '/COMMIT_MSG',
+          line: 5,
+          in_reply_to: 'ecf0b9fa_fe1a5f62',
+          updated: '2018-02-13 22:48:48.018000000',
+          message: 'draft',
+          unresolved: false,
+          __draft: true,
+          __draftID: '0.m683trwff68',
+          __editing: false,
+          patch_set: '2',
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'ecf0b9fa_fe1a5f62',
+      start_datetime: '2018-02-08 18:49:18.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 3,
+          id: 'ecf0b9fa_fe5f62',
+          robot_id: 'rb2',
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          __path: 'test.txt',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 3,
+          id: '09a9fb0a_1484e6cf',
+          side: 'PARENT',
+          updated: '2018-02-13 22:47:19.000000000',
+          message: 'Some comment on another patchset.',
+          unresolved: false,
+        },
+      ],
+      patchNum: 3,
+      path: 'test.txt',
+      rootId: '09a9fb0a_1484e6cf',
+      start_datetime: '2018-02-13 22:47:19.000000000',
+      commentSide: 'PARENT',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2,
+          id: '8caddf38_44770ec1',
+          line: 4,
+          updated: '2018-02-13 22:48:40.000000000',
+          message: 'Another unresolved comment',
+          unresolved: true,
+        },
+      ],
+      patchNum: 2,
+      path: '/COMMIT_MSG',
+      line: 4,
+      rootId: '8caddf38_44770ec1',
+      start_datetime: '2018-02-13 22:48:40.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2,
+          id: 'scaddf38_44770ec1',
+          line: 4,
+          updated: '2018-02-14 22:48:40.000000000',
+          message: 'Yet another unresolved comment',
+          unresolved: true,
+        },
+      ],
+      patchNum: 2,
+      path: '/COMMIT_MSG',
+      line: 4,
+      rootId: 'scaddf38_44770ec1',
+      start_datetime: '2018-02-14 22:48:40.000000000',
+    },
+    {
+      comments: [
+        {
+          id: 'zcf0b9fa_fe1a5f62',
+          path: '/COMMIT_MSG',
+          line: 6,
+          updated: '2018-02-15 22:48:48.018000000',
+          message: 'resolved draft',
+          unresolved: false,
+          __draft: true,
+          __draftID: '0.m683trwff68',
+          __editing: false,
+          patch_set: '2',
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 6,
+      rootId: 'zcf0b9fa_fe1a5f62',
+      start_datetime: '2018-02-09 18:49:18.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'rc1',
+          line: 5,
+          updated: '2019-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+          robot_id: 'rc1',
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'rc1',
+      start_datetime: '2019-02-08 18:49:18.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'rc2',
+          line: 5,
+          updated: '2019-03-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+          robot_id: 'rc2',
+        },
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'c2_1',
+          line: 5,
+          updated: '2019-03-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'rc2',
+      start_datetime: '2019-03-08 18:49:18.000000000',
+    },
+  ];
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-endpoint-decorator', {
+      _import: sandbox.stub().returns(Promise.resolve()),
+    });
+    // Since pluginEndpoints are global, must reset state.
+    _testOnly_resetEndpoints();
+    navigateToChangeStub = sandbox.stub(GerritNav, 'navigateToChange');
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({test: 'config'}); },
+      getAccount() { return Promise.resolve(null); },
+      getDiffComments() { return Promise.resolve({}); },
+      getDiffRobotComments() { return Promise.resolve({}); },
+      getDiffDrafts() { return Promise.resolve({}); },
+      _fetchSharedCacheURL() { return Promise.resolve({}); },
+    });
+    element = fixture.instantiate();
+    sandbox.stub(element.$.actions, 'reload').returns(Promise.resolve());
+    pluginLoader.loadPlugins([]);
+    pluginApi.install(
+        plugin => {
+          plugin.registerDynamicCustomComponent(
+              'change-view-tab-header',
+              'gr-checks-change-view-tab-header-view'
+          );
+          plugin.registerDynamicCustomComponent(
+              'change-view-tab-content',
+              'gr-checks-view'
+          );
+        },
+        '0.1',
+        'http://some/plugins/url.html'
+    );
+  });
+
+  teardown(done => {
+    flush(() => {
+      sandbox.restore();
+      done();
+    });
+  });
+
+  const getCustomCssValue =
+      cssParam => util.getComputedStyleValue(cssParam, element);
+
+  test('_handleMessageAnchorTap', () => {
+    element._changeNum = '1';
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 1,
+    };
+    const getUrlStub = sandbox.stub(GerritNav, 'getUrlForChange');
+    const replaceStateStub = sandbox.stub(history, 'replaceState');
+    element._handleMessageAnchorTap({detail: {id: 'a12345'}});
+
+    assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
+    assert.isTrue(replaceStateStub.called);
+  });
+
+  suite('plugins adding to file tab', () => {
+    setup(done => {
+      // Resolving it here instead of during setup() as other tests depend
+      // on flush() not being called during setup.
+      flush(() => done());
+    });
+
+    test('plugin added tab shows up as a dynamic endpoint', () => {
+      assert(element._dynamicTabHeaderEndpoints.includes(
+          'change-view-tab-header-url'));
+      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
+      // 3 Tabs are : Files, Plugin, Findings
+      assert.equal(paperTabs.querySelectorAll('paper-tab').length, 3);
+      assert.equal(paperTabs.querySelectorAll('paper-tab')[1].dataset.name,
+          'change-view-tab-header-url');
+    });
+
+    test('_setActivePrimaryTab switched tab correctly', done => {
+      element._setActivePrimaryTab({detail:
+          {tab: 'change-view-tab-header-url'}});
+      flush(() => {
+        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
+        done();
+      });
+    });
+
+    test('show-primary-tab switched primary tab correctly', done => {
+      element.fire('show-primary-tab', {tab: 'change-view-tab-header-url'});
+      flush(() => {
+        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
+        done();
+      });
+    });
+
+    test('param change should switch primary tab correctly', done => {
+      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+      const queryMap = new Map();
+      queryMap.set('tab', PrimaryTab.FINDINGS);
+      // view is required
+      element.params = Object.assign(
+          {
+            view: GerritNav.View.CHANGE,
+          },
+          element.params, {queryMap});
+      flush(() => {
+        assert.equal(element._activeTabs[0], PrimaryTab.FINDINGS);
+        done();
+      });
+    });
+
+    test('invalid param change should not switch primary tab', done => {
+      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+      const queryMap = new Map();
+      queryMap.set('tab', 'random');
+      // view is required
+      element.params = Object.assign(
+          {
+            view: GerritNav.View.CHANGE,
+          },
+          element.params, {queryMap});
+      flush(() => {
+        assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+        done();
+      });
+    });
+
+    test('switching tab sets _selectedTabPluginEndpoint', done => {
+      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
+      MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[1]);
+      flush(() => {
+        assert.equal(element._selectedTabPluginEndpoint,
+            'change-view-tab-content-url');
+        done();
+      });
+    });
+  });
+
+  suite('keyboard shortcuts', () => {
+    test('t to add topic', () => {
+      const editStub = sandbox.stub(element.$.metadata, 'editTopic');
+      MockInteractions.pressAndReleaseKeyOn(element, 83, null, 't');
+      assert(editStub.called);
+    });
+
+    test('S should toggle the CL star', () => {
+      const starStub = sandbox.stub(element.$.changeStar, 'toggleStar');
+      MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
+      assert(starStub.called);
+    });
+
+    test('U should navigate to root if no backPage set', () => {
+      const relativeNavStub = sandbox.stub(GerritNav,
+          'navigateToRelativeUrl');
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert.isTrue(relativeNavStub.called);
+      assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
+          GerritNav.getUrlForRoot()));
+    });
+
+    test('U should navigate to backPage if set', () => {
+      const relativeNavStub = sandbox.stub(GerritNav,
+          'navigateToRelativeUrl');
+      element.backPage = '/dashboard/self';
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert.isTrue(relativeNavStub.called);
+      assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
+          '/dashboard/self'));
+    });
+
+    test('A fires an error event when not logged in', done => {
+      sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
+      const loggedInErrorSpy = sandbox.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      flush(() => {
+        assert.isFalse(element.$.replyOverlay.opened);
+        assert.isTrue(loggedInErrorSpy.called);
+        done();
+      });
+    });
+
+    test('shift A does not open reply overlay', done => {
+      sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+      flush(() => {
+        assert.isFalse(element.$.replyOverlay.opened);
+        done();
+      });
+    });
+
+    test('A toggles overlay when logged in', done => {
+      sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates')
+          .returns(Promise.resolve({isLatest: true}));
+      element._change = {labels: {}};
+      const openSpy = sandbox.spy(element, '_openReplyDialog');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      flush(() => {
+        assert.isTrue(element.$.replyOverlay.opened);
+        element.$.replyOverlay.close();
+        assert.isFalse(element.$.replyOverlay.opened);
+        assert(openSpy.lastCall.calledWithExactly(
+            element.$.replyDialog.FocusTarget.ANY),
+        '_openReplyDialog should have been passed ANY');
+        assert.equal(openSpy.callCount, 1);
+        done();
+      });
+    });
+
+    test('fullscreen-overlay-opened hides content', () => {
+      element._loggedIn = true;
+      element._loading = false;
+      element._change = {
+        owner: {_account_id: 1},
+        labels: {},
+        actions: {
+          abandon: {
+            enabled: true,
+            label: 'Abandon',
+            method: 'POST',
+            title: 'Abandon',
+          },
+        },
+      };
+      sandbox.spy(element, '_handleHideBackgroundContent');
+      element.$.replyDialog.dispatchEvent(
+          new CustomEvent('fullscreen-overlay-opened', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleHideBackgroundContent.called);
+      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+      assert.equal(getComputedStyle(element.$.actions).display, 'flex');
+    });
+
+    test('fullscreen-overlay-closed shows content', () => {
+      element._loggedIn = true;
+      element._loading = false;
+      element._change = {
+        owner: {_account_id: 1},
+        labels: {},
+        actions: {
+          abandon: {
+            enabled: true,
+            label: 'Abandon',
+            method: 'POST',
+            title: 'Abandon',
+          },
+        },
+      };
+      sandbox.spy(element, '_handleShowBackgroundContent');
+      element.$.replyDialog.dispatchEvent(
+          new CustomEvent('fullscreen-overlay-closed', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleShowBackgroundContent.called);
+      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('expand all messages when expand-diffs fired', () => {
+      const handleExpand =
+          sandbox.stub(element.$.fileList, 'expandAllDiffs');
+      element.$.fileListHeader.dispatchEvent(
+          new CustomEvent('expand-diffs', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(handleExpand.called);
+    });
+
+    test('collapse all messages when collapse-diffs fired', () => {
+      const handleCollapse =
+      sandbox.stub(element.$.fileList, 'collapseAllDiffs');
+      element.$.fileListHeader.dispatchEvent(
+          new CustomEvent('collapse-diffs', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(handleCollapse.called);
+    });
+
+    test('X should expand all messages', done => {
+      flush(() => {
+        const handleExpand = sandbox.stub(element.messagesList,
+            'handleExpandCollapse');
+        MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'x');
+        assert(handleExpand.calledWith(true));
+        done();
+      });
+    });
+
+    test('Z should collapse all messages', done => {
+      flush(() => {
+        const handleExpand = sandbox.stub(element.messagesList,
+            'handleExpandCollapse');
+        MockInteractions.pressAndReleaseKeyOn(element, 90, null, 'z');
+        assert(handleExpand.calledWith(false));
+        done();
+      });
+    });
+
+    test('shift + R should fetch and navigate to the latest patch set',
+        done => {
+          element._changeNum = '42';
+          element._patchRange = {
+            basePatchNum: 'PARENT',
+            patchNum: 1,
+          };
+          element._change = {
+            change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+            _number: 42,
+            revisions: {
+              rev1: {_number: 1, commit: {parents: []}},
+            },
+            current_revision: 'rev1',
+            status: 'NEW',
+            labels: {},
+            actions: {},
+          };
+
+          navigateToChangeStub.restore();
+          navigateToChangeStub = sandbox.stub(GerritNav, 'navigateToChange',
+              (change, patchNum, basePatchNum) => {
+                assert.equal(change, element._change);
+                assert.isUndefined(patchNum);
+                assert.isUndefined(basePatchNum);
+                done();
+              });
+
+          MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+        });
+
+    test('d should open download overlay', () => {
+      const stub = sandbox.stub(element.$.downloadOverlay, 'open');
+      MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
+      assert.isTrue(stub.called);
+    });
+
+    test(', should open diff preferences', () => {
+      const stub = sandbox.stub(
+          element.$.fileList.$.diffPreferencesDialog, 'open');
+      element._loggedIn = false;
+      element.disableDiffPrefs = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isFalse(stub.called);
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isFalse(stub.called);
+
+      element.disableDiffPrefs = false;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isTrue(stub.called);
+    });
+
+    test('m should toggle diff mode', () => {
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const setModeStub = sandbox.stub(element.$.fileListHeader,
+          'setDiffViewMode');
+      const e = {preventDefault: () => {}};
+      flushAsynchronousOperations();
+
+      element.viewState.diffMode = 'SIDE_BY_SIDE';
+      element._handleToggleDiffMode(e);
+      assert.isTrue(setModeStub.calledWith('UNIFIED_DIFF'));
+
+      element.viewState.diffMode = 'UNIFIED_DIFF';
+      element._handleToggleDiffMode(e);
+      assert.isTrue(setModeStub.calledWith('SIDE_BY_SIDE'));
+    });
+  });
+
+  suite('reloading drafts', () => {
+    let reloadStub;
+    const drafts = {
+      'testfile.txt': [
+        {
+          patch_set: 5,
+          id: 'dd2982f5_c01c9e6a',
+          line: 1,
+          updated: '2017-11-08 18:47:45.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+      ],
+    };
+    setup(() => {
+      // Fake computeDraftCount as its required for ChangeComments,
+      // see gr-comment-api#reloadDrafts.
+      reloadStub = sandbox.stub(element.$.commentAPI, 'reloadDrafts')
+          .returns(Promise.resolve({
+            drafts,
+            getAllThreadsForChange: () => ([]),
+            computeDraftCount: () => 1,
+          }));
+    });
+
+    test('drafts are reloaded when reload-drafts fired', done => {
+      element.$.fileList.dispatchEvent(
+          new CustomEvent('reload-drafts', {
+            detail: {
+              resolve: () => {
+                assert.isTrue(reloadStub.called);
+                assert.deepEqual(element._diffDrafts, drafts);
+                done();
+              },
+            },
+            composed: true, bubbles: true,
+          }));
+    });
+
+    test('drafts are reloaded when comment-refresh fired', () => {
+      element.dispatchEvent(
+          new CustomEvent('comment-refresh', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(reloadStub.called);
+    });
+  });
+
+  suite('_recomputeComments', () => {
+    setup(() => {
+      // Fake computeDraftCount as its required for ChangeComments,
+      // see gr-comment-api#reloadDrafts.
+      sandbox.stub(element.$.commentAPI, 'reloadDrafts')
+          .returns(Promise.resolve({
+            drafts: {},
+            getAllThreadsForChange: () => THREADS,
+            computeDraftCount: () => 0,
+          }));
+    });
+
+    test('draft threads should be a new copy with correct states', done => {
+      element.$.fileList.dispatchEvent(
+          new CustomEvent('reload-drafts', {
+            detail: {
+              resolve: () => {
+                assert.equal(element._draftCommentThreads.length, 2);
+                assert.equal(
+                    element._draftCommentThreads[0].rootId,
+                    THREADS[0].rootId
+                );
+                assert.notEqual(
+                    element._draftCommentThreads[0].comments,
+                    THREADS[0].comments
+                );
+                assert.notEqual(
+                    element._draftCommentThreads[0].comments[0],
+                    THREADS[0].comments[0]
+                );
+                assert.isTrue(
+                    element._draftCommentThreads[0]
+                        .comments
+                        .slice(0, 2)
+                        .every(c => c.collapsed === true)
+                );
+
+                assert.isTrue(
+                    element._draftCommentThreads[0]
+                        .comments[2]
+                        .collapsed === false
+                );
+                done();
+              },
+            },
+            composed: true, bubbles: true,
+          }));
+    });
+  });
+
+  test('diff comments modified', () => {
+    sandbox.spy(element, '_handleReloadCommentThreads');
+    return element._reloadComments().then(() => {
+      element.dispatchEvent(
+          new CustomEvent('diff-comments-modified', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleReloadCommentThreads.called);
+    });
+  });
+
+  test('thread list modified', () => {
+    sandbox.spy(element, '_handleReloadDiffComments');
+    element._activeTabs = [PrimaryTab.FILES, SecondaryTab.COMMENT_THREADS];
+    flushAsynchronousOperations();
+
+    return element._reloadComments().then(() => {
+      element.threadList.dispatchEvent(
+          new CustomEvent('thread-list-modified', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleReloadDiffComments.called);
+
+      let draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
+          .returns(1);
+      assert.equal(element._computeTotalCommentCounts(5,
+          element._changeComments), '5 unresolved, 1 draft');
+      assert.equal(element._computeTotalCommentCounts(0,
+          element._changeComments), '1 draft');
+      draftStub.restore();
+      draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
+          .returns(0);
+      assert.equal(element._computeTotalCommentCounts(0,
+          element._changeComments), '');
+      assert.equal(element._computeTotalCommentCounts(1,
+          element._changeComments), '1 unresolved');
+      draftStub.restore();
+      draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
+          .returns(2);
+      assert.equal(element._computeTotalCommentCounts(1,
+          element._changeComments), '1 unresolved, 2 drafts');
+      draftStub.restore();
+    });
+  });
+
+  suite('thread list and change log tabs', () => {
+    setup(() => {
+      element._changeNum = '1';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2, commit: {parents: []}},
+          rev1: {_number: 1, commit: {parents: []}},
+          rev13: {_number: 13, commit: {parents: []}},
+          rev3: {_number: 3, commit: {parents: []}},
+        },
+        current_revision: 'rev3',
+        status: 'NEW',
+        labels: {
+          test: {
+            all: [],
+            default_value: 0,
+            values: [],
+            approved: {},
+          },
+        },
+      };
+      sandbox.stub(element.$.relatedChanges, 'reload');
+      sandbox.stub(element, '_reload').returns(Promise.resolve());
+      sandbox.spy(element, '_paramsChanged');
+      element.params = {view: 'change', changeNum: '1'};
+    });
+
+    test('tab switch works correctly', done => {
+      assert.isTrue(element._paramsChanged.called);
+      assert.equal(element._activeTabs[1], SecondaryTab.CHANGE_LOG);
+
+      const commentTab = element.shadowRoot.querySelector(
+          'paper-tab.commentThreads'
+      );
+      // Switch to comment thread tab
+      MockInteractions.tap(commentTab);
+      assert.equal(element._activeTabs[1], SecondaryTab.COMMENT_THREADS);
+
+      // Switch back to 'Change Log' tab
+      element._paramsChanged(element.params);
+      flush(() => {
+        assert.equal(element._activeTabs[1], SecondaryTab.CHANGE_LOG);
+        done();
+      });
+    });
+
+    test('show-secondary-tab event works', () => {
+      assert.equal(element._activeTabs[1], SecondaryTab.CHANGE_LOG);
+      // Switch to comment thread tab
+      element.fire('show-secondary-tab', {tab: SecondaryTab.COMMENT_THREADS});
+      assert.equal(element._activeTabs[1], SecondaryTab.COMMENT_THREADS);
+    });
+
+    test('param change should switched secondary tab correctly', done => {
+      assert.equal(element._activeTabs[1], SecondaryTab.CHANGE_LOG);
+      const queryMap = new Map();
+      queryMap.set('secondaryTab', SecondaryTab.COMMENT_THREADS);
+      // view is required
+      element.params = Object.assign(
+          {view: GerritNav.View.CHANGE},
+          element.params, {queryMap}
+      );
+      flush(() => {
+        assert.equal(element._activeTabs[1], SecondaryTab.COMMENT_THREADS);
+        done();
+      });
+    });
+
+    test('invalid secondaryTab should not switch tab', done => {
+      assert.equal(element._activeTabs[1], SecondaryTab.CHANGE_LOG);
+      const queryMap = new Map();
+      queryMap.set('secondaryTab', 'random');
+      // view is required
+      element.params = Object.assign({
+        view: GerritNav.View.CHANGE,
+      }, element.params, {queryMap});
+      flush(() => {
+        assert.equal(element._activeTabs[1], SecondaryTab.CHANGE_LOG);
+        done();
+      });
+    });
+  });
+
+  suite('Findings comment tab', () => {
+    setup(done => {
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2, commit: {parents: []}},
+          rev1: {_number: 1, commit: {parents: []}},
+          rev13: {_number: 13, commit: {parents: []}},
+          rev3: {_number: 3, commit: {parents: []}},
+          rev4: {_number: 4, commit: {parents: []}},
+        },
+        current_revision: 'rev4',
+      };
+      element._commentThreads = THREADS;
+      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
+      MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[2]);
+      flush(() => {
+        done();
+      });
+    });
+
+    test('robot comments count per patchset', () => {
+      const count = element._robotCommentCountPerPatchSet(THREADS);
+      const expectedCount = {
+        2: 1,
+        3: 1,
+        4: 2,
+      };
+      assert.deepEqual(count, expectedCount);
+      assert.equal(element._computeText({_number: 2}, THREADS),
+          'Patchset 2 (1 finding)');
+      assert.equal(element._computeText({_number: 4}, THREADS),
+          'Patchset 4 (2 findings)');
+      assert.equal(element._computeText({_number: 5}, THREADS),
+          'Patchset 5');
+    });
+
+    test('only robot comments are rendered', () => {
+      assert.equal(element._robotCommentThreads.length, 2);
+      assert.equal(element._robotCommentThreads[0].comments[0].robot_id,
+          'rc1');
+      assert.equal(element._robotCommentThreads[1].comments[0].robot_id,
+          'rc2');
+    });
+
+    test('changing patchsets resets robot comments', done => {
+      element.set('_change.current_revision', 'rev3');
+      flush(() => {
+        assert.equal(element._robotCommentThreads.length, 1);
+        done();
+      });
+    });
+
+    test('Show more button is hidden', () => {
+      assert.isNull(element.shadowRoot.querySelector('.show-robot-comments'));
+    });
+
+    suite('robot comments show more button', () => {
+      setup(done => {
+        const arr = [];
+        for (let i = 0; i <= 30; i++) {
+          arr.push(...THREADS);
+        }
+        element._commentThreads = arr;
+        flush(() => {
+          done();
+        });
+      });
+
+      test('Show more button is rendered', () => {
+        assert.isOk(element.shadowRoot.querySelector('.show-robot-comments'));
+        assert.equal(element._robotCommentThreads.length,
+            ROBOT_COMMENTS_LIMIT);
+      });
+
+      test('Clicking show more button renders all comments', done => {
+        MockInteractions.tap(element.shadowRoot.querySelector(
+            '.show-robot-comments'));
+        flush(() => {
+          assert.equal(element._robotCommentThreads.length, 62);
+          done();
+        });
+      });
+    });
+  });
+
+  test('reply button is not visible when logged out', () => {
+    assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
+    element._loggedIn = true;
+    assert.notEqual(getComputedStyle(element.$.replyBtn).display, 'none');
+  });
+
+  test('download tap calls _handleOpenDownloadDialog', () => {
+    sandbox.stub(element, '_handleOpenDownloadDialog');
+    element.$.actions.dispatchEvent(
+        new CustomEvent('download-tap', {
+          composed: true, bubbles: true,
+        }));
+    assert.isTrue(element._handleOpenDownloadDialog.called);
+  });
+
+  test('fetches the server config on attached', done => {
+    flush(() => {
+      assert.equal(element._serverConfig.test, 'config');
+      done();
+    });
+  });
+
+  test('_changeStatuses', () => {
+    sandbox.stub(element, 'changeStatuses').returns(
+        ['Merged', 'WIP']);
+    element._loading = false;
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev2: {_number: 2},
+        rev1: {_number: 1},
+        rev13: {_number: 13},
+        rev3: {_number: 3},
+      },
+      current_revision: 'rev3',
+      labels: {
+        test: {
+          all: [],
+          default_value: 0,
+          values: [],
+          approved: {},
+        },
+      },
+    };
+    element._mergeable = true;
+    const expectedStatuses = ['Merged', 'WIP'];
+    assert.deepEqual(element._changeStatuses, expectedStatuses);
+    assert.equal(element._changeStatus, expectedStatuses.join(', '));
+    flushAsynchronousOperations();
+    const statusChips = dom(element.root)
+        .querySelectorAll('gr-change-status');
+    assert.equal(statusChips.length, 2);
+  });
+
+  test('diff preferences open when open-diff-prefs is fired', () => {
+    const overlayOpenStub = sandbox.stub(element.$.fileList,
+        'openDiffPrefs');
+    element.$.fileListHeader.dispatchEvent(
+        new CustomEvent('open-diff-prefs', {
+          composed: true, bubbles: true,
+        }));
+    assert.isTrue(overlayOpenStub.called);
+  });
+
+  test('_prepareCommitMsgForLinkify', () => {
+    let commitMessage = 'R=test@google.com';
+    let result = element._prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'R=\u200Btest@google.com');
+
+    commitMessage = 'R=test@google.com\nR=test@google.com';
+    result = element._prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
+
+    commitMessage = 'CC=test@google.com';
+    result = element._prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'CC=\u200Btest@google.com');
+  }),
+
+  test('_isSubmitEnabled', () => {
+    assert.isFalse(element._isSubmitEnabled({}));
+    assert.isFalse(element._isSubmitEnabled({submit: {}}));
+    assert.isTrue(element._isSubmitEnabled(
+        {submit: {enabled: true}}));
+  });
+
+  test('_reload is called when an approved label is removed', () => {
+    const vote = {_account_id: 1, name: 'bojack', value: 1};
+    element._changeNum = '42';
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 1,
+    };
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      owner: {email: 'abc@def'},
+      revisions: {
+        rev2: {_number: 2, commit: {parents: []}},
+        rev1: {_number: 1, commit: {parents: []}},
+        rev13: {_number: 13, commit: {parents: []}},
+        rev3: {_number: 3, commit: {parents: []}},
+      },
+      current_revision: 'rev3',
+      status: 'NEW',
+      labels: {
+        test: {
+          all: [vote],
+          default_value: 0,
+          values: [],
+          approved: {},
+        },
+      },
+    };
+    flushAsynchronousOperations();
+    const reloadStub = sandbox.stub(element, '_reload');
+    element.splice('_change.labels.test.all', 0, 1);
+    assert.isFalse(reloadStub.called);
+    element._change.labels.test.all.push(vote);
+    element._change.labels.test.all.push(vote);
+    element._change.labels.test.approved = vote;
+    flushAsynchronousOperations();
+    element.splice('_change.labels.test.all', 0, 2);
+    assert.isTrue(reloadStub.called);
+    assert.isTrue(reloadStub.calledOnce);
+  });
+
+  test('reply button has updated count when there are drafts', () => {
+    const getLabel = element._computeReplyButtonLabel;
+
+    assert.equal(getLabel(null, false), 'Reply');
+    assert.equal(getLabel(null, true), 'Start review');
+
+    const changeRecord = {base: null};
+    assert.equal(getLabel(changeRecord, false), 'Reply');
+
+    changeRecord.base = {};
+    assert.equal(getLabel(changeRecord, false), 'Reply');
+
+    changeRecord.base = {
+      'file1.txt': [{}],
+      'file2.txt': [{}, {}],
+    };
+    assert.equal(getLabel(changeRecord, false), 'Reply (3)');
+  });
+
+  test('start review button when owner of WIP change', () => {
+    assert.equal(
+        element._computeReplyButtonLabel(null, true),
+        'Start review');
+  });
+
+  test('comment events properly update diff drafts', () => {
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 2,
+    };
+    const draft = {
+      __draft: true,
+      id: 'id1',
+      path: '/foo/bar.txt',
+      text: 'hello',
+    };
+    element._handleCommentSave({detail: {comment: draft}});
+    draft.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+    draft.patch_set = null;
+    draft.text = 'hello, there';
+    element._handleCommentSave({detail: {comment: draft}});
+    draft.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+    const draft2 = {
+      __draft: true,
+      id: 'id2',
+      path: '/foo/bar.txt',
+      text: 'hola',
+    };
+    element._handleCommentSave({detail: {comment: draft2}});
+    draft2.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
+    draft.patch_set = null;
+    element._handleCommentDiscard({detail: {comment: draft}});
+    draft.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
+    element._handleCommentDiscard({detail: {comment: draft2}});
+    assert.deepEqual(element._diffDrafts, {});
+  });
+
+  test('change num change', () => {
+    element._changeNum = null;
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 2,
+    };
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      labels: {},
+    };
+    element.viewState.changeNum = null;
+    element.viewState.diffMode = 'UNIFIED';
+    assert.equal(element.viewState.numFilesShown, 200);
+    assert.equal(element._numFilesShown, 200);
+    element._numFilesShown = 150;
+    flushAsynchronousOperations();
+    assert.equal(element.viewState.diffMode, 'UNIFIED');
+    assert.equal(element.viewState.numFilesShown, 150);
+
+    element._changeNum = '1';
+    element.params = {changeNum: '1'};
+    element._change.newProp = '1';
+    flushAsynchronousOperations();
+    assert.equal(element.viewState.diffMode, 'UNIFIED');
+    assert.equal(element.viewState.changeNum, '1');
+
+    element._changeNum = '2';
+    element.params = {changeNum: '2'};
+    element._change.newProp = '2';
+    flushAsynchronousOperations();
+    assert.equal(element.viewState.diffMode, 'UNIFIED');
+    assert.equal(element.viewState.changeNum, '2');
+    assert.equal(element.viewState.numFilesShown, 200);
+    assert.equal(element._numFilesShown, 200);
+  });
+
+  test('_setDiffViewMode is called with reset when new change is loaded',
+      () => {
+        sandbox.stub(element, '_setDiffViewMode');
+        element.viewState = {changeNum: 1};
+        element._changeNum = 2;
+        element._resetFileListViewState();
+        assert.isTrue(
+            element._setDiffViewMode.lastCall.calledWithExactly(true));
+      });
+
+  test('diffViewMode is propagated from file list header', () => {
+    element.viewState = {diffMode: 'UNIFIED'};
+    element.$.fileListHeader.diffViewMode = 'SIDE_BY_SIDE';
+    assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+  });
+
+  test('diffMode defaults to side by side without preferences', done => {
+    sandbox.stub(element.$.restAPI, 'getPreferences').returns(
+        Promise.resolve({}));
+    // No user prefs or diff view mode set.
+
+    element._setDiffViewMode().then(() => {
+      assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+      done();
+    });
+  });
+
+  test('diffMode defaults to preference when not already set', done => {
+    sandbox.stub(element.$.restAPI, 'getPreferences').returns(
+        Promise.resolve({default_diff_view: 'UNIFIED'}));
+
+    element._setDiffViewMode().then(() => {
+      assert.equal(element.viewState.diffMode, 'UNIFIED');
+      done();
+    });
+  });
+
+  test('existing diffMode overrides preference', done => {
+    element.viewState.diffMode = 'SIDE_BY_SIDE';
+    sandbox.stub(element.$.restAPI, 'getPreferences').returns(
+        Promise.resolve({default_diff_view: 'UNIFIED'}));
+    element._setDiffViewMode().then(() => {
+      assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+      done();
+    });
+  });
+
+  test('don’t reload entire page when patchRange changes', () => {
+    const reloadStub = sandbox.stub(element, '_reload',
+        () => Promise.resolve());
+    const reloadPatchDependentStub = sandbox.stub(element,
+        '_reloadPatchNumDependentResources',
+        () => Promise.resolve());
+    const relatedClearSpy = sandbox.spy(element.$.relatedChanges, 'clear');
+    const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
+
+    const value = {
+      view: GerritNav.View.CHANGE,
+      patchNum: '1',
+    };
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledOnce);
+    assert.isTrue(relatedClearSpy.calledOnce);
+
+    element._initialLoadComplete = true;
+
+    value.basePatchNum = '1';
+    value.patchNum = '2';
+    element._paramsChanged(value);
+    assert.isFalse(reloadStub.calledTwice);
+    assert.isTrue(reloadPatchDependentStub.calledOnce);
+    assert.isTrue(relatedClearSpy.calledOnce);
+    assert.isTrue(collapseStub.calledTwice);
+  });
+
+  test('reload entire page when patchRange doesnt change', () => {
+    const reloadStub = sandbox.stub(element, '_reload',
+        () => Promise.resolve());
+    const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
+    const value = {
+      view: GerritNav.View.CHANGE,
+    };
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledOnce);
+    element._initialLoadComplete = true;
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledTwice);
+    assert.isTrue(collapseStub.calledTwice);
+  });
+
+  test('related changes are updated and new patch selected after rebase',
+      done => {
+        element._changeNum = '42';
+        sandbox.stub(element, 'computeLatestPatchNum', () => 1);
+        sandbox.stub(element, '_reload',
+            () => Promise.resolve());
+        const e = {detail: {action: 'rebase'}};
+        element._handleReloadChange(e).then(() => {
+          assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
+              element._change));
+          done();
+        });
+      });
+
+  test('related changes are not updated after other action', done => {
+    sandbox.stub(element, '_reload', () => Promise.resolve());
+    sandbox.stub(element.$.relatedChanges, 'reload');
+    const e = {detail: {action: 'abandon'}};
+    element._handleReloadChange(e).then(() => {
+      assert.isFalse(navigateToChangeStub.called);
+      done();
+    });
+  });
+
+  test('_computeMergedCommitInfo', () => {
+    const dummyRevs = {
+      1: {commit: {commit: 1}},
+      2: {commit: {}},
+    };
+    assert.deepEqual(element._computeMergedCommitInfo(0, dummyRevs), {});
+    assert.deepEqual(element._computeMergedCommitInfo(1, dummyRevs),
+        dummyRevs[1].commit);
+
+    // Regression test for issue 5337.
+    const commit = element._computeMergedCommitInfo(2, dummyRevs);
+    assert.notDeepEqual(commit, dummyRevs[2]);
+    assert.deepEqual(commit, {commit: 2});
+  });
+
+  test('_computeCopyTextForTitle', () => {
+    const change = {
+      _number: 123,
+      subject: 'test subject',
+      revisions: {
+        rev1: {_number: 1},
+        rev3: {_number: 3},
+      },
+      current_revision: 'rev3',
+    };
+    sandbox.stub(GerritNav, 'getUrlForChange')
+        .returns('/change/123');
+    assert.equal(
+        element._computeCopyTextForTitle(change),
+        `123: test subject | http://${location.host}/change/123`
+    );
+  });
+
+  test('get latest revision', () => {
+    let change = {
+      revisions: {
+        rev1: {_number: 1},
+        rev3: {_number: 3},
+      },
+      current_revision: 'rev3',
+    };
+    assert.equal(element._getLatestRevisionSHA(change), 'rev3');
+    change = {
+      revisions: {
+        rev1: {_number: 1},
+      },
+    };
+    assert.equal(element._getLatestRevisionSHA(change), 'rev1');
+  });
+
+  test('show commit message edit button', () => {
+    const _change = {
+      status: ChangeStatus.MERGED,
+    };
+    assert.isTrue(element._computeHideEditCommitMessage(false, false, {}));
+    assert.isTrue(element._computeHideEditCommitMessage(true, true, {}));
+    assert.isTrue(element._computeHideEditCommitMessage(false, true, {}));
+    assert.isFalse(element._computeHideEditCommitMessage(true, false, {}));
+    assert.isTrue(element._computeHideEditCommitMessage(true, false,
+        _change));
+    assert.isTrue(element._computeHideEditCommitMessage(true, false, {},
+        true));
+    assert.isFalse(element._computeHideEditCommitMessage(true, false, {},
+        false));
+  });
+
+  test('_handleCommitMessageSave trims trailing whitespace', () => {
+    const putStub = sandbox.stub(element.$.restAPI, 'putChangeCommitMessage')
+        .returns(Promise.resolve({}));
+
+    const mockEvent = content => { return {detail: {content}}; };
+
+    element._handleCommitMessageSave(mockEvent('test \n  test '));
+    assert.equal(putStub.lastCall.args[1], 'test\n  test');
+
+    element._handleCommitMessageSave(mockEvent('  test\ntest'));
+    assert.equal(putStub.lastCall.args[1], '  test\ntest');
+
+    element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
+    assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
+  });
+
+  test('_computeChangeIdCommitMessageError', () => {
+    let commitMessage =
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
+    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        null);
+
+    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'mismatch');
+
+    commitMessage = 'This is the greatest change.';
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'missing');
+  });
+
+  test('multiple change Ids in commit message picks last', () => {
+    const commitMessage = [
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    ].join('\n');
+    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        null);
+    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'mismatch');
+  });
+
+  test('does not count change Id that starts mid line', () => {
+    const commitMessage = [
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    ].join(' and ');
+    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        null);
+    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'mismatch');
+  });
+
+  test('_computeTitleAttributeWarning', () => {
+    let changeIdCommitMessageError = 'missing';
+    assert.equal(
+        element._computeTitleAttributeWarning(changeIdCommitMessageError),
+        'No Change-Id in commit message');
+
+    changeIdCommitMessageError = 'mismatch';
+    assert.equal(
+        element._computeTitleAttributeWarning(changeIdCommitMessageError),
+        'Change-Id mismatch');
+  });
+
+  test('_computeChangeIdClass', () => {
+    let changeIdCommitMessageError = 'missing';
+    assert.equal(
+        element._computeChangeIdClass(changeIdCommitMessageError), '');
+
+    changeIdCommitMessageError = 'mismatch';
+    assert.equal(
+        element._computeChangeIdClass(changeIdCommitMessageError), 'warning');
+  });
+
+  test('topic is coalesced to null', done => {
+    sandbox.stub(element, '_changeChanged');
+    sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
+      id: '123456789',
+      labels: {},
+      current_revision: 'foo',
+      revisions: {foo: {commit: {}}},
+    }));
+
+    element._getChangeDetail().then(() => {
+      assert.isNull(element._change.topic);
+      done();
+    });
+  });
+
+  test('commit sha is populated from getChangeDetail', done => {
+    sandbox.stub(element, '_changeChanged');
+    sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
+      id: '123456789',
+      labels: {},
+      current_revision: 'foo',
+      revisions: {foo: {commit: {}}},
+    }));
+
+    element._getChangeDetail().then(() => {
+      assert.equal('foo', element._commitInfo.commit);
+      done();
+    });
+  });
+
+  test('edit is added to change', () => {
+    sandbox.stub(element, '_changeChanged');
+    sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
+      id: '123456789',
+      labels: {},
+      current_revision: 'foo',
+      revisions: {foo: {commit: {}}},
+    }));
+    sandbox.stub(element, '_getEdit', () => Promise.resolve({
+      base_patch_set_number: 1,
+      commit: {commit: 'bar'},
+    }));
+    element._patchRange = {};
+
+    return element._getChangeDetail().then(() => {
+      const revs = element._change.revisions;
+      assert.equal(Object.keys(revs).length, 2);
+      assert.deepEqual(revs['foo'], {commit: {commit: 'foo'}});
+      assert.deepEqual(revs['bar'], {
+        _number: element.EDIT_NAME,
+        basePatchNum: 1,
+        commit: {commit: 'bar'},
+        fetch: undefined,
+      });
+    });
+  });
+
+  test('_getBasePatchNum', () => {
+    const _change = {
+      _number: 42,
+      revisions: {
+        '98da160735fb81604b4c40e93c368f380539dd0e': {
+          _number: 1,
+          commit: {
+            parents: [],
+          },
+        },
+      },
+    };
+    const _patchRange = {
+      basePatchNum: 'PARENT',
+    };
+    assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');
+
+    element._prefs = {
+      default_base_for_merges: 'FIRST_PARENT',
+    };
+
+    const _change2 = {
+      _number: 42,
+      revisions: {
+        '98da160735fb81604b4c40e93c368f380539dd0e': {
+          _number: 1,
+          commit: {
+            parents: [
+              {
+                commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8',
+                subject: 'test',
+              },
+              {
+                commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841',
+                subject: 'test3',
+              },
+            ],
+          },
+        },
+      },
+    };
+    assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
+
+    _patchRange.patchNum = 1;
+    assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
+  });
+
+  test('_openReplyDialog called with `ANY` when coming from tap event',
+      () => {
+        const openStub = sandbox.stub(element, '_openReplyDialog');
+        element._serverConfig = {};
+        MockInteractions.tap(element.$.replyBtn);
+        assert(openStub.lastCall.calledWithExactly(
+            element.$.replyDialog.FocusTarget.ANY),
+        '_openReplyDialog should have been passed ANY');
+        assert.equal(openStub.callCount, 1);
+      });
+
+  test('_openReplyDialog called with `BODY` when coming from message reply' +
+      'event', done => {
+    flush(() => {
+      const openStub = sandbox.stub(element, '_openReplyDialog');
+      element.messagesList.dispatchEvent(
+          new CustomEvent('reply', {
+            detail:
+          {message: {message: 'text'}},
+            composed: true, bubbles: true,
+          }));
+      assert(openStub.lastCall.calledWithExactly(
+          element.$.replyDialog.FocusTarget.BODY),
+      '_openReplyDialog should have been passed BODY');
+      assert.equal(openStub.callCount, 1);
+      done();
+    });
+  });
+
+  test('reply dialog focus can be controlled', () => {
+    const FocusTarget = element.$.replyDialog.FocusTarget;
+    const openStub = sandbox.stub(element, '_openReplyDialog');
+
+    const e = {detail: {}};
+    element._handleShowReplyDialog(e);
+    assert(openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
+        '_openReplyDialog should have been passed REVIEWERS');
+    assert.equal(openStub.callCount, 1);
+
+    e.detail.value = {ccsOnly: true};
+    element._handleShowReplyDialog(e);
+    assert(openStub.lastCall.calledWithExactly(FocusTarget.CCS),
+        '_openReplyDialog should have been passed CCS');
+    assert.equal(openStub.callCount, 2);
+  });
+
+  test('getUrlParameter functionality', () => {
+    const locationStub = sandbox.stub(element, '_getLocationSearch');
+
+    locationStub.returns('?test');
+    assert.equal(element._getUrlParameter('test'), 'test');
+    locationStub.returns('?test2=12&test=3');
+    assert.equal(element._getUrlParameter('test'), 'test');
+    locationStub.returns('');
+    assert.isNull(element._getUrlParameter('test'));
+    locationStub.returns('?');
+    assert.isNull(element._getUrlParameter('test'));
+    locationStub.returns('?test2');
+    assert.isNull(element._getUrlParameter('test'));
+  });
+
+  test('revert dialog opened with revert param', done => {
+    sandbox.stub(element.$.restAPI, 'getLoggedIn', () => Promise.resolve(true));
+    sandbox.stub(pluginLoader, 'awaitPluginsLoaded', () => Promise.resolve());
+
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 2,
+    };
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1, commit: {parents: []}},
+        rev2: {_number: 2, commit: {parents: []}},
+      },
+      current_revision: 'rev1',
+      status: ChangeStatus.MERGED,
+      labels: {},
+      actions: {},
+    };
+
+    sandbox.stub(element, '_getUrlParameter',
+        param => {
+          assert.equal(param, 'revert');
+          return param;
+        });
+
+    sandbox.stub(element.$.actions, 'showRevertDialog',
+        done);
+
+    element._maybeShowRevertDialog();
+    assert.isTrue(pluginLoader.awaitPluginsLoaded.called);
+  });
+
+  suite('scroll related tests', () => {
+    test('document scrolling calls function to set scroll height', done => {
+      const originalHeight = document.body.scrollHeight;
+      const scrollStub = sandbox.stub(element, '_handleScroll',
+          () => {
+            assert.isTrue(scrollStub.called);
+            document.body.style.height = originalHeight + 'px';
+            scrollStub.restore();
+            done();
+          });
+      document.body.style.height = '10000px';
+      element._handleScroll();
+    });
+
+    test('scrollTop is set correctly', () => {
+      element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
+
+      sandbox.stub(element, '_reload', () => {
+        // When element is reloaded, ensure that the history
+        // state has the scrollTop set earlier. This will then
+        // be reset.
+        assert.isTrue(element.viewState.scrollTop == TEST_SCROLL_TOP_PX);
+        return Promise.resolve({});
+      });
+
+      // simulate reloading component, which is done when route
+      // changes to match a regex of change view type.
+      element._paramsChanged({view: GerritNav.View.CHANGE});
+    });
+
+    test('scrollTop is reset when new change is loaded', () => {
+      element._resetFileListViewState();
+      assert.equal(element.viewState.scrollTop, 0);
+    });
+  });
+
+  suite('reply dialog tests', () => {
+    setup(() => {
+      sandbox.stub(element.$.replyDialog, '_draftChanged');
+      sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates',
+          () => Promise.resolve({isLatest: true}));
+      element._change = {labels: {}};
+    });
+
+    test('reply from comment adds quote text', () => {
+      const e = {detail: {message: {message: 'quote text'}}};
+      element._handleMessageReply(e);
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+
+    test('reply from comment replaces quote text', () => {
+      element.$.replyDialog.draft = '> old quote text\n\n some draft text';
+      element.$.replyDialog.quote = '> old quote text\n\n';
+      const e = {detail: {message: {message: 'quote text'}}};
+      element._handleMessageReply(e);
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+
+    test('reply from same comment preserves quote text', () => {
+      element.$.replyDialog.draft = '> quote text\n\n some draft text';
+      element.$.replyDialog.quote = '> quote text\n\n';
+      const e = {detail: {message: {message: 'quote text'}}};
+      element._handleMessageReply(e);
+      assert.equal(element.$.replyDialog.draft,
+          '> quote text\n\n some draft text');
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+
+    test('reply from top of page contains previous draft', () => {
+      const div = document.createElement('div');
+      element.$.replyDialog.draft = '> quote text\n\n some draft text';
+      element.$.replyDialog.quote = '> quote text\n\n';
+      const e = {target: div, preventDefault: sandbox.spy()};
+      element._handleReplyTap(e);
+      assert.equal(element.$.replyDialog.draft,
+          '> quote text\n\n some draft text');
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+  });
+
+  test('reply button is disabled until server config is loaded', () => {
+    assert.isTrue(element._replyDisabled);
+    element._serverConfig = {};
+    assert.isFalse(element._replyDisabled);
+  });
+
+  suite('commit message expand/collapse', () => {
+    setup(() => {
+      sandbox.stub(element, 'fetchChangeUpdates',
+          () => Promise.resolve({isLatest: false}));
+    });
+
+    test('commitCollapseToggle hidden for short commit message', () => {
+      element._latestCommitMessage = '';
+      assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
+    });
+
+    test('commitCollapseToggle shown for long commit message', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
+    });
+
+    test('commitCollapseToggle functions', () => {
+      element._latestCommitMessage = _.times(35, String).join('\n');
+      assert.isTrue(element._commitCollapsed);
+      assert.isTrue(element._commitCollapsible);
+      assert.isTrue(
+          element.$.commitMessageEditor.hasAttribute('collapsed'));
+      MockInteractions.tap(element.$.commitCollapseToggleButton);
+      assert.isFalse(element._commitCollapsed);
+      assert.isTrue(element._commitCollapsible);
+      assert.isFalse(
+          element.$.commitMessageEditor.hasAttribute('collapsed'));
+    });
+  });
+
+  suite('related changes expand/collapse', () => {
+    let updateHeightSpy;
+    setup(() => {
+      updateHeightSpy = sandbox.spy(element, '_updateRelatedChangeMaxHeight');
+    });
+
+    test('relatedChangesToggle shown height greater than changeInfo height',
+        () => {
+          assert.isFalse(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          sandbox.stub(element, '_getOffsetHeight', () => 50);
+          sandbox.stub(element, '_getScrollHeight', () => 60);
+          sandbox.stub(element, '_getLineHeight', () => 5);
+          sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
+          element.$.relatedChanges.dispatchEvent(
+              new CustomEvent('new-section-loaded'));
+          assert.isTrue(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          assert.equal(updateHeightSpy.callCount, 1);
+        });
+
+    test('relatedChangesToggle hidden height less than changeInfo height',
+        () => {
+          assert.isFalse(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          sandbox.stub(element, '_getOffsetHeight', () => 50);
+          sandbox.stub(element, '_getScrollHeight', () => 40);
+          sandbox.stub(element, '_getLineHeight', () => 5);
+          sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
+          element.$.relatedChanges.dispatchEvent(
+              new CustomEvent('new-section-loaded'));
+          assert.isFalse(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          assert.equal(updateHeightSpy.callCount, 1);
+        });
+
+    test('relatedChangesToggle functions', () => {
+      sandbox.stub(element, '_getOffsetHeight', () => 50);
+      sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
+      element._relatedChangesLoading = false;
+      assert.isTrue(element._relatedChangesCollapsed);
+      assert.isTrue(
+          element.$.relatedChanges.classList.contains('collapsed'));
+      MockInteractions.tap(element.$.relatedChangesToggleButton);
+      assert.isFalse(element._relatedChangesCollapsed);
+      assert.isFalse(
+          element.$.relatedChanges.classList.contains('collapsed'));
+    });
+
+    test('_updateRelatedChangeMaxHeight without commit toggle', () => {
+      sandbox.stub(element, '_getOffsetHeight', () => 50);
+      sandbox.stub(element, '_getLineHeight', () => 12);
+      sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
+
+      // 50 (existing height) - 30 (extra height) = 20 (adjusted height).
+      // 20 (max existing height)  % 12 (line height) = 6 (remainder).
+      // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).
+
+      element._updateRelatedChangeMaxHeight();
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '12px');
+      assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
+          '');
+    });
+
+    test('_updateRelatedChangeMaxHeight with commit toggle', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      sandbox.stub(element, '_getOffsetHeight', () => 50);
+      sandbox.stub(element, '_getLineHeight', () => 12);
+      sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
+
+      // 50 (existing height) % 12 (line height) = 2 (remainder).
+      // 50 (existing height)  - 2 (remainder) = 48 (max height to set).
+
+      element._updateRelatedChangeMaxHeight();
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '48px');
+      assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
+          '2px');
+    });
+
+    test('_updateRelatedChangeMaxHeight in small screen mode', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      sandbox.stub(element, '_getOffsetHeight', () => 50);
+      sandbox.stub(element, '_getLineHeight', () => 12);
+      sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
+
+      element._updateRelatedChangeMaxHeight();
+
+      // 400 (new height) % 12 (line height) = 4 (remainder).
+      // 400 (new height) - 4 (remainder) = 396.
+
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '396px');
+    });
+
+    test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      sandbox.stub(element, '_getOffsetHeight', () => 50);
+      sandbox.stub(element, '_getLineHeight', () => 12);
+      sandbox.stub(window, 'matchMedia', () => {
+        if (window.matchMedia.lastCall.args[0] === '(max-width: 75em)') {
+          return {matches: true};
+        } else {
+          return {matches: false};
+        }
+      });
+
+      // 100 (new height) % 12 (line height) = 4 (remainder).
+      // 100 (new height) - 4 (remainder) = 96.
+      element._updateRelatedChangeMaxHeight();
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '96px');
+    });
+
+    suite('update checks', () => {
+      setup(() => {
+        sandbox.spy(element, '_startUpdateCheckTimer');
+        sandbox.stub(element, 'async', f => {
+          // Only fire the async callback one time.
+          if (element.async.callCount > 1) { return; }
+          f.call(element);
+        });
+      });
+
+      test('_startUpdateCheckTimer negative delay', () => {
+        sandbox.stub(element, 'fetchChangeUpdates');
+
+        element._serverConfig = {change: {update_delay: -1}};
+
+        assert.isTrue(element._startUpdateCheckTimer.called);
+        assert.isFalse(element.fetchChangeUpdates.called);
+      });
+
+      test('_startUpdateCheckTimer up-to-date', () => {
+        sandbox.stub(element, 'fetchChangeUpdates',
+            () => Promise.resolve({isLatest: true}));
+
+        element._serverConfig = {change: {update_delay: 12345}};
+
+        assert.isTrue(element._startUpdateCheckTimer.called);
+        assert.isTrue(element.fetchChangeUpdates.called);
+        assert.equal(element.async.lastCall.args[1], 12345 * 1000);
+      });
+
+      test('_startUpdateCheckTimer out-of-date shows an alert', done => {
+        sandbox.stub(element, 'fetchChangeUpdates',
+            () => Promise.resolve({isLatest: false}));
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message,
+              'A newer patch set has been uploaded');
+          done();
+        });
+        element._serverConfig = {change: {update_delay: 12345}};
+      });
+
+      test('_startUpdateCheckTimer new status shows an alert', done => {
+        sandbox.stub(element, 'fetchChangeUpdates')
+            .returns(Promise.resolve({
+              isLatest: true,
+              newStatus: ChangeStatus.MERGED,
+            }));
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message, 'This change has been merged');
+          done();
+        });
+        element._serverConfig = {change: {update_delay: 12345}};
+      });
+
+      test('_startUpdateCheckTimer new messages shows an alert', done => {
+        sandbox.stub(element, 'fetchChangeUpdates')
+            .returns(Promise.resolve({
+              isLatest: true,
+              newMessages: true,
+            }));
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message,
+              'There are new messages on this change');
+          done();
+        });
+        element._serverConfig = {change: {update_delay: 12345}};
+      });
+    });
+
+    test('canStartReview computation', () => {
+      const change1 = {};
+      const change2 = {
+        actions: {
+          ready: {
+            enabled: true,
+          },
+        },
+      };
+      const change3 = {
+        actions: {
+          ready: {
+            label: 'Ready for Review',
+          },
+        },
+      };
+      assert.isFalse(element._computeCanStartReview(change1));
+      assert.isTrue(element._computeCanStartReview(change2));
+      assert.isFalse(element._computeCanStartReview(change3));
+    });
+  });
+
+  test('header class computation', () => {
+    assert.equal(element._computeHeaderClass(), 'header');
+    assert.equal(element._computeHeaderClass(true), 'header editMode');
+  });
+
+  test('_maybeScrollToMessage', done => {
+    flush(() => {
+      const scrollStub = sandbox.stub(element.messagesList,
+          'scrollToMessage');
+
+      element._maybeScrollToMessage('');
+      assert.isFalse(scrollStub.called);
+      element._maybeScrollToMessage('message');
+      assert.isFalse(scrollStub.called);
+      element._maybeScrollToMessage('#message-TEST');
+      assert.isTrue(scrollStub.called);
+      assert.equal(scrollStub.lastCall.args[0], 'TEST');
+      done();
+    });
+  });
+
+  test('topic update reloads related changes', () => {
+    sandbox.stub(element.$.relatedChanges, 'reload');
+    element.dispatchEvent(new CustomEvent('topic-changed'));
+    assert.isTrue(element.$.relatedChanges.reload.calledOnce);
+  });
+
+  test('_computeEditMode', () => {
+    const callCompute = (range, params) =>
+      element._computeEditMode({base: range}, {base: params});
+    assert.isFalse(callCompute({}, {}));
+    assert.isTrue(callCompute({}, {edit: true}));
+    assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}, {}));
+    assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}, {}));
+    assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}, {}));
+  });
+
+  test('_processEdit', () => {
+    element._patchRange = {};
+    const change = {
+      current_revision: 'foo',
+      revisions: {foo: {commit: {}, actions: {cherrypick: {enabled: true}}}},
+    };
+    let mockChange;
+
+    // With no edit, mockChange should be unmodified.
+    element._processEdit(mockChange = _.cloneDeep(change), null);
+    assert.deepEqual(mockChange, change);
+
+    // When edit is not based on the latest PS, current_revision should be
+    // unmodified.
+    const edit = {
+      base_patch_set_number: 1,
+      commit: {commit: 'bar'},
+      fetch: true,
+    };
+    element._processEdit(mockChange = _.cloneDeep(change), edit);
+    assert.notDeepEqual(mockChange, change);
+    assert.equal(mockChange.revisions.bar._number, element.EDIT_NAME);
+    assert.equal(mockChange.current_revision, change.current_revision);
+    assert.deepEqual(mockChange.revisions.bar.commit, {commit: 'bar'});
+    assert.notOk(mockChange.revisions.bar.actions);
+
+    edit.base_revision = 'foo';
+    element._processEdit(mockChange = _.cloneDeep(change), edit);
+    assert.notDeepEqual(mockChange, change);
+    assert.equal(mockChange.current_revision, 'bar');
+    assert.deepEqual(mockChange.revisions.bar.actions,
+        mockChange.revisions.foo.actions);
+
+    // If _patchRange.patchNum is defined, do not load edit.
+    element._patchRange.patchNum = 'baz';
+    change.current_revision = 'baz';
+    element._processEdit(mockChange = _.cloneDeep(change), edit);
+    assert.equal(element._patchRange.patchNum, 'baz');
+    assert.notOk(mockChange.revisions.bar.actions);
+  });
+
+  test('file-action-tap handling', () => {
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 1,
+    };
+    const fileList = element.$.fileList;
+    const Actions = GrEditConstants.Actions;
+    const controls = element.$.fileListHeader.$.editControls;
+    sandbox.stub(controls, 'openDeleteDialog');
+    sandbox.stub(controls, 'openRenameDialog');
+    sandbox.stub(controls, 'openRestoreDialog');
+    sandbox.stub(GerritNav, 'getEditUrlForDiff');
+    sandbox.stub(GerritNav, 'navigateToRelativeUrl');
+
+    // Delete
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.DELETE.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(controls.openDeleteDialog.called);
+    assert.equal(controls.openDeleteDialog.lastCall.args[0], 'foo');
+
+    // Restore
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.RESTORE.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(controls.openRestoreDialog.called);
+    assert.equal(controls.openRestoreDialog.lastCall.args[0], 'foo');
+
+    // Rename
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.RENAME.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(controls.openRenameDialog.called);
+    assert.equal(controls.openRenameDialog.lastCall.args[0], 'foo');
+
+    // Open
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.OPEN.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(GerritNav.getEditUrlForDiff.called);
+    assert.equal(GerritNav.getEditUrlForDiff.lastCall.args[1], 'foo');
+    assert.equal(GerritNav.getEditUrlForDiff.lastCall.args[2], '1');
+    assert.isTrue(GerritNav.navigateToRelativeUrl.called);
+  });
+
+  test('_selectedRevision updates when patchNum is changed', () => {
+    const revision1 = {_number: 1, commit: {parents: []}};
+    const revision2 = {_number: 2, commit: {parents: []}};
+    sandbox.stub(element.$.restAPI, 'getChangeDetail').returns(
+        Promise.resolve({
+          revisions: {
+            aaa: revision1,
+            bbb: revision2,
+          },
+          labels: {},
+          actions: {},
+          current_revision: 'bbb',
+          change_id: 'loremipsumdolorsitamet',
+        }));
+    sandbox.stub(element, '_getEdit').returns(Promise.resolve());
+    sandbox.stub(element, '_getPreferences').returns(Promise.resolve({}));
+    element._patchRange = {patchNum: '2'};
+    return element._getChangeDetail().then(() => {
+      assert.strictEqual(element._selectedRevision, revision2);
+
+      element.set('_patchRange.patchNum', '1');
+      assert.strictEqual(element._selectedRevision, revision1);
+    });
+  });
+
+  test('_selectedRevision is assigned when patchNum is edit', () => {
+    const revision1 = {_number: 1, commit: {parents: []}};
+    const revision2 = {_number: 2, commit: {parents: []}};
+    const revision3 = {_number: 'edit', commit: {parents: []}};
+    sandbox.stub(element.$.restAPI, 'getChangeDetail').returns(
+        Promise.resolve({
+          revisions: {
+            aaa: revision1,
+            bbb: revision2,
+            ccc: revision3,
+          },
+          labels: {},
+          actions: {},
+          current_revision: 'ccc',
+          change_id: 'loremipsumdolorsitamet',
+        }));
+    sandbox.stub(element, '_getEdit').returns(Promise.resolve());
+    sandbox.stub(element, '_getPreferences').returns(Promise.resolve({}));
+    element._patchRange = {patchNum: 'edit'};
+    return element._getChangeDetail().then(() => {
+      assert.strictEqual(element._selectedRevision, revision3);
+    });
+  });
+
+  test('_sendShowChangeEvent', () => {
+    element._change = {labels: {}};
+    element._patchRange = {patchNum: 4};
+    element._mergeable = true;
+    const showStub = sandbox.stub(element.$.jsAPI, 'handleEvent');
+    element._sendShowChangeEvent();
+    assert.isTrue(showStub.calledOnce);
+    assert.equal(
+        showStub.lastCall.args[0], element.$.jsAPI.EventType.SHOW_CHANGE);
+    assert.deepEqual(showStub.lastCall.args[1], {
+      change: {labels: {}},
+      patchNum: 4,
+      info: {mergeable: true},
+    });
+  });
+
+  suite('_handleEditTap', () => {
+    let fireEdit;
+
+    setup(() => {
+      fireEdit = () => {
+        element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
+      };
+      navigateToChangeStub.restore();
+
+      element._change = {revisions: {rev1: {_number: 1}}};
+    });
+
+    test('edit exists in revisions', done => {
+      sandbox.stub(GerritNav, 'navigateToChange', (...args) => {
+        assert.equal(args.length, 2);
+        assert.equal(args[1], element.EDIT_NAME); // patchNum
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {_number: element.EDIT_NAME});
+      flushAsynchronousOperations();
+
+      fireEdit();
+    });
+
+    test('no edit exists in revisions, non-latest patchset', done => {
+      sandbox.stub(GerritNav, 'navigateToChange', (...args) => {
+        assert.equal(args.length, 4);
+        assert.equal(args[1], 1); // patchNum
+        assert.equal(args[3], true); // opt_isEdit
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {_number: 2});
+      element._patchRange = {patchNum: 1};
+      flushAsynchronousOperations();
+
+      fireEdit();
+    });
+
+    test('no edit exists in revisions, latest patchset', done => {
+      sandbox.stub(GerritNav, 'navigateToChange', (...args) => {
+        assert.equal(args.length, 4);
+        // No patch should be specified when patchNum == latest.
+        assert.isNotOk(args[1]); // patchNum
+        assert.equal(args[3], true); // opt_isEdit
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {_number: 2});
+      element._patchRange = {patchNum: 2};
+      flushAsynchronousOperations();
+
+      fireEdit();
+    });
+  });
+
+  test('_handleStopEditTap', done => {
+    sandbox.stub(element.$.metadata, '_computeLabelNames');
+    navigateToChangeStub.restore();
+    sandbox.stub(element, 'computeLatestPatchNum').returns(1);
+    sandbox.stub(GerritNav, 'navigateToChange', (...args) => {
+      assert.equal(args.length, 2);
+      assert.equal(args[1], 1); // patchNum
+      done();
+    });
+
+    element._patchRange = {patchNum: 1};
+    element.$.actions.dispatchEvent(new CustomEvent('stop-edit-tap',
+        {bubbles: false}));
+  });
+
+  suite('plugin endpoints', () => {
+    test('endpoint params', done => {
+      element._change = {labels: {}};
+      element._selectedRevision = {};
+      let hookEl;
+      let plugin;
+      pluginApi.install(
+          p => {
+            plugin = p;
+            plugin.hook('change-view-integration').getLastAttached()
+                .then(
+                    el => hookEl = el);
+          },
+          '0.1',
+          'http://some/plugins/url.html');
+      flush(() => {
+        assert.strictEqual(hookEl.plugin, plugin);
+        assert.strictEqual(hookEl.change, element._change);
+        assert.strictEqual(hookEl.revision, element._selectedRevision);
+        done();
+      });
+    });
+  });
+
+  suite('_getMergeability', () => {
+    let getMergeableStub;
+
+    setup(() => {
+      element._change = {labels: {}};
+      getMergeableStub = sandbox.stub(element.$.restAPI, 'getMergeable')
+          .returns(Promise.resolve({mergeable: true}));
+    });
+
+    test('merged change', () => {
+      element._mergeable = null;
+      element._change.status = ChangeStatus.MERGED;
+      return element._getMergeability().then(() => {
+        assert.isFalse(element._mergeable);
+        assert.isFalse(getMergeableStub.called);
+      });
+    });
+
+    test('abandoned change', () => {
+      element._mergeable = null;
+      element._change.status = ChangeStatus.ABANDONED;
+      return element._getMergeability().then(() => {
+        assert.isFalse(element._mergeable);
+        assert.isFalse(getMergeableStub.called);
+      });
+    });
+
+    test('open change', () => {
+      element._mergeable = null;
+      return element._getMergeability().then(() => {
+        assert.isTrue(element._mergeable);
+        assert.isTrue(getMergeableStub.called);
+      });
+    });
+  });
+
+  test('_paramsChanged sets in projectLookup', () => {
+    sandbox.stub(element.$.relatedChanges, 'reload');
+    sandbox.stub(element, '_reload').returns(Promise.resolve());
+    const setStub = sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+    element._paramsChanged({
+      view: GerritNav.View.CHANGE,
+      changeNum: 101,
+      project: 'test-project',
+    });
+    assert.isTrue(setStub.calledOnce);
+    assert.isTrue(setStub.calledWith(101, 'test-project'));
+  });
+
+  test('_handleToggleStar called when star is tapped', () => {
+    element._change = {
+      owner: {_account_id: 1},
+      starred: false,
+    };
+    element._loggedIn = true;
+    const stub = sandbox.stub(element, '_handleToggleStar');
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(element.$.changeStar.shadowRoot
+        .querySelector('button'));
+    assert.isTrue(stub.called);
+  });
+
+  suite('gr-reporting tests', () => {
+    setup(() => {
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      sandbox.stub(element, '_getChangeDetail').returns(Promise.resolve());
+      sandbox.stub(element, '_getProjectConfig').returns(Promise.resolve());
+      sandbox.stub(element, '_reloadComments').returns(Promise.resolve());
+      sandbox.stub(element, '_getMergeability').returns(Promise.resolve());
+      sandbox.stub(element, '_getLatestCommitMessage')
+          .returns(Promise.resolve());
+    });
+
+    test('don\'t report changedDisplayed on reply', done => {
+      const changeDisplayStub =
+        sandbox.stub(element.reporting, 'changeDisplayed');
+      const changeFullyLoadedStub =
+        sandbox.stub(element.reporting, 'changeFullyLoaded');
+      element._handleReplySent();
+      flush(() => {
+        assert.isFalse(changeDisplayStub.called);
+        assert.isFalse(changeFullyLoadedStub.called);
+        done();
+      });
+    });
+
+    test('report changedDisplayed on _paramsChanged', done => {
+      const changeDisplayStub =
+        sandbox.stub(element.reporting, 'changeDisplayed');
+      const changeFullyLoadedStub =
+        sandbox.stub(element.reporting, 'changeFullyLoaded');
+      element._paramsChanged({
+        view: GerritNav.View.CHANGE,
+        changeNum: 101,
+        project: 'test-project',
+      });
+      flush(() => {
+        assert.isTrue(changeDisplayStub.called);
+        assert.isTrue(changeFullyLoadedStub.called);
+        done();
+      });
+    });
+  });
+});