| /** | 
 |  * @license | 
 |  * Copyright (C) 2020 The Android Open Source Project | 
 |  * | 
 |  * Licensed under the Apache License, Version 2.0 (the "License"); | 
 |  * you may not use this file except in compliance with the License. | 
 |  * You may obtain a copy of the License at | 
 |  * | 
 |  * http://www.apache.org/licenses/LICENSE-2.0 | 
 |  * | 
 |  * Unless required by applicable law or agreed to in writing, software | 
 |  * distributed under the License is distributed on an "AS IS" BASIS, | 
 |  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 
 |  * See the License for the specific language governing permissions and | 
 |  * limitations under the License. | 
 |  */ | 
 |  | 
 | import '../../../test/common-test-setup-karma'; | 
 | import '../../core/gr-router/gr-router'; | 
 | import './gr-change-metadata'; | 
 | import {GerritNav} from '../../core/gr-navigation/gr-navigation'; | 
 | import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader'; | 
 | import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit'; | 
 | import {GrChangeMetadata} from './gr-change-metadata'; | 
 | import { | 
 |   createServerInfo, | 
 |   createUserConfig, | 
 |   createParsedChange, | 
 |   createAccountWithId, | 
 |   createRequirement, | 
 |   createCommitInfoWithRequiredCommit, | 
 |   createWebLinkInfo, | 
 |   createGerritInfo, | 
 |   createGitPerson, | 
 |   createCommit, | 
 |   createRevision, | 
 |   createAccountDetailWithId, | 
 |   createChangeConfig, | 
 | } from '../../../test/test-data-generators'; | 
 | import { | 
 |   ChangeStatus, | 
 |   SubmitType, | 
 |   RequirementStatus, | 
 |   GpgKeyInfoStatus, | 
 | } from '../../../constants/constants'; | 
 | import { | 
 |   EmailAddress, | 
 |   AccountId, | 
 |   CommitId, | 
 |   ServerInfo, | 
 |   RevisionInfo, | 
 |   ParentCommitInfo, | 
 |   TopicName, | 
 |   ElementPropertyDeepChange, | 
 |   PatchSetNum, | 
 |   NumericChangeId, | 
 |   LabelValueToDescriptionMap, | 
 |   Hashtag, | 
 | } from '../../../types/common'; | 
 | import {SinonStubbedMember} from 'sinon'; | 
 | import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api'; | 
 | import {tap} from '@polymer/iron-test-helpers/mock-interactions'; | 
 | import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label'; | 
 | import {PluginApi} from '../../../api/plugin'; | 
 | import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator'; | 
 | import {queryAndAssert, stubRestApi} from '../../../test/test-utils'; | 
 | import {ParsedChangeInfo} from '../../../types/types'; | 
 | import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip'; | 
 | import {GrButton} from '../../shared/gr-button/gr-button'; | 
 |  | 
 | const basicFixture = fixtureFromElement('gr-change-metadata'); | 
 |  | 
 | const pluginApi = _testOnly_initGerritPluginApi(); | 
 |  | 
 | suite('gr-change-metadata tests', () => { | 
 |   let element: GrChangeMetadata; | 
 |  | 
 |   setup(() => { | 
 |     stubRestApi('getLoggedIn').returns(Promise.resolve(false)); | 
 |     stubRestApi('getConfig').returns( | 
 |       Promise.resolve({ | 
 |         ...createServerInfo(), | 
 |         user: { | 
 |           ...createUserConfig(), | 
 |           anonymous_coward_name: 'test coward name', | 
 |         }, | 
 |       }) | 
 |     ); | 
 |     element = basicFixture.instantiate(); | 
 |   }); | 
 |  | 
 |   test('_computeMergedCommitInfo', () => { | 
 |     const dummyRevs: {[revisionId: string]: RevisionInfo} = { | 
 |       1: createRevision(1), | 
 |       2: createRevision(2), | 
 |     }; | 
 |     assert.deepEqual( | 
 |       element._computeMergedCommitInfo('0' as CommitId, dummyRevs), | 
 |       {} | 
 |     ); | 
 |     assert.deepEqual( | 
 |       element._computeMergedCommitInfo('1' as CommitId, dummyRevs), | 
 |       dummyRevs[1].commit | 
 |     ); | 
 |  | 
 |     // Regression test for issue 5337. | 
 |     const commit = element._computeMergedCommitInfo('2' as CommitId, dummyRevs); | 
 |     assert.notDeepEqual(commit, dummyRevs[2]); | 
 |     assert.deepEqual(commit, dummyRevs[2].commit); | 
 |   }); | 
 |  | 
 |   test('computed fields', () => { | 
 |     assert.isFalse( | 
 |       element._computeHideStrategy({ | 
 |         ...createParsedChange(), | 
 |         status: ChangeStatus.NEW, | 
 |       }) | 
 |     ); | 
 |     assert.isTrue( | 
 |       element._computeHideStrategy({ | 
 |         ...createParsedChange(), | 
 |         status: ChangeStatus.MERGED, | 
 |       }) | 
 |     ); | 
 |     assert.isTrue( | 
 |       element._computeHideStrategy({ | 
 |         ...createParsedChange(), | 
 |         status: ChangeStatus.ABANDONED, | 
 |       }) | 
 |     ); | 
 |     assert.equal( | 
 |       element._computeStrategy({ | 
 |         ...createParsedChange(), | 
 |         submit_type: SubmitType.CHERRY_PICK, | 
 |       }), | 
 |       'Cherry Pick' | 
 |     ); | 
 |     assert.equal( | 
 |       element._computeStrategy({ | 
 |         ...createParsedChange(), | 
 |         submit_type: SubmitType.REBASE_ALWAYS, | 
 |       }), | 
 |       'Rebase Always' | 
 |     ); | 
 |   }); | 
 |  | 
 |   test('computed fields requirements', () => { | 
 |     assert.isFalse( | 
 |       element._computeShowRequirements({ | 
 |         ...createParsedChange(), | 
 |         status: ChangeStatus.MERGED, | 
 |       }) | 
 |     ); | 
 |     assert.isFalse( | 
 |       element._computeShowRequirements({ | 
 |         ...createParsedChange(), | 
 |         status: ChangeStatus.ABANDONED, | 
 |       }) | 
 |     ); | 
 |  | 
 |     // No labels and no requirements: submit status is useless | 
 |     assert.isFalse( | 
 |       element._computeShowRequirements({ | 
 |         ...createParsedChange(), | 
 |         status: ChangeStatus.NEW, | 
 |         labels: {}, | 
 |       }) | 
 |     ); | 
 |  | 
 |     // Work in Progress: submit status should be present | 
 |     assert.isTrue( | 
 |       element._computeShowRequirements({ | 
 |         ...createParsedChange(), | 
 |         status: ChangeStatus.NEW, | 
 |         labels: {}, | 
 |         work_in_progress: true, | 
 |       }) | 
 |     ); | 
 |  | 
 |     // We have at least one reason to display Submit Status | 
 |     assert.isTrue( | 
 |       element._computeShowRequirements({ | 
 |         ...createParsedChange(), | 
 |         status: ChangeStatus.NEW, | 
 |         labels: { | 
 |           Verified: { | 
 |             approved: createAccountWithId(), | 
 |           }, | 
 |         }, | 
 |         requirements: [], | 
 |       }) | 
 |     ); | 
 |     assert.isTrue( | 
 |       element._computeShowRequirements({ | 
 |         ...createParsedChange(), | 
 |         status: ChangeStatus.NEW, | 
 |         labels: {}, | 
 |         requirements: [ | 
 |           { | 
 |             ...createRequirement(), | 
 |             fallbackText: 'Resolve all comments', | 
 |             status: RequirementStatus.OK, | 
 |           }, | 
 |         ], | 
 |       }) | 
 |     ); | 
 |   }); | 
 |  | 
 |   test('show strategy for open change', () => { | 
 |     element.change = { | 
 |       ...createParsedChange(), | 
 |       status: ChangeStatus.NEW, | 
 |       submit_type: SubmitType.CHERRY_PICK, | 
 |       labels: {}, | 
 |     }; | 
 |     flush(); | 
 |     const strategy = element.shadowRoot?.querySelector('.strategy'); | 
 |     assert.ok(strategy); | 
 |     assert.isFalse(strategy?.hasAttribute('hidden')); | 
 |     assert.equal(strategy?.children[1].innerHTML, 'Cherry Pick'); | 
 |   }); | 
 |  | 
 |   test('hide strategy for closed change', () => { | 
 |     element.change = { | 
 |       ...createParsedChange(), | 
 |       status: ChangeStatus.MERGED, | 
 |       labels: {}, | 
 |     }; | 
 |     flush(); | 
 |     assert.isTrue( | 
 |       element.shadowRoot?.querySelector('.strategy')?.hasAttribute('hidden') | 
 |     ); | 
 |   }); | 
 |  | 
 |   test('weblinks use GerritNav interface', () => { | 
 |     const weblinksStub = sinon | 
 |       .stub(GerritNav, '_generateWeblinks') | 
 |       .returns([{name: 'stubb', url: '#s'}]); | 
 |     element.commitInfo = createCommitInfoWithRequiredCommit(); | 
 |     element.serverConfig = createServerInfo(); | 
 |     flush(); | 
 |     const webLinks = element.$.webLinks; | 
 |     assert.isTrue(weblinksStub.called); | 
 |     assert.isFalse(webLinks.hasAttribute('hidden')); | 
 |     assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1); | 
 |   }); | 
 |  | 
 |   test('weblinks hidden when no weblinks', () => { | 
 |     element.commitInfo = createCommitInfoWithRequiredCommit(); | 
 |     element.serverConfig = createServerInfo(); | 
 |     flush(); | 
 |     const webLinks = element.$.webLinks; | 
 |     assert.isTrue(webLinks.hasAttribute('hidden')); | 
 |   }); | 
 |  | 
 |   test('weblinks hidden when only gitiles weblink', () => { | 
 |     element.commitInfo = { | 
 |       ...createCommitInfoWithRequiredCommit(), | 
 |       web_links: [{...createWebLinkInfo(), name: 'gitiles', url: '#'}], | 
 |     }; | 
 |     element.serverConfig = createServerInfo(); | 
 |     flush(); | 
 |     const webLinks = element.$.webLinks; | 
 |     assert.isTrue(webLinks.hasAttribute('hidden')); | 
 |     assert.equal(element._computeWebLinks(element.commitInfo), null); | 
 |   }); | 
 |  | 
 |   test('weblinks hidden when sole weblink is set as primary', () => { | 
 |     const browser = 'browser'; | 
 |     element.commitInfo = { | 
 |       ...createCommitInfoWithRequiredCommit(), | 
 |       web_links: [{...createWebLinkInfo(), name: browser, url: '#'}], | 
 |     }; | 
 |     element.serverConfig = { | 
 |       ...createServerInfo(), | 
 |       gerrit: { | 
 |         ...createGerritInfo(), | 
 |         primary_weblink_name: browser, | 
 |       }, | 
 |     }; | 
 |     flush(); | 
 |     const webLinks = element.$.webLinks; | 
 |     assert.isTrue(webLinks.hasAttribute('hidden')); | 
 |   }); | 
 |  | 
 |   test('weblinks are visible when other weblinks', () => { | 
 |     const router = document.createElement('gr-router'); | 
 |     sinon | 
 |       .stub(GerritNav, '_generateWeblinks') | 
 |       .callsFake(router._generateWeblinks.bind(router)); | 
 |  | 
 |     element.commitInfo = { | 
 |       ...createCommitInfoWithRequiredCommit(), | 
 |       web_links: [{...createWebLinkInfo(), name: 'test', url: '#'}], | 
 |     }; | 
 |     flush(); | 
 |     const webLinks = element.$.webLinks; | 
 |     assert.isFalse(webLinks.hasAttribute('hidden')); | 
 |     assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1); | 
 |     // With two non-gitiles weblinks, there are two returned. | 
 |     element.commitInfo = { | 
 |       ...createCommitInfoWithRequiredCommit(), | 
 |       web_links: [ | 
 |         {...createWebLinkInfo(), name: 'test', url: '#'}, | 
 |         {...createWebLinkInfo(), name: 'test2', url: '#'}, | 
 |       ], | 
 |     }; | 
 |     assert.equal(element._computeWebLinks(element.commitInfo)?.length, 2); | 
 |   }); | 
 |  | 
 |   test('weblinks are visible when gitiles and other weblinks', () => { | 
 |     const router = document.createElement('gr-router'); | 
 |     sinon | 
 |       .stub(GerritNav, '_generateWeblinks') | 
 |       .callsFake(router._generateWeblinks.bind(router)); | 
 |  | 
 |     element.commitInfo = { | 
 |       ...createCommitInfoWithRequiredCommit(), | 
 |       web_links: [ | 
 |         {...createWebLinkInfo(), name: 'test', url: '#'}, | 
 |         {...createWebLinkInfo(), name: 'gitiles', url: '#'}, | 
 |       ], | 
 |     }; | 
 |     flush(); | 
 |     const webLinks = element.$.webLinks; | 
 |     assert.isFalse(webLinks.hasAttribute('hidden')); | 
 |     // Only the non-gitiles weblink is returned. | 
 |     assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1); | 
 |   }); | 
 |  | 
 |   suite('_getNonOwnerRole', () => { | 
 |     let change: ParsedChangeInfo | undefined; | 
 |  | 
 |     setup(() => { | 
 |       change = { | 
 |         ...createParsedChange(), | 
 |         owner: { | 
 |           ...createAccountWithId(), | 
 |           email: 'abc@def' as EmailAddress, | 
 |           _account_id: 1019328 as AccountId, | 
 |         }, | 
 |         revisions: { | 
 |           rev1: { | 
 |             ...createRevision(), | 
 |             uploader: { | 
 |               ...createAccountWithId(), | 
 |               email: 'ghi@def' as EmailAddress, | 
 |               _account_id: 1011123 as AccountId, | 
 |             }, | 
 |             commit: { | 
 |               ...createCommit(), | 
 |               author: {...createGitPerson(), email: 'jkl@def' as EmailAddress}, | 
 |               committer: { | 
 |                 ...createGitPerson(), | 
 |                 email: 'ghi@def' as EmailAddress, | 
 |               }, | 
 |             }, | 
 |           }, | 
 |         }, | 
 |         current_revision: 'rev1' as CommitId, | 
 |       }; | 
 |     }); | 
 |  | 
 |     suite('role=uploader', () => { | 
 |       test('_getNonOwnerRole for uploader', () => { | 
 |         assert.deepEqual( | 
 |           element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER), | 
 |           { | 
 |             ...createAccountWithId(), | 
 |             email: 'ghi@def' as EmailAddress, | 
 |             _account_id: 1011123 as AccountId, | 
 |           } | 
 |         ); | 
 |       }); | 
 |  | 
 |       test('_getNonOwnerRole that it does not return uploader', () => { | 
 |         // Set the uploader email to be the same as the owner. | 
 |         change!.revisions.rev1.uploader!._account_id = 1019328 as AccountId; | 
 |         assert.isNotOk( | 
 |           element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER) | 
 |         ); | 
 |       }); | 
 |  | 
 |       test('_computeShowRoleClass show uploader', () => { | 
 |         assert.equal( | 
 |           element._computeShowRoleClass(change, element._CHANGE_ROLE.UPLOADER), | 
 |           '' | 
 |         ); | 
 |       }); | 
 |  | 
 |       test('_computeShowRoleClass hide uploader', () => { | 
 |         // Set the uploader email to be the same as the owner. | 
 |         change!.revisions.rev1.uploader!._account_id = 1019328 as AccountId; | 
 |         assert.equal( | 
 |           element._computeShowRoleClass(change, element._CHANGE_ROLE.UPLOADER), | 
 |           'hideDisplay' | 
 |         ); | 
 |       }); | 
 |     }); | 
 |  | 
 |     suite('role=committer', () => { | 
 |       test('_getNonOwnerRole for committer', () => { | 
 |         change!.revisions.rev1.uploader!.email = 'ghh@def' as EmailAddress; | 
 |         assert.deepEqual( | 
 |           element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER), | 
 |           {...createGitPerson(), email: 'ghi@def' as EmailAddress} | 
 |         ); | 
 |       }); | 
 |  | 
 |       test('_getNonOwnerRole is null if committer is same as uploader', () => { | 
 |         assert.isNotOk( | 
 |           element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER) | 
 |         ); | 
 |       }); | 
 |  | 
 |       test('_getNonOwnerRole that it does not return committer', () => { | 
 |         // Set the committer email to be the same as the owner. | 
 |         change!.revisions.rev1.commit!.committer.email = | 
 |           'abc@def' as EmailAddress; | 
 |         assert.isNotOk( | 
 |           element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER) | 
 |         ); | 
 |       }); | 
 |  | 
 |       test('_getNonOwnerRole null for committer with no commit', () => { | 
 |         delete change!.revisions.rev1.commit; | 
 |         assert.isNotOk( | 
 |           element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER) | 
 |         ); | 
 |       }); | 
 |     }); | 
 |  | 
 |     suite('role=author', () => { | 
 |       test('_getNonOwnerRole for author', () => { | 
 |         assert.deepEqual( | 
 |           element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR), | 
 |           {...createGitPerson(), email: 'jkl@def' as EmailAddress} | 
 |         ); | 
 |       }); | 
 |  | 
 |       test('_getNonOwnerRole that it does not return author', () => { | 
 |         // Set the author email to be the same as the owner. | 
 |         change!.revisions.rev1.commit!.author.email = 'abc@def' as EmailAddress; | 
 |         assert.isNotOk( | 
 |           element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR) | 
 |         ); | 
 |       }); | 
 |  | 
 |       test('_getNonOwnerRole null for author with no commit', () => { | 
 |         delete change!.revisions.rev1.commit; | 
 |         assert.isNotOk( | 
 |           element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR) | 
 |         ); | 
 |       }); | 
 |     }); | 
 |   }); | 
 |  | 
 |   suite('Push Certificate Validation', () => { | 
 |     let serverConfig: ServerInfo | undefined; | 
 |     let change: ParsedChangeInfo | undefined; | 
 |  | 
 |     setup(() => { | 
 |       serverConfig = { | 
 |         ...createServerInfo(), | 
 |         receive: { | 
 |           enable_signed_push: 'true', | 
 |         }, | 
 |       }; | 
 |       change = { | 
 |         ...createParsedChange(), | 
 |         revisions: { | 
 |           rev1: { | 
 |             ...createRevision(1), | 
 |             push_certificate: { | 
 |               certificate: 'Push certificate', | 
 |               key: { | 
 |                 status: GpgKeyInfoStatus.BAD, | 
 |                 problems: ['No public keys found for key ID E5E20E52'], | 
 |               }, | 
 |             }, | 
 |           }, | 
 |         }, | 
 |         current_revision: 'rev1' as CommitId, | 
 |         status: ChangeStatus.NEW, | 
 |         labels: {}, | 
 |         mergeable: true, | 
 |       }; | 
 |     }); | 
 |  | 
 |     test('Push Certificate Validation test BAD', () => { | 
 |       change!.revisions.rev1!.push_certificate = { | 
 |         certificate: 'Push certificate', | 
 |         key: { | 
 |           status: GpgKeyInfoStatus.BAD, | 
 |           problems: ['No public keys found for key ID E5E20E52'], | 
 |         }, | 
 |       }; | 
 |       const result = element._computePushCertificateValidation( | 
 |         serverConfig, | 
 |         change | 
 |       ); | 
 |       assert.equal( | 
 |         result?.message, | 
 |         'Push certificate is invalid:\n' + | 
 |           'No public keys found for key ID E5E20E52' | 
 |       ); | 
 |       assert.equal(result?.icon, 'gr-icons:close'); | 
 |       assert.equal(result?.class, 'invalid'); | 
 |     }); | 
 |  | 
 |     test('Push Certificate Validation test TRUSTED', () => { | 
 |       change!.revisions.rev1!.push_certificate = { | 
 |         certificate: 'Push certificate', | 
 |         key: { | 
 |           status: GpgKeyInfoStatus.TRUSTED, | 
 |         }, | 
 |       }; | 
 |       const result = element._computePushCertificateValidation( | 
 |         serverConfig, | 
 |         change | 
 |       ); | 
 |       assert.equal( | 
 |         result?.message, | 
 |         'Push certificate is valid and key is trusted' | 
 |       ); | 
 |       assert.equal(result?.icon, 'gr-icons:check'); | 
 |       assert.equal(result?.class, 'trusted'); | 
 |     }); | 
 |  | 
 |     test('Push Certificate Validation is missing test', () => { | 
 |       change!.revisions.rev1! = createRevision(1); | 
 |       const result = element._computePushCertificateValidation( | 
 |         serverConfig, | 
 |         change | 
 |       ); | 
 |       assert.equal( | 
 |         result?.message, | 
 |         'This patch set was created without a push certificate' | 
 |       ); | 
 |       assert.equal(result?.icon, 'gr-icons:help'); | 
 |       assert.equal(result?.class, 'help'); | 
 |     }); | 
 |   }); | 
 |  | 
 |   test('_computeParents', () => { | 
 |     const parents: ParentCommitInfo[] = [ | 
 |       {...createCommit(), commit: '123' as CommitId, subject: 'abc'}, | 
 |     ]; | 
 |     const revision: RevisionInfo = { | 
 |       ...createRevision(1), | 
 |       commit: {...createCommit(), parents}, | 
 |     }; | 
 |     assert.equal(element._computeParents(undefined, revision), parents); | 
 |     const change = (current_revision: CommitId): ParsedChangeInfo => { | 
 |       return { | 
 |         ...createParsedChange(), | 
 |         current_revision, | 
 |         revisions: {456: revision}, | 
 |       }; | 
 |     }; | 
 |     const change_bad_revision = change('789' as CommitId); | 
 |     assert.deepEqual( | 
 |       element._computeParents(change_bad_revision, createRevision()), | 
 |       [] | 
 |     ); | 
 |     const change_no_commit: ParsedChangeInfo = { | 
 |       ...createParsedChange(), | 
 |       current_revision: '456' as CommitId, | 
 |       revisions: {456: createRevision()}, | 
 |     }; | 
 |     assert.deepEqual(element._computeParents(change_no_commit, undefined), []); | 
 |     const change_good = change('456' as CommitId); | 
 |     assert.equal(element._computeParents(change_good, undefined), parents); | 
 |   }); | 
 |  | 
 |   test('_currentParents', () => { | 
 |     const revision = (parent: CommitId): RevisionInfo => { | 
 |       return { | 
 |         ...createRevision(), | 
 |         commit: { | 
 |           ...createCommit(), | 
 |           parents: [{...createCommit(), commit: parent, subject: 'abc'}], | 
 |         }, | 
 |       }; | 
 |     }; | 
 |     element.change = { | 
 |       ...createParsedChange(), | 
 |       current_revision: '456' as CommitId, | 
 |       revisions: {456: revision('111' as CommitId)}, | 
 |       owner: {}, | 
 |     }; | 
 |     element.revision = revision('222' as CommitId); | 
 |     assert.equal(element._currentParents[0].commit, '222'); | 
 |     element.revision = revision('333' as CommitId); | 
 |     assert.equal(element._currentParents[0].commit, '333'); | 
 |     element.revision = undefined; | 
 |     assert.equal(element._currentParents[0].commit, '111'); | 
 |     element.change = createParsedChange(); | 
 |     assert.deepEqual(element._currentParents, []); | 
 |   }); | 
 |  | 
 |   test('_computeParentsLabel', () => { | 
 |     const parent: ParentCommitInfo = { | 
 |       ...createCommit(), | 
 |       commit: 'abc123' as CommitId, | 
 |       subject: 'My parent commit', | 
 |     }; | 
 |     assert.equal(element._computeParentsLabel([parent]), 'Parent'); | 
 |     assert.equal(element._computeParentsLabel([parent, parent]), 'Parents'); | 
 |   }); | 
 |  | 
 |   test('_computeParentListClass', () => { | 
 |     const parent: ParentCommitInfo = { | 
 |       ...createCommit(), | 
 |       commit: 'abc123' as CommitId, | 
 |       subject: 'My parent commit', | 
 |     }; | 
 |     assert.equal( | 
 |       element._computeParentListClass([parent], true), | 
 |       'parentList nonMerge current' | 
 |     ); | 
 |     assert.equal( | 
 |       element._computeParentListClass([parent], false), | 
 |       'parentList nonMerge notCurrent' | 
 |     ); | 
 |     assert.equal( | 
 |       element._computeParentListClass([parent, parent], false), | 
 |       'parentList merge notCurrent' | 
 |     ); | 
 |     assert.equal( | 
 |       element._computeParentListClass([parent, parent], true), | 
 |       'parentList merge current' | 
 |     ); | 
 |   }); | 
 |  | 
 |   test('_showAddTopic', () => { | 
 |     const changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'> = | 
 |       { | 
 |         base: {...createParsedChange()}, | 
 |         path: '', | 
 |         value: undefined, | 
 |       }; | 
 |     assert.isTrue(element._showAddTopic(undefined, false)); | 
 |     assert.isTrue(element._showAddTopic(changeRecord, false)); | 
 |     assert.isFalse(element._showAddTopic(changeRecord, true)); | 
 |     changeRecord.base!.topic = 'foo' as TopicName; | 
 |     assert.isFalse(element._showAddTopic(changeRecord, true)); | 
 |     assert.isFalse(element._showAddTopic(changeRecord, false)); | 
 |   }); | 
 |  | 
 |   test('_showTopicChip', () => { | 
 |     const changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'> = | 
 |       { | 
 |         base: {...createParsedChange()}, | 
 |         path: '', | 
 |         value: undefined, | 
 |       }; | 
 |     assert.isFalse(element._showTopicChip(undefined, false)); | 
 |     assert.isFalse(element._showTopicChip(changeRecord, false)); | 
 |     assert.isFalse(element._showTopicChip(changeRecord, true)); | 
 |     changeRecord.base!.topic = 'foo' as TopicName; | 
 |     assert.isFalse(element._showTopicChip(changeRecord, true)); | 
 |     assert.isTrue(element._showTopicChip(changeRecord, false)); | 
 |   }); | 
 |  | 
 |   test('_showCherryPickOf', () => { | 
 |     const changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'> = | 
 |       { | 
 |         base: {...createParsedChange()}, | 
 |         path: '', | 
 |         value: undefined, | 
 |       }; | 
 |     assert.isFalse(element._showCherryPickOf(undefined)); | 
 |     assert.isFalse(element._showCherryPickOf(changeRecord)); | 
 |     changeRecord.base!.cherry_pick_of_change = 123 as NumericChangeId; | 
 |     changeRecord.base!.cherry_pick_of_patch_set = 1 as PatchSetNum; | 
 |     assert.isTrue(element._showCherryPickOf(changeRecord)); | 
 |   }); | 
 |  | 
 |   suite('Topic removal', () => { | 
 |     let change: ParsedChangeInfo; | 
 |     setup(() => { | 
 |       change = { | 
 |         ...createParsedChange(), | 
 |         actions: { | 
 |           topic: {enabled: false}, | 
 |         }, | 
 |         topic: 'the topic' as TopicName, | 
 |         status: ChangeStatus.NEW, | 
 |         submit_type: SubmitType.CHERRY_PICK, | 
 |         labels: { | 
 |           test: { | 
 |             all: [{_account_id: 1 as AccountId, name: 'bojack', value: 1}], | 
 |             default_value: 0, | 
 |             values: [] as unknown as LabelValueToDescriptionMap, | 
 |           }, | 
 |         }, | 
 |         removable_reviewers: [], | 
 |       }; | 
 |     }); | 
 |  | 
 |     test('_computeTopicReadOnly', () => { | 
 |       let mutable = false; | 
 |       assert.isTrue(element._computeTopicReadOnly(mutable, change)); | 
 |       mutable = true; | 
 |       assert.isTrue(element._computeTopicReadOnly(mutable, change)); | 
 |       change!.actions!.topic!.enabled = true; | 
 |       assert.isFalse(element._computeTopicReadOnly(mutable, change)); | 
 |       mutable = false; | 
 |       assert.isTrue(element._computeTopicReadOnly(mutable, change)); | 
 |     }); | 
 |  | 
 |     test('topic read only hides delete button', async () => { | 
 |       element.account = createAccountDetailWithId(); | 
 |       element.change = change; | 
 |       sinon.stub(GerritNav, 'getUrlForTopic').returns('/q/topic:test'); | 
 |       await flush(); | 
 |       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip'); | 
 |       const button = queryAndAssert<GrButton>(chip, 'gr-button'); | 
 |       assert.isTrue(button.hasAttribute('hidden')); | 
 |     }); | 
 |  | 
 |     test('topic not read only does not hide delete button', async () => { | 
 |       element.account = createAccountDetailWithId(); | 
 |       change.actions!.topic!.enabled = true; | 
 |       element.change = change; | 
 |       sinon.stub(GerritNav, 'getUrlForTopic').returns('/q/topic:test'); | 
 |       await flush(); | 
 |       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip'); | 
 |       const button = queryAndAssert<GrButton>(chip, 'gr-button'); | 
 |       assert.isFalse(button.hasAttribute('hidden')); | 
 |     }); | 
 |   }); | 
 |  | 
 |   suite('Hashtag removal', () => { | 
 |     let change: ParsedChangeInfo; | 
 |     setup(() => { | 
 |       change = { | 
 |         ...createParsedChange(), | 
 |         actions: { | 
 |           hashtags: {enabled: false}, | 
 |         }, | 
 |         hashtags: ['test-hashtag' as Hashtag], | 
 |         labels: { | 
 |           test: { | 
 |             all: [{_account_id: 1 as AccountId, name: 'bojack', value: 1}], | 
 |             default_value: 0, | 
 |             values: [] as unknown as LabelValueToDescriptionMap, | 
 |           }, | 
 |         }, | 
 |         removable_reviewers: [], | 
 |       }; | 
 |     }); | 
 |  | 
 |     test('_computeHashtagReadOnly', async () => { | 
 |       await flush(); | 
 |       let mutable = false; | 
 |       assert.isTrue(element._computeHashtagReadOnly(mutable, change)); | 
 |       mutable = true; | 
 |       assert.isTrue(element._computeHashtagReadOnly(mutable, change)); | 
 |       change!.actions!.hashtags!.enabled = true; | 
 |       assert.isFalse(element._computeHashtagReadOnly(mutable, change)); | 
 |       mutable = false; | 
 |       assert.isTrue(element._computeHashtagReadOnly(mutable, change)); | 
 |     }); | 
 |  | 
 |     test('hashtag read only hides delete button', async () => { | 
 |       await flush(); | 
 |       element.account = createAccountDetailWithId(); | 
 |       element.change = change; | 
 |       sinon | 
 |         .stub(GerritNav, 'getUrlForHashtag') | 
 |         .returns('/q/hashtag:test+(status:open%20OR%20status:merged)'); | 
 |       await flush(); | 
 |       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip'); | 
 |       const button = queryAndAssert<GrButton>(chip, 'gr-button'); | 
 |       assert.isTrue(button.hasAttribute('hidden')); | 
 |     }); | 
 |  | 
 |     test('hashtag not read only does not hide delete button', async () => { | 
 |       await flush(); | 
 |       element.account = createAccountDetailWithId(); | 
 |       change!.actions!.hashtags!.enabled = true; | 
 |       element.change = change; | 
 |       sinon | 
 |         .stub(GerritNav, 'getUrlForHashtag') | 
 |         .returns('/q/hashtag:test+(status:open%20OR%20status:merged)'); | 
 |       await flush(); | 
 |       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip'); | 
 |       const button = queryAndAssert<GrButton>(chip, 'gr-button'); | 
 |       assert.isFalse(button.hasAttribute('hidden')); | 
 |     }); | 
 |   }); | 
 |  | 
 |   suite('remove reviewer votes', () => { | 
 |     setup(() => { | 
 |       sinon.stub(element, '_computeTopicReadOnly').returns(true); | 
 |       element.change = { | 
 |         ...createParsedChange(), | 
 |         topic: 'the topic' as TopicName, | 
 |         labels: { | 
 |           test: { | 
 |             all: [{_account_id: 1 as AccountId, name: 'bojack', value: 1}], | 
 |             default_value: 0, | 
 |             values: [] as unknown as LabelValueToDescriptionMap, | 
 |           }, | 
 |         }, | 
 |         removable_reviewers: [], | 
 |       }; | 
 |       flush(); | 
 |     }); | 
 |  | 
 |     suite('assignee field', () => { | 
 |       const dummyAccount = createAccountWithId(); | 
 |       const change: ParsedChangeInfo = { | 
 |         ...createParsedChange(), | 
 |         actions: { | 
 |           assignee: {enabled: false}, | 
 |         }, | 
 |         assignee: dummyAccount, | 
 |       }; | 
 |       let deleteStub: SinonStubbedMember<RestApiService['deleteAssignee']>; | 
 |       let setStub: SinonStubbedMember<RestApiService['setAssignee']>; | 
 |  | 
 |       setup(() => { | 
 |         deleteStub = stubRestApi('deleteAssignee'); | 
 |         setStub = stubRestApi('setAssignee'); | 
 |         element.serverConfig = { | 
 |           ...createServerInfo(), | 
 |           change: { | 
 |             ...createChangeConfig(), | 
 |             enable_assignee: true, | 
 |           }, | 
 |         }; | 
 |       }); | 
 |  | 
 |       test('changing change recomputes _assignee', () => { | 
 |         assert.isFalse(!!element._assignee?.length); | 
 |         const change = element.change; | 
 |         change!.assignee = dummyAccount; | 
 |         element._changeChanged(change); | 
 |         assert.deepEqual(element?._assignee?.[0], dummyAccount); | 
 |       }); | 
 |  | 
 |       test('modifying _assignee calls API', () => { | 
 |         assert.isFalse(!!element._assignee?.length); | 
 |         element.set('_assignee', [dummyAccount]); | 
 |         assert.isTrue(setStub.calledOnce); | 
 |         assert.deepEqual(element.change!.assignee, dummyAccount); | 
 |         element.set('_assignee', [dummyAccount]); | 
 |         assert.isTrue(setStub.calledOnce); | 
 |         element.set('_assignee', []); | 
 |         assert.isTrue(deleteStub.calledOnce); | 
 |         assert.equal(element.change!.assignee, undefined); | 
 |         element.set('_assignee', []); | 
 |         assert.isTrue(deleteStub.calledOnce); | 
 |       }); | 
 |  | 
 |       test('_computeAssigneeReadOnly', () => { | 
 |         let mutable = false; | 
 |         assert.isTrue(element._computeAssigneeReadOnly(mutable, change)); | 
 |         mutable = true; | 
 |         assert.isTrue(element._computeAssigneeReadOnly(mutable, change)); | 
 |         change.actions!.assignee!.enabled = true; | 
 |         assert.isFalse(element._computeAssigneeReadOnly(mutable, change)); | 
 |         mutable = false; | 
 |         assert.isTrue(element._computeAssigneeReadOnly(mutable, change)); | 
 |       }); | 
 |     }); | 
 |  | 
 |     test('changing topic', () => { | 
 |       const newTopic = 'the new topic' as TopicName; | 
 |       const setChangeTopicStub = stubRestApi('setChangeTopic').returns( | 
 |         Promise.resolve(newTopic) | 
 |       ); | 
 |       element._handleTopicChanged(new CustomEvent('test', {detail: newTopic})); | 
 |       const topicChangedSpy = sinon.spy(); | 
 |       element.addEventListener('topic-changed', topicChangedSpy); | 
 |       assert.isTrue( | 
 |         setChangeTopicStub.calledWith(42 as NumericChangeId, newTopic) | 
 |       ); | 
 |       return setChangeTopicStub.lastCall.returnValue.then(() => { | 
 |         assert.equal(element.change!.topic, newTopic); | 
 |         assert.isTrue(topicChangedSpy.called); | 
 |       }); | 
 |     }); | 
 |  | 
 |     test('topic removal', async () => { | 
 |       const newTopic = 'the new topic' as TopicName; | 
 |       const setChangeTopicStub = stubRestApi('setChangeTopic').returns( | 
 |         Promise.resolve(newTopic) | 
 |       ); | 
 |       sinon.stub(GerritNav, 'getUrlForTopic').returns('/q/topic:the+new+topic'); | 
 |       await flush(); | 
 |       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip'); | 
 |       const remove = queryAndAssert(chip, '#remove'); | 
 |       const topicChangedSpy = sinon.spy(); | 
 |       element.addEventListener('topic-changed', topicChangedSpy); | 
 |       tap(remove); | 
 |       assert.isTrue(chip?.disabled); | 
 |       assert.isTrue(setChangeTopicStub.calledWith(42 as NumericChangeId)); | 
 |       return setChangeTopicStub.lastCall.returnValue.then(() => { | 
 |         assert.isFalse(chip?.disabled); | 
 |         assert.equal(element.change!.topic, '' as TopicName); | 
 |         assert.isTrue(topicChangedSpy.called); | 
 |       }); | 
 |     }); | 
 |  | 
 |     test('changing hashtag', async () => { | 
 |       await flush(); | 
 |       element._newHashtag = 'new hashtag' as Hashtag; | 
 |       const newHashtag: Hashtag[] = ['new hashtag' as Hashtag]; | 
 |       const setChangeHashtagStub = stubRestApi('setChangeHashtag').returns( | 
 |         Promise.resolve(newHashtag) | 
 |       ); | 
 |       element._handleHashtagChanged(); | 
 |       assert.isTrue( | 
 |         setChangeHashtagStub.calledWith(42 as NumericChangeId, { | 
 |           add: ['new hashtag' as Hashtag], | 
 |         }) | 
 |       ); | 
 |       return setChangeHashtagStub.lastCall.returnValue.then(() => { | 
 |         assert.equal(element.change!.hashtags, newHashtag); | 
 |       }); | 
 |     }); | 
 |   }); | 
 |  | 
 |   test('editTopic', async () => { | 
 |     element.account = createAccountDetailWithId(); | 
 |     element.change = { | 
 |       ...createParsedChange(), | 
 |       actions: {topic: {enabled: true}}, | 
 |     }; | 
 |     await flush(); | 
 |  | 
 |     const label = element.shadowRoot!.querySelector( | 
 |       '.topicEditableLabel' | 
 |     ) as GrEditableLabel; | 
 |     assert.ok(label); | 
 |     const openStub = sinon.stub(label, 'open'); | 
 |     element.editTopic(); | 
 |     await flush(); | 
 |  | 
 |     assert.isTrue(openStub.called); | 
 |   }); | 
 |  | 
 |   suite('plugin endpoints', () => { | 
 |     test('endpoint params', async () => { | 
 |       element.change = createParsedChange(); | 
 |       element.revision = createRevision(); | 
 |       interface MetadataGrEndpointDecorator extends GrEndpointDecorator { | 
 |         plugin: PluginApi; | 
 |         change: ParsedChangeInfo; | 
 |         revision: RevisionInfo; | 
 |       } | 
 |       let hookEl: MetadataGrEndpointDecorator; | 
 |       let plugin: PluginApi; | 
 |       pluginApi.install( | 
 |         p => { | 
 |           plugin = p; | 
 |           plugin | 
 |             .hook('change-metadata-item') | 
 |             .getLastAttached() | 
 |             .then(el => (hookEl = el as MetadataGrEndpointDecorator)); | 
 |         }, | 
 |         '0.1', | 
 |         'http://some/plugins/url.js' | 
 |       ); | 
 |       getPluginLoader().loadPlugins([]); | 
 |       await flush(); | 
 |       assert.strictEqual(hookEl!.plugin, plugin!); | 
 |       assert.strictEqual(hookEl!.change, element.change); | 
 |       assert.strictEqual(hookEl!.revision, element.revision); | 
 |     }); | 
 |   }); | 
 | }); |