| // Copyright 2026 The Chromium Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import {assert} from 'chai'; | |
| import {beforeEach, describe, it} from 'mocha'; | |
| import sinon from 'sinon'; | |
| import * as Host from '../../core/host/host.js'; | |
| import * as CommentManager from './comment_manager.js'; | |
| describe('CommentThread', () => { | |
| const defaultAnchor: CommentManager.CommentThread.CommentAnchorSignature = { | |
| vePath: 'Panel: elements > TreeItem: rule', | |
| textSignature: 'color: red;', | |
| }; | |
| it('generates a non-empty unique id', () => { | |
| const thread1 = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| const thread2 = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| assert.isNotEmpty(thread1.id); | |
| assert.isNotEmpty(thread2.id); | |
| assert.notStrictEqual(thread1.id, thread2.id); | |
| }); | |
| it('defaults status to DRAFT and transmitted to false', () => { | |
| const thread = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| assert.strictEqual(thread.status, 'DRAFT'); | |
| assert.isFalse(thread.transmitted); | |
| assert.isEmpty(thread.comments); | |
| }); | |
| it('markTransmitted() sets transmitted and dispatches CHANGED only once', () => { | |
| const thread = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| const changedSpy = sinon.spy(); | |
| thread.addEventListener(CommentManager.CommentThread.Events.CHANGED, changedSpy); | |
| thread.markTransmitted(); | |
| assert.isTrue(thread.transmitted); | |
| sinon.assert.calledOnce(changedSpy); | |
| thread.markTransmitted(); | |
| assert.isTrue(thread.transmitted); | |
| sinon.assert.calledOnce(changedSpy); | |
| }); | |
| it('save() transitions status from DRAFT to ACTIVE and dispatches CHANGED event', () => { | |
| let changeCount = 0; | |
| const thread = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| thread.addEventListener(CommentManager.CommentThread.Events.CHANGED, () => { | |
| changeCount++; | |
| }); | |
| assert.strictEqual(thread.status, 'DRAFT'); | |
| thread.save(); | |
| assert.strictEqual(thread.status, 'ACTIVE'); | |
| assert.strictEqual(changeCount, 1); | |
| // Calling save() when already ACTIVE does not change status or dispatch CHANGED | |
| thread.save(); | |
| assert.strictEqual(thread.status, 'ACTIVE'); | |
| assert.strictEqual(changeCount, 1); | |
| // Calling save() when RESOLVED does not change status or dispatch CHANGED | |
| thread.resolve(); | |
| assert.strictEqual(thread.status, 'RESOLVED'); | |
| assert.strictEqual(changeCount, 2); | |
| thread.save(); | |
| assert.strictEqual(thread.status, 'RESOLVED'); | |
| assert.strictEqual(changeCount, 2); | |
| }); | |
| it('resolve() sets status to RESOLVED, appends trimmed AGENT comment if replyText is non-empty, and dispatches CHANGED event', | |
| () => { | |
| let changeCount = 0; | |
| const thread = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| thread.addEventListener(CommentManager.CommentThread.Events.CHANGED, () => { | |
| changeCount++; | |
| }); | |
| thread.resolve(' Fixed issue '); | |
| assert.strictEqual(thread.status, 'RESOLVED'); | |
| assert.strictEqual(changeCount, 1); | |
| assert.lengthOf(thread.comments, 1); | |
| assert.strictEqual(thread.comments[0].author, 'AGENT'); | |
| assert.strictEqual(thread.comments[0].text, 'Fixed issue'); | |
| // Resolving with empty/whitespace string does not append comment | |
| const thread2 = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| thread2.addEventListener(CommentManager.CommentThread.Events.CHANGED, () => { | |
| changeCount++; | |
| }); | |
| thread2.resolve(' '); | |
| assert.strictEqual(thread2.status, 'RESOLVED'); | |
| assert.strictEqual(changeCount, 2); | |
| assert.isEmpty(thread2.comments); | |
| }); | |
| it('markResponseSeen() marks the response of a RESOLVED thread as seen and dispatches CHANGED once', () => { | |
| let changeCount = 0; | |
| const thread = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| thread.addEventListener(CommentManager.CommentThread.Events.CHANGED, () => { | |
| changeCount++; | |
| }); | |
| assert.isFalse(thread.responseSeen); | |
| // No-op before the thread is resolved. | |
| thread.sendToAgent('Fix this'); | |
| changeCount = 0; | |
| thread.markResponseSeen(); | |
| assert.isFalse(thread.responseSeen); | |
| assert.strictEqual(changeCount, 0); | |
| thread.resolve('Fixed'); | |
| assert.isFalse(thread.responseSeen); | |
| changeCount = 0; | |
| thread.markResponseSeen(); | |
| assert.isTrue(thread.responseSeen); | |
| assert.strictEqual(changeCount, 1); | |
| // Marking again does not dispatch CHANGED. | |
| thread.markResponseSeen(); | |
| assert.strictEqual(changeCount, 1); | |
| }); | |
| it('resolve() marks the response as unseen again only when there is a new reply', () => { | |
| const thread = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| thread.sendToAgent('Fix this'); | |
| thread.resolve('Fixed'); | |
| thread.markResponseSeen(); | |
| thread.resolve(); | |
| assert.isTrue(thread.responseSeen); | |
| thread.resolve('Fixed again'); | |
| assert.isFalse(thread.responseSeen); | |
| }); | |
| it('save(text) appends comment, transitions DRAFT to ACTIVE, and dispatches CHANGED event', () => { | |
| let changeCount = 0; | |
| const thread = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| thread.addEventListener(CommentManager.CommentThread.Events.CHANGED, () => { | |
| changeCount++; | |
| }); | |
| assert.strictEqual(thread.status, 'DRAFT'); | |
| thread.save('First comment'); | |
| assert.strictEqual(thread.status, 'ACTIVE'); | |
| assert.strictEqual(changeCount, 1); | |
| assert.lengthOf(thread.comments, 1); | |
| assert.strictEqual(thread.comments[0].text, 'First comment'); | |
| assert.strictEqual(thread.comments[0].author, 'DEVELOPER'); | |
| }); | |
| it('sendToAgent() transitions DRAFT or ACTIVE to SENT_TO_AGENT, assigns index, appends optional text, and dispatches CHANGED', | |
| () => { | |
| let changeCount = 0; | |
| const thread1 = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| thread1.addEventListener(CommentManager.CommentThread.Events.CHANGED, () => { | |
| changeCount++; | |
| }); | |
| assert.strictEqual(thread1.status, 'DRAFT'); | |
| thread1.sendToAgent('Send from draft'); | |
| assert.strictEqual(thread1.status, 'SENT_TO_AGENT'); | |
| assert.strictEqual(changeCount, 1); | |
| assert.lengthOf(thread1.comments, 1); | |
| assert.strictEqual(thread1.comments[0].text, 'Send from draft'); | |
| // Calling sendToAgent() again without text does not dispatch CHANGED | |
| thread1.sendToAgent(); | |
| assert.strictEqual(thread1.status, 'SENT_TO_AGENT'); | |
| assert.strictEqual(changeCount, 1); | |
| // Transition from ACTIVE (saved) to SENT_TO_AGENT | |
| const thread2 = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| thread2.save(); | |
| assert.strictEqual(thread2.status, 'ACTIVE'); | |
| thread2.sendToAgent(); | |
| assert.strictEqual(thread2.status, 'SENT_TO_AGENT'); | |
| }); | |
| it('editComment() replaces the developer comment text of an ACTIVE thread and dispatches CHANGED', () => { | |
| const thread = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| thread.save('Original'); | |
| let changeCount = 0; | |
| thread.addEventListener(CommentManager.CommentThread.Events.CHANGED, () => { | |
| changeCount++; | |
| }); | |
| thread.editComment(' Edited '); | |
| assert.strictEqual(thread.status, 'ACTIVE'); | |
| assert.lengthOf(thread.comments, 1); | |
| assert.strictEqual(thread.comments[0].author, 'DEVELOPER'); | |
| assert.strictEqual(thread.comments[0].text, 'Edited'); | |
| assert.strictEqual(changeCount, 1); | |
| }); | |
| it('editComment() is a no-op for a SENT_TO_AGENT thread', () => { | |
| const thread = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| thread.sendToAgent('Sent'); | |
| let changeCount = 0; | |
| thread.addEventListener(CommentManager.CommentThread.Events.CHANGED, () => { | |
| changeCount++; | |
| }); | |
| thread.editComment('Edited'); | |
| assert.strictEqual(thread.comments[0].text, 'Sent'); | |
| assert.strictEqual(changeCount, 0); | |
| }); | |
| it('editComment() does not dispatch CHANGED when the text is unchanged', () => { | |
| const thread = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| thread.save('Original'); | |
| let changeCount = 0; | |
| thread.addEventListener(CommentManager.CommentThread.Events.CHANGED, () => { | |
| changeCount++; | |
| }); | |
| thread.editComment('Original'); | |
| thread.editComment(' Original '); | |
| assert.strictEqual(thread.comments[0].text, 'Original'); | |
| assert.strictEqual(changeCount, 0); | |
| }); | |
| it('editComment() ignores empty or whitespace-only text', () => { | |
| const thread = new CommentManager.CommentThread.CommentThread({ | |
| anchor: defaultAnchor, | |
| }); | |
| thread.save('Original'); | |
| let changeCount = 0; | |
| thread.addEventListener(CommentManager.CommentThread.Events.CHANGED, () => { | |
| changeCount++; | |
| }); | |
| thread.editComment(''); | |
| thread.editComment(' '); | |
| assert.strictEqual(thread.status, 'ACTIVE'); | |
| assert.lengthOf(thread.comments, 1); | |
| assert.strictEqual(thread.comments[0].text, 'Original'); | |
| assert.strictEqual(changeCount, 0); | |
| }); | |
| }); | |
| describe('CommentThread UMA', () => { | |
| const anchor: CommentManager.CommentThread.CommentAnchorSignature = { | |
| vePath: 'Panel: elements > TreeItem: rule', | |
| }; | |
| let commentCreated: sinon.SinonStub; | |
| let commentSize: sinon.SinonStub; | |
| beforeEach(() => { | |
| commentCreated = sinon.stub(Host.userMetrics, 'commentCreated'); | |
| commentSize = sinon.stub(Host.userMetrics, 'commentSize'); | |
| }); | |
| it('records CommentCreated and CommentSize once when a draft is saved', () => { | |
| const thread = new CommentManager.CommentThread.CommentThread({anchor}); | |
| thread.save(' Make this blue '); | |
| thread.save('Follow-up'); | |
| thread.sendToAgent(); | |
| sinon.assert.calledOnceWithExactly(commentCreated, 1); | |
| sinon.assert.calledOnceWithExactly(commentSize, 'Make this blue'.length); | |
| }); | |
| it('records CommentCreated and CommentSize when a draft is sent to the agent directly', () => { | |
| const thread = new CommentManager.CommentThread.CommentThread({anchor}); | |
| thread.sendToAgent('Fix padding'); | |
| sinon.assert.calledOnceWithExactly(commentCreated, 1); | |
| sinon.assert.calledOnceWithExactly(commentSize, 'Fix padding'.length); | |
| }); | |
| it('does not record CommentCreated for generated comments', () => { | |
| const thread = new CommentManager.CommentThread.CommentThread({ | |
| anchor, | |
| comments: [{author: 'DEVELOPER', text: 'Changed color', timestamp: 0}], | |
| isGeneratedComment: true, | |
| }); | |
| thread.save(); | |
| sinon.assert.notCalled(commentCreated); | |
| sinon.assert.notCalled(commentSize); | |
| }); | |
| it('does not record CommentCreated or CommentSize for drafts without developer text', () => { | |
| const whitespaceThread = new CommentManager.CommentThread.CommentThread({ | |
| anchor, | |
| comments: [{author: 'DEVELOPER', text: ' ', timestamp: 0}], | |
| }); | |
| whitespaceThread.save(); | |
| const emptyThread = new CommentManager.CommentThread.CommentThread({anchor}); | |
| emptyThread.save(); | |
| sinon.assert.notCalled(commentCreated); | |
| sinon.assert.notCalled(commentSize); | |
| }); | |
| it('does not record CommentCreated for drafts that are never saved', () => { | |
| const thread = new CommentManager.CommentThread.CommentThread({anchor}); | |
| thread.editComment('ignored'); | |
| sinon.assert.notCalled(commentCreated); | |
| }); | |
| describe('CommentThread CommentQueued UMA', () => { | |
| const anchor: CommentManager.CommentThread.CommentAnchorSignature = { | |
| vePath: 'Panel: elements > TreeItem: rule', | |
| }; | |
| let commentQueued: sinon.SinonStub; | |
| beforeEach(() => { | |
| commentQueued = sinon.stub(Host.userMetrics, 'commentQueued'); | |
| }); | |
| it('records once when a draft is queued', () => { | |
| const thread = new CommentManager.CommentThread.CommentThread({anchor}); | |
| thread.save('Make this blue'); | |
| thread.save('Follow-up'); | |
| thread.sendToAgent(); | |
| sinon.assert.calledOnceWithExactly(commentQueued, 1); | |
| }); | |
| it('does not record when a draft is sent to the agent directly', () => { | |
| const thread = new CommentManager.CommentThread.CommentThread({anchor}); | |
| thread.sendToAgent('Fix padding'); | |
| sinon.assert.notCalled(commentQueued); | |
| }); | |
| it('does not record for generated comments', () => { | |
| const thread = new CommentManager.CommentThread.CommentThread({ | |
| anchor, | |
| comments: [{author: 'DEVELOPER', text: 'Changed color', timestamp: 0}], | |
| isGeneratedComment: true, | |
| }); | |
| thread.save(); | |
| sinon.assert.notCalled(commentQueued); | |
| }); | |
| it('does not record for empty text', () => { | |
| const thread = new CommentManager.CommentThread.CommentThread({anchor}); | |
| thread.save(' '); | |
| sinon.assert.notCalled(commentQueued); | |
| }); | |
| }); | |
| }); |