blob: b4ba4ee69019428486d15244b32286254504e314 [file]
// 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 {afterEach, 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('CommentManager', () => {
let manager: CommentManager.CommentManager.CommentManager;
beforeEach(() => {
manager = new CommentManager.CommentManager.CommentManager();
});
afterEach(() => {
manager.clear();
});
it('manages comment mode and dispatches COMMENT_MODE_CHANGED event', () => {
const modeChangedEvents: boolean[] = [];
manager.addEventListener(CommentManager.CommentManager.Events.COMMENT_MODE_CHANGED, event => {
modeChangedEvents.push(event.data);
});
assert.isFalse(manager.isCommentMode());
// Cannot enable comment mode when agent is not attached
manager.setCommentMode(true);
assert.isFalse(manager.isCommentMode());
assert.deepEqual(modeChangedEvents, []);
manager.setAgentAttached(true);
manager.setCommentMode(true);
assert.isTrue(manager.isCommentMode());
assert.deepEqual(modeChangedEvents, [true]);
// Setting same mode should not dispatch again
manager.setCommentMode(true);
assert.deepEqual(modeChangedEvents, [true]);
manager.setCommentMode(false);
assert.isFalse(manager.isCommentMode());
assert.deepEqual(modeChangedEvents, [true, false]);
});
it('manages agent attached state and dispatches AGENT_ATTACHED_CHANGED event', () => {
const attachedChangedEvents: boolean[] = [];
manager.addEventListener(CommentManager.CommentManager.Events.AGENT_ATTACHED_CHANGED, event => {
attachedChangedEvents.push(event.data);
});
assert.isFalse(manager.isAgentAttached());
manager.setAgentAttached(true);
assert.isTrue(manager.isAgentAttached());
assert.deepEqual(attachedChangedEvents, [true]);
// Setting same value should not dispatch again
manager.setAgentAttached(true);
assert.deepEqual(attachedChangedEvents, [true]);
manager.setCommentMode(true);
assert.isTrue(manager.isCommentMode());
manager.setAgentAttached(false);
assert.isFalse(manager.isAgentAttached());
assert.isFalse(manager.isCommentMode());
assert.deepEqual(attachedChangedEvents, [true, false]);
});
it('creates and retrieves comment threads and dispatches COMMENT_THREADS_CHANGED event', () => {
const threadChangedEvents: CommentManager.CommentManager.CommentThread[][] = [];
manager.addEventListener(CommentManager.CommentManager.Events.COMMENT_THREADS_CHANGED, event => {
threadChangedEvents.push(event.data);
});
const anchor: CommentManager.CommentManager.CommentAnchorSignature = {
vePath: 'Panel: elements > TreeItem: rule',
textSignature: 'color: red;',
};
const thread = manager.createCommentThread(anchor, 'Initial comment');
assert.isNotNull(thread);
assert.strictEqual(thread.anchor, anchor);
assert.lengthOf(thread.comments, 1);
assert.strictEqual(thread.comments[0].author, 'DEVELOPER');
assert.strictEqual(thread.comments[0].text, 'Initial comment');
assert.strictEqual(thread.status, 'DRAFT');
assert.lengthOf(manager.getCommentThreads(), 1);
assert.strictEqual(manager.getCommentThread(thread.id), thread);
assert.lengthOf(threadChangedEvents, 1);
assert.strictEqual(threadChangedEvents[0][0], thread);
thread.save();
assert.strictEqual(thread.status, 'ACTIVE');
assert.lengthOf(threadChangedEvents, 2);
});
it('creates and retrieves comment threads with TimelineAnchorSignature', () => {
const anchor: CommentManager.CommentManager.CommentAnchorSignature = {
vePath: 'Panel: timeline > FlameChart: main',
textSignature: 'Compile Script',
timeline: {
traceId: 'trace-1',
traceEventKey: 'r-42',
entryName: 'Compile Script',
startTimeMicro: 1000,
durationMicro: 500,
chartLocation: 'main',
},
};
const thread = manager.createCommentThread(anchor, 'Flamechart comment');
assert.isNotNull(thread);
assert.deepEqual(thread.anchor.timeline, {
traceId: 'trace-1',
traceEventKey: 'r-42',
entryName: 'Compile Script',
startTimeMicro: 1000,
durationMicro: 500,
chartLocation: 'main',
});
assert.strictEqual(thread.comments[0].text, 'Flamechart comment');
});
it('supports isGeneratedComment metadata in created threads', () => {
const anchor: CommentManager.CommentManager.CommentAnchorSignature = {
vePath: 'Panel: elements > TreeItem: rule',
textSignature: 'margin: 0;',
};
const thread =
manager.createCommentThread(anchor, 'Changed property "margin" from "0" to "8px"', 'DEVELOPER', true);
assert.strictEqual(thread.comments[0].author, 'DEVELOPER');
assert.strictEqual(thread.comments[0].text, 'Changed property "margin" from "0" to "8px"');
assert.isTrue(thread.isGeneratedComment);
});
it('resolves comment threads with optional reply text', () => {
const anchor: CommentManager.CommentManager.CommentAnchorSignature = {
vePath: 'Panel: elements > TreeItem: rule',
textSignature: 'color: red;',
};
const thread = manager.createCommentThread(anchor, 'Initial comment');
assert.strictEqual(thread.status, 'DRAFT');
assert.isFalse(thread.isGeneratedComment);
const success = manager.resolveCommentThread(thread.id, 'Done');
assert.isTrue(success);
assert.strictEqual(thread.status, 'RESOLVED');
assert.lengthOf(thread.comments, 2);
assert.strictEqual(thread.comments[1].author, 'AGENT');
assert.strictEqual(thread.comments[1].text, 'Done');
});
it('creates comment thread without initial text leaving comments array empty', () => {
const anchor: CommentManager.CommentManager.CommentAnchorSignature = {
vePath: 'Panel: elements > TreeOutline > TreeItem',
textSignature: 'div.header',
};
const thread = manager.createCommentThread(anchor, undefined, undefined, true);
assert.isNotNull(thread);
assert.isEmpty(thread.comments);
assert.isTrue(thread.isGeneratedComment);
assert.strictEqual(thread.status, 'DRAFT');
});
it('creates comment thread with empty string text', () => {
const anchor: CommentManager.CommentManager.CommentAnchorSignature = {
vePath: 'Panel: elements > TreeOutline > TreeItem',
textSignature: 'div.header',
};
const thread = manager.createCommentThread(anchor, '');
assert.isNotNull(thread);
assert.lengthOf(thread.comments, 1);
assert.strictEqual(thread.comments[0].text, '');
});
it('returns undefined for non-existent comment thread ID', () => {
assert.isUndefined(manager.getCommentThread('non-existent-id'));
});
it('removes comment threads and dispatches COMMENT_THREADS_CHANGED event', () => {
const anchor: CommentManager.CommentManager.CommentAnchorSignature = {
vePath: 'Panel: network > TableRow: item',
textSignature: 'GET /api',
};
const thread = manager.createCommentThread(anchor, 'Network comment');
assert.lengthOf(manager.getCommentThreads(), 1);
let threadsAfterRemoval: CommentManager.CommentManager.CommentThread[] = [];
manager.addEventListener(CommentManager.CommentManager.Events.COMMENT_THREADS_CHANGED, event => {
threadsAfterRemoval = event.data;
});
manager.removeCommentThread(thread.id);
assert.lengthOf(manager.getCommentThreads(), 0);
assert.deepEqual(threadsAfterRemoval, []);
});
it('does nothing when removing non-existent thread', () => {
let eventCount = 0;
manager.addEventListener(CommentManager.CommentManager.Events.COMMENT_THREADS_CHANGED, () => {
eventCount++;
});
manager.removeCommentThread('non-existent-id');
assert.strictEqual(eventCount, 0);
});
it('clears all threads and resets comment mode on clear()', () => {
manager.setAgentAttached(true);
manager.setCommentMode(true);
const anchor: CommentManager.CommentManager.CommentAnchorSignature = {
vePath: 'Panel: sources > TreeItem: file',
textSignature: 'app.js',
};
manager.createCommentThread(anchor, 'To be cleared');
assert.lengthOf(manager.getCommentThreads(), 1);
assert.isTrue(manager.isCommentMode());
manager.clear();
assert.lengthOf(manager.getCommentThreads(), 0);
assert.isFalse(manager.isCommentMode());
});
it('returns previously unsent comments and marks them as sent in takeComments()', () => {
const anchor: CommentManager.CommentManager.CommentAnchorSignature = {
vePath: 'Panel: elements > TreeItem: rule',
textSignature: 'color: red;',
};
const thread1 = manager.createCommentThread(anchor, 'Initial developer comment', 'DEVELOPER');
assert.isFalse(thread1.transmitted);
assert.strictEqual(thread1.status, 'DRAFT');
// Returns empty array when thread is in DRAFT state
assert.isEmpty(manager.takeComments());
assert.isFalse(thread1.transmitted);
// Returns empty array when thread is in ACTIVE (saved, unsent) state
thread1.save();
assert.strictEqual(thread1.status, 'ACTIVE');
assert.isEmpty(manager.takeComments());
assert.isFalse(thread1.transmitted);
thread1.sendToAgent();
assert.strictEqual(thread1.status, 'SENT_TO_AGENT');
const taken = manager.takeComments();
assert.lengthOf(taken, 1);
assert.strictEqual(taken[0].id, thread1.id);
assert.isTrue(taken[0].transmitted);
assert.isTrue(thread1.transmitted);
assert.lengthOf(taken[0].comments, 1);
assert.strictEqual(taken[0].comments[0].text, 'Initial developer comment');
// Calling takeComments again returns empty array as it was marked as sent
const takenAgain = manager.takeComments();
assert.lengthOf(takenAgain, 0);
// Creating another thread makes it available in takeComments after sendToAgent()
const thread2 = manager.createCommentThread(anchor, 'Second thread', 'DEVELOPER');
assert.isFalse(thread2.transmitted);
assert.isEmpty(manager.takeComments());
thread2.sendToAgent();
const takenNew = manager.takeComments();
assert.lengthOf(takenNew, 1);
assert.strictEqual(takenNew[0].id, thread2.id);
assert.isTrue(thread2.transmitted);
assert.strictEqual(takenNew[0].comments[0].text, 'Second thread');
// Resolving thread with agent reply
manager.resolveCommentThread(thread2.id, 'Agent reply');
assert.lengthOf(manager.takeComments(), 0);
});
it('dispatches COMMENT_THREADS_CHANGED when takeComments() marks threads as transmitted', () => {
const anchor: CommentManager.CommentManager.CommentAnchorSignature = {
vePath: 'Panel: elements > TreeItem: rule',
textSignature: 'color: red;',
};
const thread = manager.createCommentThread(anchor, 'Comment', 'DEVELOPER');
thread.sendToAgent();
const changedSpy = sinon.spy();
manager.addEventListener(CommentManager.CommentManager.Events.COMMENT_THREADS_CHANGED, changedSpy);
manager.takeComments();
sinon.assert.calledOnce(changedSpy);
// Nothing new to take, so no further change is dispatched.
manager.takeComments();
sinon.assert.calledOnce(changedSpy);
});
it('supports timeline anchor signatures with traceId', () => {
const anchor: CommentManager.CommentManager.CommentAnchorSignature = {
vePath: 'Panel: timeline > FlameChart: main',
textSignature: 'Task',
timeline: {
traceId: 'trace-12345',
traceEventKey: 'r-0',
entryName: 'Task',
startTimeMicro: 1000000,
chartLocation: 'main',
},
};
const thread = manager.createCommentThread(anchor, 'Timeline comment');
assert.strictEqual(thread.anchor.timeline?.traceId, 'trace-12345');
assert.strictEqual(thread.anchor.timeline?.traceEventKey, 'r-0');
});
});
describe('CommentManager UMA', () => {
const anchor: CommentManager.CommentManager.CommentAnchorSignature = {
vePath: 'Panel: elements > TreeItem: rule',
};
let manager: CommentManager.CommentManager.CommentManager;
let commentSharedWithAgent: sinon.SinonStub;
let changeSharedWithAgent: sinon.SinonStub;
let commentResolved: sinon.SinonStub;
beforeEach(() => {
manager = new CommentManager.CommentManager.CommentManager();
commentSharedWithAgent = sinon.stub(Host.userMetrics, 'commentSharedWithAgent');
changeSharedWithAgent = sinon.stub(Host.userMetrics, 'changeSharedWithAgent');
commentResolved = sinon.stub(Host.userMetrics, 'commentResolved');
});
afterEach(() => {
manager.clear();
});
it('records shared comment and change counts when threads are taken by the agent', () => {
manager.createCommentThread(anchor).sendToAgent('Comment 1');
manager.createCommentThread(anchor).sendToAgent('Comment 2');
manager.createCommentThread(anchor, 'Change 1', 'DEVELOPER', true).sendToAgent();
manager.createCommentThread(anchor).save('Queued, not sent');
assert.lengthOf(manager.takeComments(), 3);
sinon.assert.calledOnceWithExactly(commentSharedWithAgent, 2);
sinon.assert.calledOnceWithExactly(changeSharedWithAgent, 1);
});
it('does not record shared counts when nothing is taken', () => {
manager.createCommentThread(anchor).sendToAgent('Comment');
manager.takeComments();
commentSharedWithAgent.resetHistory();
assert.isEmpty(manager.takeComments());
sinon.assert.notCalled(commentSharedWithAgent);
sinon.assert.notCalled(changeSharedWithAgent);
});
it('records CommentResolvedByAgent once per thread', () => {
const thread = manager.createCommentThread(anchor);
thread.sendToAgent('Comment');
assert.isTrue(manager.resolveCommentThread(thread.id, 'Done'));
assert.isTrue(manager.resolveCommentThread(thread.id, 'Done again'));
assert.isFalse(manager.resolveCommentThread('unknown-id'));
sinon.assert.calledOnceWithExactly(commentResolved, 'Agent', 1);
});
it('records CommentResolvedByAgent for generated change threads', () => {
const thread = manager.createCommentThread(anchor, 'Change 1', 'DEVELOPER', true);
thread.sendToAgent();
assert.isTrue(manager.resolveCommentThread(thread.id, 'Done'));
sinon.assert.calledOnceWithExactly(commentResolved, 'Agent', 1);
});
});