blob: c1e2564acaa76c3473b514bb9114378263247771 [file] [log] [blame]
/**
* @license
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '../../../test/common-test-setup-karma.js';
import {IronOverlayManager} from '@polymer/iron-overlay-behavior/iron-overlay-manager.js';
import './gr-reply-dialog.js';
import {mockPromise, stubStorage} from '../../../test/test-utils.js';
import {SpecialFilePath} from '../../../constants/constants.js';
import {appContext} from '../../../services/app-context.js';
import {addListenerForTest} from '../../../test/test-utils.js';
import {stubRestApi} from '../../../test/test-utils.js';
import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js';
import {CODE_REVIEW} from '../../../utils/label-util.js';
import {createAccountWithId} from '../../../test/test-data-generators.js';
const basicFixture = fixtureFromElement('gr-reply-dialog');
function cloneableResponse(status, text) {
return {
ok: false,
status,
text() {
return Promise.resolve(text);
},
clone() {
return {
ok: false,
status,
text() {
return Promise.resolve(text);
},
};
},
};
}
suite('gr-reply-dialog tests', () => {
let element;
let changeNum;
let patchNum;
let getDraftCommentStub;
let setDraftCommentStub;
let eraseDraftCommentStub;
let lastId = 0;
const makeAccount = function() { return {_account_id: lastId++}; };
const makeGroup = function() { return {id: lastId++}; };
setup(() => {
changeNum = 42;
patchNum = 1;
stubRestApi('getAccount').returns(Promise.resolve({}));
stubRestApi('getChange').returns(Promise.resolve({}));
stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
element = basicFixture.instantiate();
element.change = {
_number: changeNum,
owner: {
_account_id: 999,
display_name: 'Kermit',
},
labels: {
'Verified': {
values: {
'-1': 'Fails',
' 0': 'No score',
'+1': 'Verified',
},
default_value: 0,
},
'Code-Review': {
values: {
'-2': 'Do not submit',
'-1': 'I would prefer that you didn\'t submit this',
' 0': 'No score',
'+1': 'Looks good to me, but someone else must approve',
'+2': 'Looks good to me, approved',
},
default_value: 0,
},
},
};
element.patchNum = patchNum;
element.permittedLabels = {
'Code-Review': [
'-1',
' 0',
'+1',
],
'Verified': [
'-1',
' 0',
'+1',
],
};
getDraftCommentStub = stubStorage('getDraftComment');
setDraftCommentStub = stubStorage('setDraftComment');
eraseDraftCommentStub = stubStorage('eraseDraftComment');
// sinon.stub(patchSetUtilMockProxy, 'fetchChangeUpdates')
// .returns(Promise.resolve({isLatest: true}));
// Allow the elements created by dom-repeat to be stamped.
flush();
});
function stubSaveReview(jsonResponseProducer) {
return sinon.stub(
element,
'_saveReview')
.callsFake(review => new Promise((resolve, reject) => {
try {
const result = jsonResponseProducer(review) || {};
const resultStr = JSON_PREFIX + JSON.stringify(result);
resolve({
ok: true,
text() {
return Promise.resolve(resultStr);
},
});
} catch (err) {
reject(err);
}
}));
}
test('default to publishing draft comments with reply', done => {
// Async tick is needed because iron-selector content is distributed and
// distributed content requires an observer to be set up.
// Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
flush(() => {
flush(() => {
element.draft = 'I wholeheartedly disapprove';
stubSaveReview(review => {
assert.deepEqual(review, {
drafts: 'PUBLISH_ALL_REVISIONS',
labels: {
'Code-Review': 0,
'Verified': 0,
},
comments: {
[SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
message: 'I wholeheartedly disapprove',
unresolved: false,
}],
},
reviewers: [],
});
assert.isFalse(element.$.commentList.hidden);
done();
});
// This is needed on non-Blink engines most likely due to the ways in
// which the dom-repeat elements are stamped.
flush(() => {
MockInteractions.tap(element.shadowRoot
.querySelector('.send'));
});
});
});
});
test('modified attention set', done => {
element.serverConfig = {
change: {enable_attention_set: true},
};
element._newAttentionSet = new Set([314]);
const buttonEl = element.shadowRoot.querySelector('.edit-attention-button');
MockInteractions.tap(buttonEl);
flush();
stubSaveReview(review => {
assert.isTrue(review.ignore_automatic_attention_set_rules);
assert.deepEqual(review.add_to_attention_set, [{
user: 314,
reason: 'Anonymous replied on the change',
}]);
assert.deepEqual(review.remove_from_attention_set, []);
done();
});
MockInteractions.tap(element.shadowRoot.querySelector('.send'));
});
function checkComputeAttention(status, userId, reviewerIds, ownerId,
attSetIds, replyToIds, expectedIds, uploaderId, hasDraft,
includeComments = true) {
const user = {_account_id: userId};
const reviewers = {base: reviewerIds.map(id => {
return {_account_id: id};
})};
const draftThreads = [
{comments: []},
];
if (hasDraft) {
draftThreads[0].comments.push({__draft: true, unresolved: true});
}
replyToIds.forEach(id => draftThreads[0].comments.push({
author: {_account_id: id},
}));
const change = {
owner: {_account_id: ownerId},
status,
attention_set: {},
};
attSetIds.forEach(id => change.attention_set[id] = {});
if (uploaderId) {
change.current_revision = 1;
change.revisions = [{}, {uploader: {_account_id: uploaderId}}];
}
element.change = change;
element._reviewers = reviewers.base;
flush();
const hasDrafts = draftThreads.length > 0;
element._computeNewAttention(
user, reviewers, [], change, draftThreads, includeComments, undefined,
hasDrafts);
assert.sameMembers([...element._newAttentionSet], expectedIds);
}
test('computeNewAttention NEW', () => {
checkComputeAttention('NEW', null, [], 999, [], [], [999]);
checkComputeAttention('NEW', 1, [], 999, [], [], [999]);
checkComputeAttention('NEW', 1, [], 999, [1], [], [999]);
checkComputeAttention('NEW', 1, [22], 999, [], [], [999]);
checkComputeAttention('NEW', 1, [22], 999, [22], [], [22, 999]);
checkComputeAttention('NEW', 1, [22], 999, [], [22], [22, 999]);
checkComputeAttention('NEW', 1, [22, 33], 999, [33], [22], [22, 33, 999]);
// If the owner replies, then do not add them.
checkComputeAttention('NEW', 1, [], 1, [], [], []);
checkComputeAttention('NEW', 1, [], 1, [1], [], []);
checkComputeAttention('NEW', 1, [22], 1, [], [], []);
checkComputeAttention('NEW', 1, [22], 1, [], [22], [22]);
checkComputeAttention('NEW', 1, [22, 33], 1, [33], [22], [22, 33]);
checkComputeAttention('NEW', 1, [22, 33], 1, [], [22], [22]);
checkComputeAttention('NEW', 1, [22, 33], 1, [], [22, 33], [22, 33]);
checkComputeAttention('NEW', 1, [22, 33], 1, [22, 33], [], [22, 33]);
// with uploader
checkComputeAttention('NEW', 1, [], 1, [], [2], [2], 2);
checkComputeAttention('NEW', 1, [], 1, [2], [], [2], 2);
checkComputeAttention('NEW', 1, [], 3, [], [], [2, 3], 2);
});
test('computeNewAttention MERGED', () => {
checkComputeAttention('MERGED', null, [], 999, [], [], []);
checkComputeAttention('MERGED', 1, [], 999, [], [], []);
checkComputeAttention('MERGED', 1, [], 999, [], [], [999], undefined, true);
checkComputeAttention(
'MERGED', 1, [], 999, [], [], [], undefined, true, false);
checkComputeAttention('MERGED', 1, [], 999, [1], [], []);
checkComputeAttention('MERGED', 1, [22], 999, [], [], []);
checkComputeAttention('MERGED', 1, [22], 999, [22], [], [22]);
checkComputeAttention('MERGED', 1, [22], 999, [], [22], []);
checkComputeAttention('MERGED', 1, [22, 33], 999, [33], [22], [33]);
checkComputeAttention('MERGED', 1, [], 1, [], [], []);
checkComputeAttention('MERGED', 1, [], 1, [], [], [], undefined, true);
checkComputeAttention('MERGED', 1, [], 1, [1], [], []);
checkComputeAttention('MERGED', 1, [], 1, [1], [], [], undefined, true);
checkComputeAttention('MERGED', 1, [22], 1, [], [], []);
checkComputeAttention('MERGED', 1, [22], 1, [], [22], []);
checkComputeAttention('MERGED', 1, [22, 33], 1, [33], [22], [33]);
checkComputeAttention('MERGED', 1, [22, 33], 1, [], [22], []);
checkComputeAttention('MERGED', 1, [22, 33], 1, [], [22, 33], []);
checkComputeAttention('MERGED', 1, [22, 33], 1, [22, 33], [], [22, 33]);
});
test('computeNewAttention when adding reviewers', () => {
const user = {_account_id: 1};
const reviewers = {base: [
{_account_id: 1, _pendingAdd: true},
{_account_id: 2, _pendingAdd: true},
]};
const change = {
owner: {_account_id: 5},
status: 'NEW',
attention_set: {},
};
element.change = change;
element._reviewers = reviewers.base;
flush();
element._computeNewAttention(user, reviewers, [], change, [], true);
assert.sameMembers([...element._newAttentionSet], [1, 2]);
// If the user votes on the change, then they should not be added to the
// attention set, even if they have just added themselves as reviewer.
// But voting should also add the owner (5).
const labelsChanged = true;
element._computeNewAttention(
user, reviewers, [], change, [], true, labelsChanged);
assert.sameMembers([...element._newAttentionSet], [2, 5]);
});
test('computeNewAttention when sending wip change for review', () => {
const reviewers = {base: [
{_account_id: 2},
{_account_id: 3},
]};
const change = {
owner: {_account_id: 1},
status: 'NEW',
attention_set: {},
};
element.change = change;
element._reviewers = reviewers.base;
flush();
// For an active change there is no reason to add anyone to the set.
let user = {_account_id: 1};
element._computeNewAttention(user, reviewers, [], change, [], false);
assert.sameMembers([...element._newAttentionSet], []);
// If the change is "work in progress" and the owner sends a reply, then
// add all reviewers.
element.canBeStarted = true;
flush();
user = {_account_id: 1};
element._computeNewAttention(user, reviewers, [], change, [], false);
assert.sameMembers([...element._newAttentionSet], [2, 3]);
// ... but not when someone else replies.
user = {_account_id: 4};
element._computeNewAttention(user, reviewers, [], change, [], false);
assert.sameMembers([...element._newAttentionSet], []);
});
test('computeNewAttentionAccounts', () => {
element._reviewers = [
{_account_id: 123, display_name: 'Ernie'},
{_account_id: 321, display_name: 'Bert'},
];
element._ccs = [
{_account_id: 7, display_name: 'Elmo'},
];
const compute = (currentAtt, newAtt) =>
element._computeNewAttentionAccounts(
undefined, new Set(currentAtt), new Set(newAtt))
.map(a => a._account_id);
assert.sameMembers(compute([], []), []);
assert.sameMembers(compute([], [999]), [999]);
assert.sameMembers(compute([999], []), []);
assert.sameMembers(compute([999], [999]), []);
assert.sameMembers(compute([123, 321], [999]), [999]);
assert.sameMembers(compute([999], [7, 123, 999]), [7, 123]);
});
test('_computeCommentAccounts', () => {
element.change = {
labels: {
'Code-Review': {
all: [
{_account_id: 1, value: 0},
{_account_id: 2, value: 1},
{_account_id: 3, value: 2},
],
values: {
'-2': 'Do not submit',
'-1': 'I would prefer that you didnt submit this',
' 0': 'No score',
'+1': 'Looks good to me, but someone else must approve',
'+2': 'Looks good to me, approved',
},
},
},
};
const threads = [
{
comments: [
{author: {_account_id: 1}, unresolved: false},
{author: {_account_id: 2}, unresolved: true},
],
},
{
comments: [
{author: {_account_id: 3}, unresolved: false},
{author: {_account_id: 4}, unresolved: false},
],
},
];
const actualAccounts = [...element._computeCommentAccounts(threads)];
// Account 3 is not included, because the comment is resolved *and* they
// have given the highest possible vote on the Code-Review label.
assert.sameMembers(actualAccounts, [1, 2, 4]);
});
test('toggle resolved checkbox', done => {
// Async tick is needed because iron-selector content is distributed and
// distributed content requires an observer to be set up.
// Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
const checkboxEl = element.shadowRoot.querySelector(
'#resolvedPatchsetLevelCommentCheckbox');
MockInteractions.tap(checkboxEl);
flush(() => {
flush(() => {
element.draft = 'I wholeheartedly disapprove';
stubSaveReview(review => {
assert.deepEqual(review, {
drafts: 'PUBLISH_ALL_REVISIONS',
labels: {
'Code-Review': 0,
'Verified': 0,
},
comments: {
[SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
message: 'I wholeheartedly disapprove',
unresolved: true,
}],
},
reviewers: [],
});
done();
});
// This is needed on non-Blink engines most likely due to the ways in
// which the dom-repeat elements are stamped.
flush(() => {
MockInteractions.tap(element.shadowRoot
.querySelector('.send'));
});
});
});
});
test('keep draft comments with reply', done => {
MockInteractions.tap(element.shadowRoot.querySelector('#includeComments'));
assert.equal(element._includeComments, false);
// Async tick is needed because iron-selector content is distributed and
// distributed content requires an observer to be set up.
// Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
flush(() => {
flush(() => {
element.draft = 'I wholeheartedly disapprove';
stubSaveReview(review => {
assert.deepEqual(review, {
drafts: 'KEEP',
labels: {
'Code-Review': 0,
'Verified': 0,
},
comments: {
[SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
message: 'I wholeheartedly disapprove',
unresolved: false,
}],
},
reviewers: [],
});
assert.isTrue(element.$.commentList.hidden);
done();
});
// This is needed on non-Blink engines most likely due to the ways in
// which the dom-repeat elements are stamped.
flush(() => {
MockInteractions.tap(element.shadowRoot
.querySelector('.send'));
});
});
});
});
test('label picker', done => {
element.draft = 'I wholeheartedly disapprove';
stubSaveReview(review => {
assert.deepEqual(review, {
drafts: 'PUBLISH_ALL_REVISIONS',
labels: {
'Code-Review': -1,
'Verified': -1,
},
comments: {
[SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
message: 'I wholeheartedly disapprove',
unresolved: false,
}],
},
reviewers: [],
});
});
sinon.stub(element.$.labelScores, 'getLabelValues').callsFake( () => {
return {
'Code-Review': -1,
'Verified': -1,
};
});
element.addEventListener('send', () => {
// Flush to ensure properties are updated.
flush(() => {
assert.isFalse(element.disabled,
'Element should be enabled when done sending reply.');
assert.equal(element.draft.length, 0);
done();
});
});
// This is needed on non-Blink engines most likely due to the ways in
// which the dom-repeat elements are stamped.
flush(() => {
MockInteractions.tap(element.shadowRoot
.querySelector('.send'));
assert.isTrue(element.disabled);
});
});
test('getlabelValue returns value', done => {
flush(() => {
element.shadowRoot
.querySelector('gr-label-scores')
.shadowRoot
.querySelector(`gr-label-score-row[name="Verified"]`)
.setSelectedValue(-1);
assert.equal('-1', element.getLabelValue('Verified'));
done();
});
});
test('getlabelValue when no score is selected', done => {
flush(() => {
element.shadowRoot
.querySelector('gr-label-scores')
.shadowRoot
.querySelector(`gr-label-score-row[name="Code-Review"]`)
.setSelectedValue(-1);
assert.strictEqual(element.getLabelValue('Verified'), ' 0');
done();
});
});
test('setlabelValue', done => {
element._account = {_account_id: 1};
flush(() => {
const label = 'Verified';
const value = '+1';
element.setLabelValue(label, value);
const labels = element.$.labelScores.getLabelValues();
assert.deepEqual(labels, {
'Code-Review': 0,
'Verified': 1,
});
done();
});
});
function getActiveElement() {
return IronOverlayManager.deepActiveElement;
}
function isVisible(el) {
assert.ok(el);
return getComputedStyle(el).getPropertyValue('display') != 'none';
}
function overlayObserver(mode) {
return new Promise(resolve => {
function listener() {
element.removeEventListener('iron-overlay-' + mode, listener);
resolve();
}
element.addEventListener('iron-overlay-' + mode, listener);
});
}
function isFocusInsideElement(element) {
// In Polymer 2 focused element either <paper-input> or nested
// native input <input> element depending on the current focus
// in browser window.
// For example, the focus is changed if the developer console
// get a focus.
let activeElement = getActiveElement();
while (activeElement) {
if (activeElement === element) {
return true;
}
if (activeElement.parentElement) {
activeElement = activeElement.parentElement;
} else {
activeElement = activeElement.getRootNode().host;
}
}
return false;
}
async function testConfirmationDialog(cc) {
const yesButton = element
.shadowRoot
.querySelector('.reviewerConfirmationButtons gr-button:first-child');
const noButton = element
.shadowRoot
.querySelector('.reviewerConfirmationButtons gr-button:last-child');
element._ccPendingConfirmation = null;
element._reviewerPendingConfirmation = null;
flush();
assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
// Cause the confirmation dialog to display.
let observer = overlayObserver('opened');
const group = {
id: 'id',
name: 'name',
};
if (cc) {
element._ccPendingConfirmation = {
group,
count: 10,
};
} else {
element._reviewerPendingConfirmation = {
group,
count: 10,
};
}
flush();
if (cc) {
assert.deepEqual(
element._ccPendingConfirmation,
element._pendingConfirmationDetails);
} else {
assert.deepEqual(
element._reviewerPendingConfirmation,
element._pendingConfirmationDetails);
}
await observer;
assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
observer = overlayObserver('closed');
const expected = 'Group name has 10 members';
assert.notEqual(
element.$.reviewerConfirmationOverlay.innerText
.indexOf(expected),
-1);
MockInteractions.tap(noButton); // close the overlay
await observer;
assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
// We should be focused on account entry input.
assert.isTrue(
isFocusInsideElement(
element.$.reviewers.$.entry.$.input.$.input
)
);
// No reviewer/CC should have been added.
assert.equal(element.$.ccs.additions().length, 0);
assert.equal(element.$.reviewers.additions().length, 0);
// Reopen confirmation dialog.
observer = overlayObserver('opened');
if (cc) {
element._ccPendingConfirmation = {
group,
count: 10,
};
} else {
element._reviewerPendingConfirmation = {
group,
count: 10,
};
}
await observer;
assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
observer = overlayObserver('closed');
MockInteractions.tap(yesButton); // Confirm the group.
await observer;
assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
const additions = cc ?
element.$.ccs.additions() :
element.$.reviewers.additions();
assert.deepEqual(
additions,
[
{
group: {
id: 'id',
name: 'name',
confirmed: true,
_group: true,
_pendingAdd: true,
},
},
]);
// We should be focused on account entry input.
if (cc) {
assert.isTrue(
isFocusInsideElement(
element.$.ccs.$.entry.$.input.$.input
)
);
} else {
assert.isTrue(
isFocusInsideElement(
element.$.reviewers.$.entry.$.input.$.input
)
);
}
}
test('cc confirmation', async () => {
testConfirmationDialog(true);
});
test('reviewer confirmation', async () => {
testConfirmationDialog(false);
});
test('_getStorageLocation', () => {
const actual = element._getStorageLocation();
assert.equal(actual.changeNum, changeNum);
assert.equal(actual.patchNum, '@change');
assert.equal(actual.path, '@change');
});
test('_reviewersMutated when account-text-change is fired from ccs', () => {
flush();
assert.isFalse(element._reviewersMutated);
assert.isTrue(element.$.ccs.allowAnyInput);
assert.isFalse(element.shadowRoot
.querySelector('#reviewers').allowAnyInput);
element.$.ccs.dispatchEvent(new CustomEvent('account-text-changed',
{bubbles: true, composed: true}));
assert.isTrue(element._reviewersMutated);
});
test('gets draft from storage on open', () => {
const storedDraft = 'hello world';
getDraftCommentStub.returns({message: storedDraft});
element.open();
assert.isTrue(getDraftCommentStub.called);
assert.equal(element.draft, storedDraft);
});
test('gets draft from storage even when text is already present', () => {
const storedDraft = 'hello world';
getDraftCommentStub.returns({message: storedDraft});
element.draft = 'foo bar';
element.open();
assert.isTrue(getDraftCommentStub.called);
assert.equal(element.draft, storedDraft);
});
test('blank if no stored draft', () => {
getDraftCommentStub.returns(null);
element.draft = 'foo bar';
element.open();
assert.isTrue(getDraftCommentStub.called);
assert.equal(element.draft, '');
});
test('does not check stored draft when quote is present', () => {
const storedDraft = 'hello world';
const quote = '> foo bar';
getDraftCommentStub.returns({message: storedDraft});
element.quote = quote;
element.open();
assert.isFalse(getDraftCommentStub.called);
assert.equal(element.draft, quote);
assert.isNotOk(element.quote);
});
test('updates stored draft on edits', () => {
const firstEdit = 'hello';
const location = element._getStorageLocation();
element.draft = firstEdit;
element.storeTask.flush();
assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
element.draft = '';
element.storeTask.flush();
assert.isTrue(eraseDraftCommentStub.calledWith(location));
});
test('400 converts to human-readable server-error', done => {
stubRestApi('saveChangeReview').callsFake(
(changeNum, patchNum, review, errFn) => {
errFn(cloneableResponse(
400,
'....{"reviewers":{"id1":{"error":"human readable"}}}'
));
return Promise.resolve(undefined);
}
);
const listener = event => {
if (event.target !== document) return;
event.detail.response.text().then(body => {
if (body === 'human readable') {
done();
}
});
};
addListenerForTest(document, 'server-error', listener);
flush(() => { element.send(); });
});
test('non-json 400 is treated as a normal server-error', done => {
stubRestApi('saveChangeReview').callsFake(
(changeNum, patchNum, review, errFn) => {
errFn(cloneableResponse(400, 'Comment validation error!'));
return Promise.resolve(undefined);
}
);
const listener = event => {
if (event.target !== document) return;
event.detail.response.text().then(body => {
if (body === 'Comment validation error!') {
done();
}
});
};
addListenerForTest(document, 'server-error', listener);
// Async tick is needed because iron-selector content is distributed and
// distributed content requires an observer to be set up.
flush(() => { element.send(); });
});
test('filterReviewerSuggestion', () => {
const owner = makeAccount();
const reviewer1 = makeAccount();
const reviewer2 = makeGroup();
const cc1 = makeAccount();
const cc2 = makeGroup();
let filter = element._filterReviewerSuggestionGenerator(false);
element._owner = owner;
element._reviewers = [reviewer1, reviewer2];
element._ccs = [cc1, cc2];
assert.isTrue(filter({account: makeAccount()}));
assert.isTrue(filter({group: makeGroup()}));
// Owner should be excluded.
assert.isFalse(filter({account: owner}));
// Existing and pending reviewers should be excluded when isCC = false.
assert.isFalse(filter({account: reviewer1}));
assert.isFalse(filter({group: reviewer2}));
filter = element._filterReviewerSuggestionGenerator(true);
// Existing and pending CCs should be excluded when isCC = true;.
assert.isFalse(filter({account: cc1}));
assert.isFalse(filter({group: cc2}));
});
test('_focusOn', async () => {
sinon.spy(element, '_chooseFocusTarget');
element._focusOn();
await flush();
assert.equal(element._chooseFocusTarget.callCount, 1);
assert.equal(element.shadowRoot.activeElement.tagName, 'GR-TEXTAREA');
assert.equal(element.shadowRoot.activeElement.id, 'textarea');
element._focusOn(element.FocusTarget.ANY);
await flush();
assert.equal(element._chooseFocusTarget.callCount, 2);
assert.equal(element.shadowRoot.activeElement.tagName, 'GR-TEXTAREA');
assert.equal(element.shadowRoot.activeElement.id, 'textarea');
element._focusOn(element.FocusTarget.BODY);
await flush();
assert.equal(element._chooseFocusTarget.callCount, 2);
assert.equal(element.shadowRoot.activeElement.tagName, 'GR-TEXTAREA');
assert.equal(element.shadowRoot.activeElement.id, 'textarea');
element._focusOn(element.FocusTarget.REVIEWERS);
await flush();
assert.equal(element._chooseFocusTarget.callCount, 2);
assert.equal(element.shadowRoot.activeElement.tagName, 'GR-ACCOUNT-LIST');
assert.equal(element.shadowRoot.activeElement.id, 'reviewers');
element._focusOn(element.FocusTarget.CCS);
await flush();
assert.equal(element._chooseFocusTarget.callCount, 2);
assert.equal(element.shadowRoot.activeElement.tagName, 'GR-ACCOUNT-LIST');
assert.equal(element.shadowRoot.activeElement.id, 'ccs');
});
test('_chooseFocusTarget', () => {
element._account = undefined;
assert.strictEqual(
element._chooseFocusTarget(), element.FocusTarget.BODY);
element._account = {_account_id: 1};
assert.strictEqual(
element._chooseFocusTarget(), element.FocusTarget.BODY);
element.change.owner = {_account_id: 2};
assert.strictEqual(
element._chooseFocusTarget(), element.FocusTarget.BODY);
element.change.owner._account_id = 1;
element.change._reviewers = null;
assert.strictEqual(
element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
element._reviewers = [];
assert.strictEqual(
element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
element._reviewers.push({});
assert.strictEqual(
element._chooseFocusTarget(), element.FocusTarget.BODY);
});
test('only send labels that have changed', done => {
flush(() => {
stubSaveReview(review => {
assert.deepEqual(review.labels, {
'Code-Review': 0,
'Verified': -1,
});
});
element.addEventListener('send', () => {
done();
});
// Without wrapping this test in flush(), the below two calls to
// MockInteractions.tap() cause a race in some situations in shadow DOM.
// The send button can be tapped before the others, causing the test to
// fail.
element.shadowRoot
.querySelector('gr-label-scores').shadowRoot
.querySelector(
'gr-label-score-row[name="Verified"]')
.setSelectedValue(-1);
MockInteractions.tap(element.shadowRoot
.querySelector('.send'));
});
});
test('_processReviewerChange', () => {
const mockIndexSplices = function(toRemove) {
return [{
removed: [toRemove],
}];
};
element._processReviewerChange(
mockIndexSplices(makeAccount()), 'REVIEWER');
assert.equal(element._reviewersPendingRemove.REVIEWER.length, 1);
});
test('_purgeReviewersPendingRemove', () => {
const removeStub = sinon.stub(element, '_removeAccount');
const mock = function() {
element._reviewersPendingRemove = {
CC: [makeAccount()],
REVIEWER: [makeAccount(), makeAccount()],
};
};
const checkObjEmpty = function(obj) {
for (const prop of Object.keys(obj)) {
if (obj[prop].length) { return false; }
}
return true;
};
mock();
element._purgeReviewersPendingRemove(true); // Cancel
assert.isFalse(removeStub.called);
assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
mock();
element._purgeReviewersPendingRemove(false); // Submit
assert.isTrue(removeStub.called);
assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
});
test('_removeAccount', done => {
stubRestApi('removeChangeReviewer')
.returns(Promise.resolve({ok: true}));
const arr = [makeAccount(), makeAccount()];
element.change.reviewers = {
REVIEWER: arr.slice(),
};
element._removeAccount(arr[1], 'REVIEWER').then(() => {
assert.equal(element.change.reviewers.REVIEWER.length, 1);
assert.deepEqual(element.change.reviewers.REVIEWER, arr.slice(0, 1));
done();
});
});
test('moving from cc to reviewer', () => {
element._reviewersPendingRemove = {
CC: [],
REVIEWER: [],
};
flush();
const reviewer1 = makeAccount();
const reviewer2 = makeAccount();
const reviewer3 = makeAccount();
const cc1 = makeAccount();
const cc2 = makeAccount();
const cc3 = makeAccount();
const cc4 = makeAccount();
element._reviewers = [reviewer1, reviewer2, reviewer3];
element._ccs = [cc1, cc2, cc3, cc4];
element.push('_reviewers', cc1);
flush();
assert.deepEqual(element._reviewers,
[reviewer1, reviewer2, reviewer3, cc1]);
assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
assert.deepEqual(element._reviewersPendingRemove.CC, [cc1]);
element.push('_reviewers', cc4, cc3);
flush();
assert.deepEqual(element._reviewers,
[reviewer1, reviewer2, reviewer3, cc1, cc4, cc3]);
assert.deepEqual(element._ccs, [cc2]);
assert.deepEqual(element._reviewersPendingRemove.CC, [cc1, cc4, cc3]);
});
test('update attention section when reviewers and ccs change', () => {
element._account = makeAccount();
element._reviewers = [makeAccount(), makeAccount()];
element._ccs = [makeAccount(), makeAccount()];
element.draftCommentThreads = [];
const modifyButton =
element.shadowRoot.querySelector('.edit-attention-button');
MockInteractions.tap(modifyButton);
flush();
// "Modify" button disabled, because "Send" button is disabled.
assert.isFalse(element._attentionExpanded);
element.draft = 'a test comment';
MockInteractions.tap(modifyButton);
flush();
assert.isTrue(element._attentionExpanded);
let accountLabels = Array.from(element.shadowRoot.querySelectorAll(
'.attention-detail gr-account-label'));
assert.equal(accountLabels.length, 5);
element.push('_reviewers', makeAccount());
element.push('_ccs', makeAccount());
flush();
// The 'attention modified' section collapses and resets when reviewers or
// ccs change.
assert.isFalse(element._attentionExpanded);
MockInteractions.tap(
element.shadowRoot.querySelector('.edit-attention-button'));
flush();
assert.isTrue(element._attentionExpanded);
accountLabels = Array.from(element.shadowRoot.querySelectorAll(
'.attention-detail gr-account-label'));
assert.equal(accountLabels.length, 7);
element.pop('_reviewers', makeAccount());
element.pop('_reviewers', makeAccount());
element.pop('_ccs', makeAccount());
element.pop('_ccs', makeAccount());
MockInteractions.tap(
element.shadowRoot.querySelector('.edit-attention-button'));
flush();
accountLabels = Array.from(element.shadowRoot.querySelectorAll(
'.attention-detail gr-account-label'));
assert.equal(accountLabels.length, 3);
});
test('moving from reviewer to cc', () => {
element._reviewersPendingRemove = {
CC: [],
REVIEWER: [],
};
flush();
const reviewer1 = makeAccount();
const reviewer2 = makeAccount();
const reviewer3 = makeAccount();
const cc1 = makeAccount();
const cc2 = makeAccount();
const cc3 = makeAccount();
const cc4 = makeAccount();
element._reviewers = [reviewer1, reviewer2, reviewer3];
element._ccs = [cc1, cc2, cc3, cc4];
element.push('_ccs', reviewer1);
flush();
assert.deepEqual(element._reviewers,
[reviewer2, reviewer3]);
assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
assert.deepEqual(element._reviewersPendingRemove.REVIEWER, [reviewer1]);
element.push('_ccs', reviewer3, reviewer2);
flush();
assert.deepEqual(element._reviewers, []);
assert.deepEqual(element._ccs,
[cc1, cc2, cc3, cc4, reviewer1, reviewer3, reviewer2]);
assert.deepEqual(element._reviewersPendingRemove.REVIEWER,
[reviewer1, reviewer3, reviewer2]);
});
test('migrate reviewers between states', async () => {
element._reviewersPendingRemove = {
CC: [],
REVIEWER: [],
};
flush();
const reviewers = element.$.reviewers;
const ccs = element.$.ccs;
const reviewer1 = makeAccount();
const reviewer2 = makeAccount();
const cc1 = makeAccount();
const cc2 = makeAccount();
const cc3 = makeAccount();
element._reviewers = [reviewer1, reviewer2];
element._ccs = [cc1, cc2, cc3];
const mutations = [];
stubSaveReview(review => mutations.push(...review.reviewers));
sinon.stub(element, '_removeAccount').callsFake((account, type) => {
mutations.push({state: 'REMOVED', account});
return Promise.resolve();
});
// Remove and add to other field.
reviewers.dispatchEvent(
new CustomEvent('remove', {
detail: {account: reviewer1},
composed: true, bubbles: true,
}));
ccs.$.entry.dispatchEvent(
new CustomEvent('add', {
detail: {value: {account: reviewer1}},
composed: true, bubbles: true,
}));
ccs.dispatchEvent(
new CustomEvent('remove', {
detail: {account: cc1},
composed: true, bubbles: true,
}));
ccs.dispatchEvent(
new CustomEvent('remove', {
detail: {account: cc3},
composed: true, bubbles: true,
}));
reviewers.$.entry.dispatchEvent(
new CustomEvent('add', {
detail: {value: {account: cc1}},
composed: true, bubbles: true,
}));
// Add to other field without removing from former field.
// (Currently not possible in UI, but this is a good consistency check).
reviewers.$.entry.dispatchEvent(
new CustomEvent('add', {
detail: {value: {account: cc2}},
composed: true, bubbles: true,
}));
ccs.$.entry.dispatchEvent(
new CustomEvent('add', {
detail: {value: {account: reviewer2}},
composed: true, bubbles: true,
}));
const mapReviewer = function(reviewer, opt_state) {
const result = {reviewer: reviewer._account_id};
if (opt_state) {
result.state = opt_state;
}
return result;
};
// Send and purge and verify moves, delete cc3.
await element.send()
.then(keepReviewers =>
element._purgeReviewersPendingRemove(false, keepReviewers));
expect(mutations).to.have.lengthOf(5);
expect(mutations[0]).to.deep.equal(mapReviewer(cc1));
expect(mutations[1]).to.deep.equal(mapReviewer(cc2));
expect(mutations[2]).to.deep.equal(mapReviewer(reviewer1, 'CC'));
expect(mutations[3]).to.deep.equal(mapReviewer(reviewer2, 'CC'));
expect(mutations[4]).to.deep.equal({account: cc3, state: 'REMOVED'});
});
test('emits cancel on esc key', () => {
const cancelHandler = sinon.spy();
element.addEventListener('cancel', cancelHandler);
MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
flush();
assert.isTrue(cancelHandler.called);
});
test('should not send on enter key', () => {
stubSaveReview(() => undefined);
element.addEventListener('send', () => assert.fail('wrongly called'));
MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
flush();
});
test('emit send on ctrl+enter key', done => {
stubSaveReview(() => undefined);
element.addEventListener('send', () => done());
MockInteractions.pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
flush();
});
test('_computeMessagePlaceholder', () => {
assert.equal(
element._computeMessagePlaceholder(false),
'Say something nice...');
assert.equal(
element._computeMessagePlaceholder(true),
'Add a note for your reviewers...');
});
test('_computeSendButtonLabel', () => {
assert.equal(
element._computeSendButtonLabel(false),
'Send');
assert.equal(
element._computeSendButtonLabel(true),
'Send and Start review');
});
test('_handle400Error reviewers and CCs', done => {
const error1 = 'error 1';
const error2 = 'error 2';
const error3 = 'error 3';
const text = ')]}\'' + JSON.stringify({
reviewers: {
username1: {
input: 'username1',
error: error1,
},
username2: {
input: 'username2',
error: error2,
},
username3: {
input: 'username3',
error: error3,
},
},
});
const listener = e => {
e.detail.response.text().then(text => {
assert.equal(text, [error1, error2, error3].join(', '));
done();
});
};
addListenerForTest(document, 'server-error', listener);
element._handle400Error(cloneableResponse(400, text));
});
test('fires height change when the drafts comments load', done => {
// Flush DOM operations before binding to the autogrow event so we don't
// catch the events fired from the initial layout.
flush(() => {
const autoGrowHandler = sinon.stub();
element.addEventListener('autogrow', autoGrowHandler);
element.draftCommentThreads = [];
flush(() => {
assert.isTrue(autoGrowHandler.called);
done();
});
});
});
suite('start review and save buttons', () => {
let sendStub;
setup(() => {
sendStub = sinon.stub(element, 'send').callsFake(() => Promise.resolve());
element.canBeStarted = true;
// Flush to make both Start/Save buttons appear in DOM.
flush();
});
test('start review sets ready', () => {
MockInteractions.tap(element.shadowRoot
.querySelector('.send'));
flush();
assert.isTrue(sendStub.calledWith(true, true));
});
test('save review doesn\'t set ready', () => {
MockInteractions.tap(element.shadowRoot
.querySelector('.save'));
flush();
assert.isTrue(sendStub.calledWith(true, false));
});
});
test('buttons disabled until all API calls are resolved', () => {
stubSaveReview(review => {
return {ready: true};
});
return element.send(true, true).then(() => {
assert.isFalse(element.disabled);
});
});
suite('error handling', () => {
const expectedDraft = 'draft';
const expectedError = new Error('test');
setup(() => {
element.draft = expectedDraft;
});
function assertDialogOpenAndEnabled() {
assert.strictEqual(expectedDraft, element.draft);
assert.isFalse(element.disabled);
}
test('error occurs in _saveReview', () => {
stubSaveReview(review => {
throw expectedError;
});
return element.send(true, true).catch(err => {
assert.strictEqual(expectedError, err);
assertDialogOpenAndEnabled();
});
});
suite('pending diff drafts?', () => {
test('yes', async () => {
const promise = mockPromise();
const refreshSpy = sinon.spy();
element.addEventListener('comment-refresh', refreshSpy);
stubRestApi('hasPendingDiffDrafts').returns(true);
stubRestApi('awaitPendingDiffDrafts').returns(promise);
element.open();
assert.isFalse(refreshSpy.called);
assert.isTrue(element._savingComments);
promise.resolve();
await flush();
assert.isTrue(refreshSpy.called);
assert.isFalse(element._savingComments);
});
test('no', () => {
stubRestApi('hasPendingDiffDrafts').returns(false);
element.open();
assert.isFalse(element._savingComments);
});
});
});
test('_computeSendButtonDisabled_canBeStarted', () => {
// Mock canBeStarted
assert.isFalse(element._computeSendButtonDisabled(
/* canBeStarted= */ true,
/* draftCommentThreads= */ [],
/* text= */ '',
/* reviewersMutated= */ false,
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
/* commentEditing= */ false,
/* change= */ element.change,
/* account= */ makeAccount()
));
});
test('_computeSendButtonDisabled_allFalse', () => {
// Mock everything false
assert.isTrue(element._computeSendButtonDisabled(
/* canBeStarted= */ false,
/* draftCommentThreads= */ [],
/* text= */ '',
/* reviewersMutated= */ false,
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
/* commentEditing= */ false,
/* change= */ element.change,
/* account= */ makeAccount()
));
});
test('_computeSendButtonDisabled_draftCommentsSend', () => {
// Mock nonempty comment draft array, with sending comments.
assert.isFalse(element._computeSendButtonDisabled(
/* canBeStarted= */ false,
/* draftCommentThreads= */ [{comments: [{__draft: true}]}],
/* text= */ '',
/* reviewersMutated= */ false,
/* labelsChanged= */ false,
/* includeComments= */ true,
/* disabled= */ false,
/* commentEditing= */ false,
/* change= */ element.change,
/* account= */ makeAccount()
));
});
test('_computeSendButtonDisabled_draftCommentsDoNotSend', () => {
// Mock nonempty comment draft array, without sending comments.
assert.isTrue(element._computeSendButtonDisabled(
/* canBeStarted= */ false,
/* draftCommentThreads= */ [{comments: [{__draft: true}]}],
/* text= */ '',
/* reviewersMutated= */ false,
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
/* commentEditing= */ false,
/* change= */ element.change,
/* account= */ makeAccount()
));
});
test('_computeSendButtonDisabled_changeMessage', () => {
// Mock nonempty change message.
assert.isFalse(element._computeSendButtonDisabled(
/* canBeStarted= */ false,
/* draftCommentThreads= */ {},
/* text= */ 'test',
/* reviewersMutated= */ false,
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
/* commentEditing= */ false,
/* change= */ element.change,
/* account= */ makeAccount()
));
});
test('_computeSendButtonDisabled_reviewersChanged', () => {
// Mock reviewers mutated.
assert.isFalse(element._computeSendButtonDisabled(
/* canBeStarted= */ false,
/* draftCommentThreads= */ {},
/* text= */ '',
/* reviewersMutated= */ true,
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
/* commentEditing= */ false,
/* change= */ element.change,
/* account= */ makeAccount()
));
});
test('_computeSendButtonDisabled_labelsChanged', () => {
// Mock labels changed.
assert.isFalse(element._computeSendButtonDisabled(
/* canBeStarted= */ false,
/* draftCommentThreads= */ {},
/* text= */ '',
/* reviewersMutated= */ false,
/* labelsChanged= */ true,
/* includeComments= */ false,
/* disabled= */ false,
/* commentEditing= */ false,
/* change= */ element.change,
/* account= */ makeAccount()
));
});
test('_computeSendButtonDisabled_dialogDisabled', () => {
// Whole dialog is disabled.
assert.isTrue(element._computeSendButtonDisabled(
/* canBeStarted= */ false,
/* draftCommentThreads= */ {},
/* text= */ '',
/* reviewersMutated= */ false,
/* labelsChanged= */ true,
/* includeComments= */ false,
/* disabled= */ true,
/* commentEditing= */ false,
/* change= */ element.change,
/* account= */ makeAccount()
));
});
test('_computeSendButtonDisabled_existingVote', async () => {
const account = createAccountWithId();
element.change.labels[CODE_REVIEW].all = [account];
await flush();
// User has already voted.
assert.isFalse(element._computeSendButtonDisabled(
/* canBeStarted= */ false,
/* draftCommentThreads= */ {},
/* text= */ '',
/* reviewersMutated= */ false,
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
/* commentEditing= */ false,
/* change= */ element.change,
/* account= */ account
));
});
test('_submit blocked when no mutations exist', async () => {
const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
// Stub the below function to avoid side effects from the send promise
// resolving.
sinon.stub(element, '_purgeReviewersPendingRemove');
element.account = makeAccount();
element.draftCommentThreads = [];
await flush();
MockInteractions.tap(element.shadowRoot
.querySelector('gr-button.send'));
assert.isFalse(sendStub.called);
element.draftCommentThreads = [{comments: [
{__draft: true, path: 'test', line: 1, patch_set: 1},
]}];
await flush();
MockInteractions.tap(element.shadowRoot
.querySelector('gr-button.send'));
assert.isTrue(sendStub.called);
});
test('getFocusStops', async () => {
// Setting draftCommentThreads to an empty object causes _sendDisabled to be
// computed to false.
element.draftCommentThreads = [];
element.account = makeAccount();
await flush();
assert.equal(element.getFocusStops().end, element.$.cancelButton);
element.draftCommentThreads = [
{comments: [{__draft: true, path: 'test', line: 1, patch_set: 1}]},
];
await flush();
assert.equal(element.getFocusStops().end, element.$.sendButton);
});
test('setPluginMessage', () => {
element.setPluginMessage('foo');
assert.equal(element.$.pluginMessage.textContent, 'foo');
});
});