Merge "Set AutocompleteSuggestion property value to be string"
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 4bad587..f0c5012 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -51,6 +51,7 @@
 import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
 import {CustomKeyboardEvent} from '../../../types/events';
 import {LineNumber, FILE} from '../../diff/gr-diff/gr-diff-line';
+import {GrButton} from '../gr-button/gr-button';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -59,6 +60,8 @@
   $: {
     restAPI: RestApiService & Element;
     storage: GrStorage;
+    replyBtn: GrButton;
+    quoteBtn: GrButton;
   };
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
deleted file mode 100644
index 1833b73..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
+++ /dev/null
@@ -1,879 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-comment-thread.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {sortComments} from '../../../utils/comment-util.js';
-
-const basicFixture = fixtureFromElement('gr-comment-thread');
-
-const withCommentFixture = fixtureFromElement('gr-comment-thread');
-
-suite('gr-comment-thread tests', () => {
-  suite('basic test', () => {
-    let element;
-
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-
-      element = basicFixture.instantiate();
-      element.patchNum = '3';
-      element.changeNum = '1';
-      flush();
-    });
-
-    test('comments are sorted correctly', () => {
-      const comments = [
-        {
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          __date: new Date('2015-12-25'),
-        }, {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sally_to_dr_finklestein',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }, {
-          id: 'dr_finklesteins_response',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000',
-        }, {
-          id: 'sallys_mission',
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000',
-        },
-      ];
-      const results = sortComments(comments);
-      assert.deepEqual(results, [
-        {
-          id: 'sally_to_dr_finklestein',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'dr_finklesteins_response',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }, {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sallys_mission',
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          __date: new Date('2015-12-25'),
-        },
-      ]);
-    });
-
-    test('addOrEditDraft w/ edit draft', () => {
-      element.comments = [{
-        id: 'jacks_reply',
-        message: 'i like you, too',
-        in_reply_to: 'sallys_confession',
-        updated: '2015-12-25 15:00:20.396000000',
-        __draft: true,
-      }];
-      const commentElStub = sinon.stub(element, '_commentElWithDraftID')
-          .callsFake(() => { return {}; });
-      const addDraftStub = sinon.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isTrue(commentElStub.called);
-      assert.isFalse(addDraftStub.called);
-    });
-
-    test('addOrEditDraft w/o edit draft', () => {
-      element.comments = [];
-      const commentElStub = sinon.stub(element, '_commentElWithDraftID')
-          .callsFake(() => { return {}; });
-      const addDraftStub = sinon.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isFalse(commentElStub.called);
-      assert.isTrue(addDraftStub.called);
-    });
-
-    test('_shouldDisableAction', () => {
-      let showActions = true;
-      const lastComment = {};
-      assert.equal(
-          element._shouldDisableAction(showActions, lastComment), false);
-      showActions = false;
-      assert.equal(
-          element._shouldDisableAction(showActions, lastComment), true);
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(
-          element._shouldDisableAction(showActions, lastComment), true);
-      const robotComment = {};
-      robotComment.robot_id = true;
-      assert.equal(
-          element._shouldDisableAction(showActions, robotComment), false);
-    });
-
-    test('_hideActions', () => {
-      let showActions = true;
-      const lastComment = {};
-      assert.equal(element._hideActions(showActions, lastComment), false);
-      showActions = false;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      const robotComment = {};
-      robotComment.robot_id = true;
-      assert.equal(element._hideActions(showActions, robotComment), true);
-    });
-
-    test('setting project name loads the project config', done => {
-      const projectName = 'foo/bar/baz';
-      const getProjectStub = sinon.stub(element.$.restAPI, 'getProjectConfig')
-          .returns(Promise.resolve({}));
-      element.projectName = projectName;
-      flush(() => {
-        assert.isTrue(getProjectStub.calledWithExactly(projectName));
-        done();
-      });
-    });
-
-    test('optionally show file path', () => {
-      // Path info doesn't exist when showFilePath is false. Because it's in a
-      // dom-if it is not yet in the dom.
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.pathInfo'));
-
-      const commentStub = sinon.stub(GerritNav, 'getUrlForComment');
-      element.changeNum = 123;
-      element.projectName = 'test project';
-      element.path = 'path/to/file';
-      element.latestPatchNum = 10;
-      element.patchNum = 3;
-      element.lineNum = 5;
-      element.comments = [{id: 'comment_id'}];
-      element.showFilePath = true;
-      flush();
-      assert.isOk(element.shadowRoot
-          .querySelector('.pathInfo'));
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.pathInfo')).display,
-      'none');
-      assert.isTrue(commentStub.calledWithExactly(
-          element.changeNum, element.projectName, 'comment_id'));
-    });
-
-    test('_computeDisplayPath', () => {
-      let path = 'path/to/file';
-      assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-      element.lineNum = 5;
-      assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-      element.patchNum = '3';
-      path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(element._computeDisplayPath(path), 'Patchset');
-    });
-
-    test('_computeDisplayLine', () => {
-      element.lineNum = 5;
-      assert.equal(element._computeDisplayLine(), '#5');
-
-      element.path = SpecialFilePath.COMMIT_MESSAGE;
-      element.lineNum = 5;
-      assert.equal(element._computeDisplayLine(), '#5');
-
-      element.lineNum = undefined;
-      element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(element._computeDisplayLine(), '');
-    });
-  });
-});
-
-suite('comment action tests with unresolved thread', () => {
-  let element;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      saveDiffDraft() {
-        return Promise.resolve({
-          ok: true,
-          text() {
-            return Promise.resolve(')]}\'\n' +
-                JSON.stringify({
-                  id: '7afa4931_de3d65bd',
-                  path: '/path/to/file.txt',
-                  line: 5,
-                  in_reply_to: 'baf0414d_60047215',
-                  updated: '2015-12-21 02:01:10.850000000',
-                  message: 'Done',
-                }));
-          },
-        });
-      },
-      deleteDiffDraft() { return Promise.resolve({ok: true}); },
-    });
-    element = withCommentFixture.instantiate();
-    element.patchNum = '1';
-    element.changeNum = '1';
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      line: 5,
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:33.843000000',
-      path: '/path/to/file.txt',
-      unresolved: true,
-      patch_set: 3,
-      __commentSide: 'left',
-    }];
-    flush();
-  });
-
-  test('reply', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    const reportStub = sinon.stub(element.reporting,
-        'recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const replyBtn = element.$.replyBtn;
-    MockInteractions.tap(replyBtn);
-    flush();
-
-    const drafts = element._orderedComments.filter(c => c.__draft == true);
-    assert.equal(drafts.length, 1);
-    assert.notOk(drafts[0].message, 'message should be empty');
-    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('quote reply', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    const reportStub = sinon.stub(element.reporting,
-        'recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const quoteBtn = element.$.quoteBtn;
-    MockInteractions.tap(quoteBtn);
-    flush();
-
-    const drafts = element._orderedComments.filter(c => c.__draft == true);
-    assert.equal(drafts.length, 1);
-    assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
-    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('quote reply multiline', () => {
-    const reportStub = sinon.stub(element.reporting,
-        'recordDraftInteraction');
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      path: 'test',
-      line: 5,
-      message: 'is this a crossover episode!?\nIt might be!',
-      updated: '2015-12-08 19:48:33.843000000',
-    }];
-    flush();
-
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const quoteBtn = element.$.quoteBtn;
-    MockInteractions.tap(quoteBtn);
-    flush();
-
-    const drafts = element._orderedComments.filter(c => c.__draft == true);
-    assert.equal(drafts.length, 1);
-    assert.equal(drafts[0].message,
-        '> is this a crossover episode!?\n> It might be!\n\n');
-    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('ack', done => {
-    const reportStub = sinon.stub(element.reporting,
-        'recordDraftInteraction');
-    element.changeNum = '42';
-    element.patchNum = '1';
-
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const ackBtn = element.shadowRoot.querySelector('#ackBtn');
-    MockInteractions.tap(ackBtn);
-    flush(() => {
-      const drafts = element.comments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].message, 'Ack');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.equal(drafts[0].unresolved, false);
-      assert.isTrue(reportStub.calledOnce);
-      done();
-    });
-  });
-
-  test('done', done => {
-    const reportStub = sinon.stub(element.reporting,
-        'recordDraftInteraction');
-    element.changeNum = '42';
-    element.patchNum = '1';
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const doneBtn = element.shadowRoot.querySelector('#doneBtn');
-    MockInteractions.tap(doneBtn);
-    flush(() => {
-      const drafts = element.comments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].message, 'Done');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.isFalse(drafts[0].unresolved);
-      assert.isTrue(reportStub.calledOnce);
-      done();
-    });
-  });
-
-  test('save', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    element.path = '/path/to/file.txt';
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
-    element.shadowRoot
-        .querySelector('gr-comment')._fireSave();
-
-    flush(() => {
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-          'baf0414d_60047215');
-      assert.equal(element.rootId, 'baf0414d_60047215');
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-          '/path/to/file.txt');
-      done();
-    });
-  });
-
-  test('please fix', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-    commentEl.addEventListener('create-fix-comment', () => {
-      const drafts = element._orderedComments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 1);
-      assert.equal(
-          drafts[0].message, '> is this a crossover episode!?\n\nPlease fix.');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.isTrue(drafts[0].unresolved);
-      done();
-    });
-    commentEl.dispatchEvent(
-        new CustomEvent('create-fix-comment', {
-          detail: {comment: commentEl.comment},
-          composed: true, bubbles: false,
-        }));
-  });
-
-  test('discard', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    element.path = '/path/to/file.txt';
-    element.push('comments', element._newReply(
-        element.comments[0].id,
-        element.comments[0].path,
-        'it’s pronouced jiff, not giff'));
-    flush();
-
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
-    const draftEl =
-        element.root.querySelectorAll('gr-comment')[1];
-    assert.ok(draftEl);
-    draftEl.addEventListener('comment-discard', () => {
-      const drafts = element.comments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 0);
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-          element.rootId);
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-          element.path);
-      done();
-    });
-    draftEl.dispatchEvent(
-        new CustomEvent('comment-discard', {
-          detail: {comment: draftEl.comment},
-          composed: true, bubbles: false,
-        }));
-  });
-
-  test('discard with a single comment still fires event with previous rootId',
-      done => {
-        element.changeNum = '42';
-        element.patchNum = '1';
-        element.path = '/path/to/file.txt';
-        element.comments = [];
-        element.addOrEditDraft('1');
-        flush();
-        const rootId = element.rootId;
-        assert.isOk(rootId);
-
-        const saveOrDiscardStub = sinon.stub();
-        element.addEventListener('thread-changed', saveOrDiscardStub);
-        const draftEl =
-        element.root.querySelectorAll('gr-comment')[0];
-        assert.ok(draftEl);
-        draftEl.addEventListener('comment-discard', () => {
-          assert.equal(element.comments.length, 0);
-          assert.isTrue(saveOrDiscardStub.called);
-          assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-              rootId);
-          assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-              element.path);
-          done();
-        });
-        draftEl.dispatchEvent(
-            new CustomEvent('comment-discard', {
-              detail: {comment: draftEl.comment},
-              composed: true, bubbles: false,
-            }));
-      });
-
-  test('first editing comment does not add __otherEditing attribute', () => {
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      line: 5,
-      path: 'test',
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:33.843000000',
-      __draft: true,
-    }];
-
-    const replyBtn = element.$.replyBtn;
-    MockInteractions.tap(replyBtn);
-    flush();
-
-    const editing = element._orderedComments.filter(c => c.__editing == true);
-    assert.equal(editing.length, 1);
-    assert.equal(!!editing[0].__otherEditing, false);
-  });
-
-  test('When not editing other comments, local storage not set' +
-      ' after discard', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      path: 'test',
-      line: 5,
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:31.843000000',
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      __draftID: '1',
-      in_reply_to: 'baf0414d_60047215',
-      path: 'test',
-      line: 5,
-      message: 'yes',
-      updated: '2015-12-08 19:48:32.843000000',
-      __draft: true,
-      __editing: true,
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      __draftID: '2',
-      in_reply_to: 'baf0414d_60047215',
-      path: 'test',
-      line: 5,
-      message: 'no',
-      updated: '2015-12-08 19:48:33.843000000',
-      __draft: true,
-    }];
-    const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
-    flush();
-
-    const draftEl =
-    element.root.querySelectorAll('gr-comment')[1];
-    assert.ok(draftEl);
-    draftEl.addEventListener('comment-discard', () => {
-      assert.isFalse(storageStub.called);
-      storageStub.restore();
-      done();
-    });
-    draftEl.dispatchEvent(
-        new CustomEvent('comment-discard', {
-          detail: {comment: draftEl.comment},
-          composed: true, bubbles: false,
-        }));
-  });
-
-  test('comment-update', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    const updatedComment = {
-      id: element.comments[0].id,
-      foo: 'bar',
-    };
-    commentEl.dispatchEvent(
-        new CustomEvent('comment-update', {
-          detail: {comment: updatedComment},
-          composed: true, bubbles: true,
-        }));
-    assert.strictEqual(element.comments[0], updatedComment);
-  });
-
-  suite('jack and sally comment data test consolidation', () => {
-    setup(() => {
-      element.comments = [
-        {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          updated: '2015-12-25 15:00:20.396000000',
-          unresolved: false,
-        }, {
-          id: 'sallys_confession',
-          in_reply_to: 'nonexistent_comment',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sally_to_dr_finklestein',
-          in_reply_to: 'nonexistent_comment',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }];
-    });
-
-    test('orphan replies', () => {
-      assert.equal(4, element._orderedComments.length);
-    });
-
-    test('keyboard shortcuts', () => {
-      const expandCollapseStub =
-          sinon.stub(element, '_expandCollapseComments');
-      MockInteractions.pressAndReleaseKeyOn(element, 69, null, 'e');
-      assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
-
-      MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift', 'e');
-      assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
-    });
-
-    test('comment in_reply_to is either null or most recent comment', () => {
-      element._createReplyComment('dummy', true);
-      flush();
-      assert.equal(element._orderedComments.length, 5);
-      assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
-    });
-
-    test('resolvable comments', () => {
-      assert.isFalse(element.unresolved);
-      element._createReplyComment('dummy', true, true);
-      flush();
-      assert.isTrue(element.unresolved);
-    });
-
-    test('_setInitialExpandedState with unresolved', () => {
-      element.unresolved = true;
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isFalse(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState without unresolved', () => {
-      element.unresolved = false;
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isTrue(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState with robot_ids', () => {
-      for (let i = 0; i < element.comments.length; i++) {
-        element.comments[i].robot_id = 123;
-      }
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isFalse(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState with collapsed state', () => {
-      element.comments[0].collapsed = false;
-      element.unresolved = false;
-      element._setInitialExpandedState();
-      assert.isFalse(element.comments[0].collapsed);
-      for (let i = 1; i < element.comments.length; i++) {
-        assert.isTrue(element.comments[i].collapsed);
-      }
-    });
-  });
-
-  test('_computeHostClass', () => {
-    assert.equal(element._computeHostClass(true), 'unresolved');
-    assert.equal(element._computeHostClass(false), '');
-  });
-
-  test('addDraft sets unresolved state correctly', () => {
-    let unresolved = true;
-    element.comments = [];
-    element.addDraft(null, null, unresolved);
-    assert.equal(element.comments[0].unresolved, true);
-
-    unresolved = false; // comment should get added as actually resolved.
-    element.comments = [];
-    element.addDraft(null, null, unresolved);
-    assert.equal(element.comments[0].unresolved, false);
-
-    element.comments = [];
-    element.addDraft();
-    assert.equal(element.comments[0].unresolved, true);
-  });
-
-  test('_newDraft with root', () => {
-    const draft = element._newDraft();
-    assert.equal(draft.__commentSide, 'left');
-    assert.equal(draft.patch_set, 3);
-  });
-
-  test('_newDraft with no root', () => {
-    element.comments = [];
-    element.commentSide = 'right';
-    element.patchNum = 2;
-    const draft = element._newDraft();
-    assert.equal(draft.__commentSide, 'right');
-    assert.equal(draft.patch_set, 2);
-  });
-
-  test('new comment gets created', () => {
-    element.comments = [];
-    element.addOrEditDraft(1);
-    assert.equal(element.comments.length, 1);
-    // Mock a submitted comment.
-    element.comments[0].id = element.comments[0].__draftID;
-    element.comments[0].__draft = false;
-    element.addOrEditDraft(1);
-    assert.equal(element.comments.length, 2);
-  });
-
-  test('unresolved label', () => {
-    element.unresolved = false;
-    assert.isTrue(element.$.unresolvedLabel.hasAttribute('hidden'));
-    element.unresolved = true;
-    assert.isFalse(element.$.unresolvedLabel.hasAttribute('hidden'));
-  });
-
-  test('draft comments are at the end of orderedComments', () => {
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 2,
-      line: 5,
-      message: 'Earlier draft',
-      updated: '2015-12-08 19:48:33.843000000',
-      __draft: true,
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter2',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 1,
-      line: 5,
-      message: 'This comment was left last but is not a draft',
-      updated: '2015-12-10 19:48:33.843000000',
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter2',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 3,
-      line: 5,
-      message: 'Later draft',
-      updated: '2015-12-09 19:48:33.843000000',
-      __draft: true,
-    }];
-    assert.equal(element._orderedComments[0].id, '1');
-    assert.equal(element._orderedComments[1].id, '2');
-    assert.equal(element._orderedComments[2].id, '3');
-  });
-
-  test('reflects lineNum and commentSide to attributes', () => {
-    element.lineNum = 7;
-    element.commentSide = 'left';
-
-    assert.equal(element.getAttribute('line-num'), '7');
-    assert.equal(element.getAttribute('comment-side'), 'left');
-  });
-
-  test('reflects range to JSON serialized attribute if set', () => {
-    element.range = {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    };
-
-    assert.deepEqual(
-        JSON.parse(element.getAttribute('range')),
-        {start_line: 4, end_line: 5, start_character: 6, end_character: 7});
-  });
-
-  test('removes range attribute if range is unset', () => {
-    element.range = {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    };
-    element.range = undefined;
-
-    assert.notOk(element.hasAttribute('range'));
-  });
-});
-
-suite('comment action tests on resolved comments', () => {
-  let element;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      saveDiffDraft() {
-        return Promise.resolve({
-          ok: true,
-          text() {
-            return Promise.resolve(')]}\'\n' +
-                JSON.stringify({
-                  id: '7afa4931_de3d65bd',
-                  path: '/path/to/file.txt',
-                  line: 5,
-                  in_reply_to: 'baf0414d_60047215',
-                  updated: '2015-12-21 02:01:10.850000000',
-                  message: 'Done',
-                }));
-          },
-        });
-      },
-      deleteDiffDraft() { return Promise.resolve({ok: true}); },
-    });
-    element = withCommentFixture.instantiate();
-    element.patchNum = '1';
-    element.changeNum = '1';
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      line: 5,
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:33.843000000',
-      path: '/path/to/file.txt',
-      unresolved: false,
-    }];
-    flush();
-  });
-
-  test('ack and done should be hidden', () => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const ackBtn = element.shadowRoot.querySelector('#ackBtn');
-    const doneBtn = element.shadowRoot.querySelector('#doneBtn');
-    assert.equal(ackBtn, null);
-    assert.equal(doneBtn, null);
-  });
-
-  test('reply and quote button should be visible', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const replyBtn = element.shadowRoot.querySelector('#replyBtn');
-    const quoteBtn = element.shadowRoot.querySelector('#quoteBtn');
-    assert.ok(replyBtn);
-    assert.ok(quoteBtn);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
new file mode 100644
index 0000000..8ce220c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -0,0 +1,1020 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-comment-thread.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {SpecialFilePath, Side} from '../../../constants/constants.js';
+import {
+  sortComments,
+  UIComment,
+  UIRobot,
+  isDraft,
+  UIDraft,
+} from '../../../utils/comment-util.js';
+import {GrCommentThread} from './gr-comment-thread.js';
+import {
+  PatchSetNum,
+  NumericChangeId,
+  UrlEncodedCommentId,
+  Timestamp,
+  RobotId,
+  RobotRunId,
+  RepoName,
+  ConfigInfo,
+  EmailAddress,
+} from '../../../types/common.js';
+import {GrComment} from '../gr-comment/gr-comment.js';
+import {LineNumber} from '../../diff/gr-diff/gr-diff-line.js';
+import {
+  tap,
+  pressAndReleaseKeyOn,
+} from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-comment-thread');
+
+const withCommentFixture = fixtureFromElement('gr-comment-thread');
+
+suite('gr-comment-thread tests', () => {
+  suite('basic test', () => {
+    let element: GrCommentThread;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() {
+          return Promise.resolve(false);
+        },
+      });
+
+      element = basicFixture.instantiate();
+      element.patchNum = 3 as PatchSetNum;
+      element.changeNum = 1 as NumericChangeId;
+      flush();
+    });
+
+    test('comments are sorted correctly', () => {
+      const comments: UIComment[] = [
+        {
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+          __date: new Date('2015-12-25'),
+        },
+        {
+          id: 'sallys_confession' as UrlEncodedCommentId,
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_defiance' as UrlEncodedCommentId,
+          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_mission' as UrlEncodedCommentId,
+          message: 'i have to find santa',
+          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+        },
+      ];
+      const results = sortComments(comments);
+      assert.deepEqual(results, [
+        {
+          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_defiance' as UrlEncodedCommentId,
+          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_confession' as UrlEncodedCommentId,
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_mission' as UrlEncodedCommentId,
+          message: 'i have to find santa',
+          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+        },
+        {
+          message: 'i like you, too' as UrlEncodedCommentId,
+          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+          __date: new Date('2015-12-25'),
+        },
+      ]);
+    });
+
+    test('addOrEditDraft w/ edit draft', () => {
+      element.comments = [
+        {
+          id: 'jacks_reply' as UrlEncodedCommentId,
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+          updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+          __draft: true,
+        },
+      ];
+      const commentElStub = sinon
+        .stub(element, '_commentElWithDraftID')
+        .callsFake(() => {
+          return new GrComment();
+        });
+      const addDraftStub = sinon.stub(element, 'addDraft');
+
+      element.addOrEditDraft(123);
+
+      assert.isTrue(commentElStub.called);
+      assert.isFalse(addDraftStub.called);
+    });
+
+    test('addOrEditDraft w/o edit draft', () => {
+      element.comments = [];
+      const commentElStub = sinon
+        .stub(element, '_commentElWithDraftID')
+        .callsFake(() => {
+          return new GrComment();
+        });
+      const addDraftStub = sinon.stub(element, 'addDraft');
+
+      element.addOrEditDraft(123);
+
+      assert.isFalse(commentElStub.called);
+      assert.isTrue(addDraftStub.called);
+    });
+
+    test('_shouldDisableAction', () => {
+      let showActions = true;
+      const lastComment: UIComment = {};
+      assert.equal(
+        element._shouldDisableAction(showActions, lastComment),
+        false
+      );
+      showActions = false;
+      assert.equal(
+        element._shouldDisableAction(showActions, lastComment),
+        true
+      );
+      showActions = true;
+      lastComment.__draft = true;
+      assert.equal(
+        element._shouldDisableAction(showActions, lastComment),
+        true
+      );
+      const robotComment: UIRobot = {
+        id: '1234' as UrlEncodedCommentId,
+        updated: '1234' as Timestamp,
+        robot_id: 'robot_id' as RobotId,
+        robot_run_id: 'robot_run_id' as RobotRunId,
+        properties: {},
+        fix_suggestions: [],
+      };
+      assert.equal(
+        element._shouldDisableAction(showActions, robotComment),
+        false
+      );
+    });
+
+    test('_hideActions', () => {
+      let showActions = true;
+      const lastComment: UIComment = {};
+      assert.equal(element._hideActions(showActions, lastComment), false);
+      showActions = false;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+      showActions = true;
+      lastComment.__draft = true;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+      const robotComment: UIRobot = {
+        id: '1234' as UrlEncodedCommentId,
+        updated: '1234' as Timestamp,
+        robot_id: 'robot_id' as RobotId,
+        robot_run_id: 'robot_run_id' as RobotRunId,
+        properties: {},
+        fix_suggestions: [],
+      };
+      assert.equal(element._hideActions(showActions, robotComment), true);
+    });
+
+    test('setting project name loads the project config', done => {
+      const projectName = 'foo/bar/baz' as RepoName;
+      const getProjectStub = sinon
+        .stub(element.$.restAPI, 'getProjectConfig')
+        .returns(Promise.resolve({} as ConfigInfo));
+      element.projectName = projectName;
+      flush(() => {
+        assert.isTrue(getProjectStub.calledWithExactly(projectName));
+        done();
+      });
+    });
+
+    test('optionally show file path', () => {
+      // Path info doesn't exist when showFilePath is false. Because it's in a
+      // dom-if it is not yet in the dom.
+      assert.isNotOk(element.shadowRoot?.querySelector('.pathInfo'));
+
+      const commentStub = sinon.stub(GerritNav, 'getUrlForComment');
+      element.changeNum = 123 as NumericChangeId;
+      element.projectName = 'test project' as RepoName;
+      element.path = 'path/to/file';
+      element.patchNum = 3 as PatchSetNum;
+      element.lineNum = 5;
+      element.comments = [{id: 'comment_id' as UrlEncodedCommentId}];
+      element.showFilePath = true;
+      flush();
+      assert.isOk(element.shadowRoot?.querySelector('.pathInfo'));
+      assert.notEqual(
+        getComputedStyle(element.shadowRoot!.querySelector('.pathInfo')!)
+          .display,
+        'none'
+      );
+      assert.isTrue(
+        commentStub.calledWithExactly(
+          element.changeNum,
+          element.projectName,
+          'comment_id' as UrlEncodedCommentId
+        )
+      );
+    });
+
+    test('_computeDisplayPath', () => {
+      let path = 'path/to/file';
+      assert.equal(element._computeDisplayPath(path), 'path/to/file');
+
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayPath(path), 'path/to/file');
+
+      element.patchNum = 3 as PatchSetNum;
+      path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+      assert.equal(element._computeDisplayPath(path), 'Patchset');
+    });
+
+    test('_computeDisplayLine', () => {
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayLine(), '#5');
+
+      element.path = SpecialFilePath.COMMIT_MESSAGE;
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayLine(), '#5');
+
+      element.lineNum = undefined;
+      element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+      assert.equal(element._computeDisplayLine(), '');
+    });
+  });
+});
+
+suite('comment action tests with unresolved thread', () => {
+  let element: GrCommentThread;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() {
+        return Promise.resolve(false);
+      },
+      saveDiffDraft() {
+        return Promise.resolve(({
+          headers: {} as Headers,
+          redirected: false,
+          status: 200,
+          statusText: '',
+          type: '' as ResponseType,
+          url: '',
+          ok: true,
+          text() {
+            return Promise.resolve(
+              ")]}'\n" +
+                JSON.stringify({
+                  id: '7afa4931_de3d65bd',
+                  path: '/path/to/file.txt',
+                  line: 5,
+                  in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
+                  updated: '2015-12-21 02:01:10.850000000',
+                  message: 'Done',
+                })
+            );
+          },
+        } as unknown) as Response);
+      },
+      deleteDiffDraft() {
+        return Promise.resolve(({ok: true} as unknown) as Response);
+      },
+    });
+    element = withCommentFixture.instantiate();
+    element.patchNum = 1 as PatchSetNum;
+    element.changeNum = 1 as NumericChangeId;
+    element.comments = [
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: ('tenn1sballchaser@aol.com' as EmailAddress) as EmailAddress,
+        },
+        id: 'baf0414d_60047215' as UrlEncodedCommentId,
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+        path: '/path/to/file.txt',
+        unresolved: true,
+        patch_set: 3 as PatchSetNum,
+        __commentSide: Side.LEFT,
+      },
+    ];
+    flush();
+  });
+
+  test('reply', () => {
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    assert.ok(commentEl);
+
+    const replyBtn = element.$.replyBtn;
+    tap(replyBtn);
+    flush();
+
+    const drafts = element._orderedComments.filter(c => isDraft(c));
+    assert.equal(drafts.length, 1);
+    assert.notOk(drafts[0].message, 'message should be empty');
+    assert.equal(
+      drafts[0].in_reply_to,
+      ('baf0414d_60047215' as UrlEncodedCommentId) as UrlEncodedCommentId
+    );
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('quote reply', () => {
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    assert.ok(commentEl);
+
+    const quoteBtn = element.$.quoteBtn;
+    tap(quoteBtn);
+    flush();
+
+    const drafts = element._orderedComments.filter(c => isDraft(c));
+    assert.equal(drafts.length, 1);
+    assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
+    assert.equal(
+      drafts[0].in_reply_to,
+      ('baf0414d_60047215' as UrlEncodedCommentId) as UrlEncodedCommentId
+    );
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('quote reply multiline', () => {
+    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    element.comments = [
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: ('tenn1sballchaser@aol.com' as EmailAddress) as EmailAddress,
+        },
+        id: 'baf0414d_60047215' as UrlEncodedCommentId,
+        path: 'test',
+        line: 5,
+        message: 'is this a crossover episode!?\nIt might be!',
+        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+      },
+    ];
+    flush();
+
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const quoteBtn = element.$.quoteBtn;
+    tap(quoteBtn);
+    flush();
+
+    const drafts = element._orderedComments.filter(c => isDraft(c));
+    assert.equal(drafts.length, 1);
+    assert.equal(
+      drafts[0].message,
+      '> is this a crossover episode!?\n> It might be!\n\n'
+    );
+    assert.equal(
+      drafts[0].in_reply_to,
+      'baf0414d_60047215' as UrlEncodedCommentId
+    );
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('ack', done => {
+    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
+    assert.isOk(ackBtn);
+    tap(ackBtn!);
+    flush(() => {
+      const drafts = element.comments.filter(c => isDraft(c));
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].message, 'Ack');
+      assert.equal(
+        drafts[0].in_reply_to,
+        'baf0414d_60047215' as UrlEncodedCommentId
+      );
+      assert.equal(drafts[0].unresolved, false);
+      assert.isTrue(reportStub.calledOnce);
+      done();
+    });
+  });
+
+  test('done', done => {
+    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
+    assert.isOk(doneBtn);
+    tap(doneBtn!);
+    flush(() => {
+      const drafts = element.comments.filter(c => isDraft(c));
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].message, 'Done');
+      assert.equal(
+        drafts[0].in_reply_to,
+        'baf0414d_60047215' as UrlEncodedCommentId
+      );
+      assert.isFalse(drafts[0].unresolved);
+      assert.isTrue(reportStub.calledOnce);
+      done();
+    });
+  });
+
+  test('save', done => {
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+    element.path = '/path/to/file.txt';
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const saveOrDiscardStub = sinon.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    element.shadowRoot?.querySelector('gr-comment')?._fireSave();
+
+    flush(() => {
+      assert.isTrue(saveOrDiscardStub.called);
+      assert.equal(
+        saveOrDiscardStub.lastCall.args[0].detail.rootId,
+        'baf0414d_60047215'
+      );
+      assert.equal(element.rootId, 'baf0414d_60047215' as UrlEncodedCommentId);
+      assert.equal(
+        saveOrDiscardStub.lastCall.args[0].detail.path,
+        '/path/to/file.txt'
+      );
+      done();
+    });
+  });
+
+  test('please fix', done => {
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+    commentEl!.addEventListener('create-fix-comment', () => {
+      const drafts = element._orderedComments.filter(c => isDraft(c));
+      assert.equal(drafts.length, 1);
+      assert.equal(
+        drafts[0].message,
+        '> is this a crossover episode!?\n\nPlease fix.'
+      );
+      assert.equal(
+        drafts[0].in_reply_to,
+        'baf0414d_60047215' as UrlEncodedCommentId
+      );
+      assert.isTrue(drafts[0].unresolved);
+      done();
+    });
+    commentEl!.dispatchEvent(
+      new CustomEvent('create-fix-comment', {
+        detail: {comment: commentEl!.comment},
+        composed: true,
+        bubbles: false,
+      })
+    );
+  });
+
+  test('discard', done => {
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+    element.path = '/path/to/file.txt';
+    assert.isOk(element.comments[0]);
+    element.push(
+      'comments',
+      element._newReply(
+        element.comments[0]!.id as UrlEncodedCommentId,
+        'it’s pronouced jiff, not giff'
+      )
+    );
+    flush();
+
+    const saveOrDiscardStub = sinon.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    const draftEl = element.root?.querySelectorAll('gr-comment')[1];
+    assert.ok(draftEl);
+    draftEl!.addEventListener('comment-discard', () => {
+      const drafts = element.comments.filter(c => isDraft(c));
+      assert.equal(drafts.length, 0);
+      assert.isTrue(saveOrDiscardStub.called);
+      assert.equal(
+        saveOrDiscardStub.lastCall.args[0].detail.rootId,
+        element.rootId
+      );
+      assert.equal(
+        saveOrDiscardStub.lastCall.args[0].detail.path,
+        element.path
+      );
+      done();
+    });
+    draftEl!.dispatchEvent(
+      new CustomEvent('comment-discard', {
+        detail: {comment: draftEl!.comment},
+        composed: true,
+        bubbles: false,
+      })
+    );
+  });
+
+  test('discard with a single comment still fires event with previous rootId', done => {
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+    element.path = '/path/to/file.txt';
+    element.comments = [];
+    element.addOrEditDraft(1 as LineNumber);
+    flush();
+    const rootId = element.rootId;
+    assert.isOk(rootId);
+
+    const saveOrDiscardStub = sinon.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    const draftEl = element.root?.querySelectorAll('gr-comment')[0];
+    assert.ok(draftEl);
+    draftEl!.addEventListener('comment-discard', () => {
+      assert.equal(element.comments.length, 0);
+      assert.isTrue(saveOrDiscardStub.called);
+      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId, rootId);
+      assert.equal(
+        saveOrDiscardStub.lastCall.args[0].detail.path,
+        element.path
+      );
+      done();
+    });
+    draftEl!.dispatchEvent(
+      new CustomEvent('comment-discard', {
+        detail: {comment: draftEl!.comment},
+        composed: true,
+        bubbles: false,
+      })
+    );
+  });
+
+  test('first editing comment does not add __otherEditing attribute', () => {
+    element.comments = [
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: ('tenn1sballchaser@aol.com' as EmailAddress) as EmailAddress,
+        },
+        id: 'baf0414d_60047215' as UrlEncodedCommentId,
+        line: 5,
+        path: 'test',
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+        __draft: true,
+      },
+    ];
+
+    const replyBtn = element.$.replyBtn;
+    tap(replyBtn);
+    flush();
+
+    const editing = element._orderedComments.filter(c => c.__editing === true);
+    assert.equal(editing.length, 1);
+    assert.equal(!!editing[0].__otherEditing, false);
+  });
+
+  test(
+    'When not editing other comments, local storage not set' + ' after discard',
+    done => {
+      element.changeNum = 42 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element.comments = [
+        {
+          author: {
+            name: 'Mr. Peanutbutter',
+            email: 'tenn1sballchaser@aol.com' as EmailAddress,
+          },
+          id: 'baf0414d_60047215' as UrlEncodedCommentId,
+          path: 'test',
+          line: 5,
+          message: 'is this a crossover episode!?',
+          updated: '2015-12-08 19:48:31.843000000' as Timestamp,
+        },
+        {
+          author: {
+            name: 'Mr. Peanutbutter',
+            email: 'tenn1sballchaser@aol.com' as EmailAddress,
+          },
+          __draftID: '1',
+          in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
+          path: 'test',
+          line: 5,
+          message: 'yes',
+          updated: '2015-12-08 19:48:32.843000000' as Timestamp,
+          __draft: true,
+          __editing: true,
+        },
+        {
+          author: {
+            name: 'Mr. Peanutbutter',
+            email: 'tenn1sballchaser@aol.com' as EmailAddress,
+          },
+          __draftID: '2',
+          in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
+          path: 'test',
+          line: 5,
+          message: 'no',
+          updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+          __draft: true,
+        },
+      ];
+      const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
+      flush();
+
+      const draftEl = element.root?.querySelectorAll('gr-comment')[1];
+      assert.ok(draftEl);
+      draftEl!.addEventListener('comment-discard', () => {
+        assert.isFalse(storageStub.called);
+        storageStub.restore();
+        done();
+      });
+      draftEl!.dispatchEvent(
+        new CustomEvent('comment-discard', {
+          detail: {comment: draftEl!.comment},
+          composed: true,
+          bubbles: false,
+        })
+      );
+    }
+  );
+
+  test('comment-update', () => {
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    const updatedComment = {
+      id: element.comments[0].id,
+      foo: 'bar',
+    };
+    assert.isOk(commentEl);
+    commentEl!.dispatchEvent(
+      new CustomEvent('comment-update', {
+        detail: {comment: updatedComment},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.strictEqual(element.comments[0], updatedComment);
+  });
+
+  suite('jack and sally comment data test consolidation', () => {
+    setup(() => {
+      element.comments = [
+        {
+          id: 'jacks_reply' as UrlEncodedCommentId,
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+          updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+          unresolved: false,
+        },
+        {
+          id: 'sallys_confession' as UrlEncodedCommentId,
+          in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_defiance' as UrlEncodedCommentId,
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
+        },
+      ];
+    });
+
+    test('orphan replies', () => {
+      assert.equal(4, element._orderedComments.length);
+    });
+
+    test('keyboard shortcuts', () => {
+      const expandCollapseStub = sinon.stub(element, '_expandCollapseComments');
+      pressAndReleaseKeyOn(element, 69, null, 'e');
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
+
+      pressAndReleaseKeyOn(element, 69, 'shift', 'e');
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
+    });
+
+    test('comment in_reply_to is either null or most recent comment', () => {
+      element._createReplyComment('dummy', true);
+      flush();
+      assert.equal(element._orderedComments.length, 5);
+      assert.equal(
+        element._orderedComments[4].in_reply_to,
+        'jacks_reply' as UrlEncodedCommentId
+      );
+    });
+
+    test('resolvable comments', () => {
+      assert.isFalse(element.unresolved);
+      element._createReplyComment('dummy', true, true);
+      flush();
+      assert.isTrue(element.unresolved);
+    });
+
+    test('_setInitialExpandedState with unresolved', () => {
+      element.unresolved = true;
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isFalse(element.comments[i].collapsed);
+      }
+    });
+
+    test('_setInitialExpandedState without unresolved', () => {
+      element.unresolved = false;
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isTrue(element.comments[i].collapsed);
+      }
+    });
+
+    test('_setInitialExpandedState with robot_ids', () => {
+      for (let i = 0; i < element.comments.length; i++) {
+        (element.comments[i] as UIRobot).robot_id = '123' as RobotId;
+      }
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isFalse(element.comments[i].collapsed);
+      }
+    });
+
+    test('_setInitialExpandedState with collapsed state', () => {
+      element.comments[0].collapsed = false;
+      element.unresolved = false;
+      element._setInitialExpandedState();
+      assert.isFalse(element.comments[0].collapsed);
+      for (let i = 1; i < element.comments.length; i++) {
+        assert.isTrue(element.comments[i].collapsed);
+      }
+    });
+  });
+
+  test('_computeHostClass', () => {
+    assert.equal(element._computeHostClass(true), 'unresolved');
+    assert.equal(element._computeHostClass(false), '');
+  });
+
+  test('addDraft sets unresolved state correctly', () => {
+    let unresolved = true;
+    element.comments = [];
+    element.addDraft(undefined, undefined, unresolved);
+    assert.equal(element.comments[0].unresolved, true);
+
+    unresolved = false; // comment should get added as actually resolved.
+    element.comments = [];
+    element.addDraft(undefined, undefined, unresolved);
+    assert.equal(element.comments[0].unresolved, false);
+
+    element.comments = [];
+    element.addDraft();
+    assert.equal(element.comments[0].unresolved, true);
+  });
+
+  test('_newDraft with root', () => {
+    const draft = element._newDraft();
+    assert.equal(draft.__commentSide, Side.LEFT);
+    assert.equal(draft.patch_set, 3 as PatchSetNum);
+  });
+
+  test('_newDraft with no root', () => {
+    element.comments = [];
+    element.commentSide = Side.RIGHT;
+    element.patchNum = 2 as PatchSetNum;
+    const draft = element._newDraft();
+    assert.equal(draft.__commentSide, Side.RIGHT);
+    assert.equal(draft.patch_set, 2 as PatchSetNum);
+  });
+
+  test('new comment gets created', () => {
+    element.comments = [];
+    element.addOrEditDraft(1);
+    assert.equal(element.comments.length, 1);
+    // Mock a submitted comment.
+    element.comments[0].id = (element.comments[0] as UIDraft)
+      .__draftID as UrlEncodedCommentId;
+    delete (element.comments[0] as UIDraft).__draft;
+    element.addOrEditDraft(1);
+    assert.equal(element.comments.length, 2);
+  });
+
+  test('unresolved label', () => {
+    element.unresolved = false;
+    const label = element.shadowRoot?.querySelector('#unresolvedLabel');
+    assert.isOk(label);
+    assert.isTrue(label!.hasAttribute('hidden'));
+    element.unresolved = true;
+    assert.isFalse(label!.hasAttribute('hidden'));
+  });
+
+  test('draft comments are at the end of orderedComments', () => {
+    element.comments = [
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        id: '2' as UrlEncodedCommentId,
+        line: 5,
+        message: 'Earlier draft',
+        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+        __draft: true,
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter2',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        id: '1' as UrlEncodedCommentId,
+        line: 5,
+        message: 'This comment was left last but is not a draft',
+        updated: '2015-12-10 19:48:33.843000000' as Timestamp,
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter2',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        id: '3' as UrlEncodedCommentId,
+        line: 5,
+        message: 'Later draft',
+        updated: '2015-12-09 19:48:33.843000000' as Timestamp,
+        __draft: true,
+      },
+    ];
+    assert.equal(element._orderedComments[0].id, '1' as UrlEncodedCommentId);
+    assert.equal(element._orderedComments[1].id, '2' as UrlEncodedCommentId);
+    assert.equal(element._orderedComments[2].id, '3' as UrlEncodedCommentId);
+  });
+
+  test('reflects lineNum and commentSide to attributes', () => {
+    element.lineNum = 7;
+    element.commentSide = Side.LEFT;
+
+    assert.equal(element.getAttribute('line-num'), '7');
+    assert.equal(element.getAttribute('comment-side'), Side.LEFT);
+  });
+
+  test('reflects range to JSON serialized attribute if set', () => {
+    element.range = {
+      start_line: 4,
+      end_line: 5,
+      start_character: 6,
+      end_character: 7,
+    };
+
+    assert.isOk(element.getAttribute('range'));
+    assert.deepEqual(JSON.parse(element.getAttribute('range')!), {
+      start_line: 4,
+      end_line: 5,
+      start_character: 6,
+      end_character: 7,
+    });
+  });
+
+  test('removes range attribute if range is unset', () => {
+    element.range = {
+      start_line: 4,
+      end_line: 5,
+      start_character: 6,
+      end_character: 7,
+    };
+    element.range = undefined;
+
+    assert.notOk(element.hasAttribute('range'));
+  });
+});
+
+suite('comment action tests on resolved comments', () => {
+  let element: GrCommentThread;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() {
+        return Promise.resolve(false);
+      },
+      saveDiffDraft() {
+        return Promise.resolve(({
+          ok: true,
+          text() {
+            return Promise.resolve(
+              ")]}'\n" +
+                JSON.stringify({
+                  id: '7afa4931_de3d65bd',
+                  path: '/path/to/file.txt',
+                  line: 5,
+                  in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
+                  updated: '2015-12-21 02:01:10.850000000',
+                  message: 'Done',
+                })
+            );
+          },
+        } as unknown) as Response);
+      },
+      deleteDiffDraft() {
+        return Promise.resolve(({ok: true} as unknown) as Response);
+      },
+    });
+    element = withCommentFixture.instantiate();
+    element.patchNum = 1 as PatchSetNum;
+    element.changeNum = 1 as NumericChangeId;
+    element.comments = [
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        id: 'baf0414d_60047215' as UrlEncodedCommentId,
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+        path: '/path/to/file.txt',
+        unresolved: false,
+      },
+    ];
+    flush();
+  });
+
+  test('ack and done should be hidden', () => {
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
+    const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
+    assert.equal(ackBtn, null);
+    assert.equal(doneBtn, null);
+  });
+
+  test('reply and quote button should be visible', () => {
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const replyBtn = element.shadowRoot?.querySelector('#replyBtn');
+    const quoteBtn = element.shadowRoot?.querySelector('#quoteBtn');
+    assert.ok(replyBtn);
+    assert.ok(quoteBtn);
+  });
+});