blob: 6c2cd274a5584e26c2738a7bf11d0c4746b9cb1b [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 {describe, it} from 'mocha';
import sinon from 'sinon';
import * as Common from '../../core/common/common.js';
import * as CommentManager from '../../models/comment_manager/comment_manager.js';
import {assertScreenshot, renderElementIntoDOM} from '../../testing/DOMHelpers.js';
import {describeWithEnvironment} from '../../testing/EnvironmentHelpers.js';
import {createViewFunctionStub} from '../../testing/ViewFunctionHelpers.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as Comments from './comments.js';
const {CommentsPane} = Comments;
const {DEFAULT_VIEW} = CommentsPane;
describeWithEnvironment('CommentsPane', () => {
/** Stubs for the functions the view hands to the widget. */
function createViewOutput() {
return {
scrollToSection: sinon.stub<[Comments.CommentsPane.Tab, {animate: boolean}], boolean>().returns(false),
sectionAtTop: sinon.stub<[], Comments.CommentsPane.Tab>().returns('queued'),
};
}
async function createWidget(
commentManager = new CommentManager.CommentManager.CommentManager(),
viewOutput = createViewOutput(),
) {
const view = createViewFunctionStub(CommentsPane.CommentsPane, viewOutput);
const widget = new CommentsPane.CommentsPane(undefined, [commentManager], view);
widget.wasShown();
await view.nextInput;
return {view, widget, commentManager, viewOutput};
}
function seedThreads(commentManager: CommentManager.CommentManager.CommentManager) {
const t1 = commentManager.createCommentThread(
{
vePath: 'Panel: elements > Pane: styles > a.o-text-cta--small',
textSignature: 'a.o-text-cta--small',
},
'Left align this button with H2 and use the primary pink colour for the button.',
);
t1.save();
const t2 = commentManager.createCommentThread(
{
vePath: 'Panel: elements > Pane: styles > img.wp-image-1448',
textSignature: 'img.wp-image-1448',
},
'Add an animation to the hero image.',
);
t2.save();
const t3 = commentManager.createCommentThread(
{
vePath: 'Panel: elements > Pane: styles > div.c-scrolling-list_ft...',
textSignature: 'div.c-scrolling-list_ft...',
},
'Remove the animation from H1.',
);
t3.save();
return [t1, t2, t3];
}
it('does not list draft threads', async () => {
const {view, commentManager} = await createWidget();
// A thread without saved text stays a draft.
const draft =
commentManager.createCommentThread({vePath: 'Panel: elements > div.draft', textSignature: 'div.draft'});
const [, sent, resolved] = seedThreads(commentManager);
sent.sendToAgent();
resolved.resolve('Done');
const updatedInput = await view.nextInput;
assert.strictEqual(draft.status, 'DRAFT');
const listedIds = [...updatedInput.queuedThreads, ...updatedInput.sentThreads].map(item => item.thread.id);
assert.notInclude(listedIds, draft.id);
});
it('deletes a comment thread', async () => {
const commentManager = new CommentManager.CommentManager.CommentManager();
const [t1] = seedThreads(commentManager);
const {view} = await createWidget(commentManager);
view.input.onDeleteThread(t1.id);
const updatedInput = await view.nextInput;
assert.lengthOf(updatedInput.queuedThreads, 2);
assert.isUndefined(commentManager.getCommentThread(t1.id));
});
it('clears all comments', async () => {
const commentManager = new CommentManager.CommentManager.CommentManager();
seedThreads(commentManager);
const {view} = await createWidget(commentManager);
view.input.onClearAll();
const updatedInput = await view.nextInput;
assert.lengthOf(updatedInput.queuedThreads, 0);
assert.lengthOf(commentManager.getCommentThreads(), 0);
});
it('reveals the comment thread when clicked', async () => {
const revealStub = sinon.stub(Common.Revealer.RevealerRegistry.instance(), 'reveal').resolves();
const commentManager = new CommentManager.CommentManager.CommentManager();
const [t1] = seedThreads(commentManager);
const {view} = await createWidget(commentManager);
view.input.onThreadClick(t1, {left: 16, top: 450});
sinon.assert.calledOnceWithExactly(
revealStub,
new CommentManager.CommentThread.RevealableThread(t1, {left: 16, top: 450}),
false,
);
});
it('does not reveal an unresolved generated comment thread when clicked', async () => {
const revealStub = sinon.stub(Common.Revealer.RevealerRegistry.instance(), 'reveal').resolves();
const commentManager = new CommentManager.CommentManager.CommentManager();
const generatedThread = commentManager.createCommentThread(
{
vePath: 'Panel: elements > Tree: elements > TreeItem',
textSignature: 'div.changed',
},
'Changed color from red to blue.',
'DEVELOPER',
/* isGeneratedComment */ true,
);
generatedThread.save();
const {view} = await createWidget(commentManager);
view.input.onThreadClick(generatedThread, {left: 16, top: 450});
sinon.assert.notCalled(revealStub);
});
it('reveals a resolved generated comment thread when clicked', async () => {
const revealStub = sinon.stub(Common.Revealer.RevealerRegistry.instance(), 'reveal').resolves();
const commentManager = new CommentManager.CommentManager.CommentManager();
const generatedThread = commentManager.createCommentThread(
{
vePath: 'Panel: elements > Tree: elements > TreeItem',
textSignature: 'div.changed',
},
'Changed color from red to blue.',
'DEVELOPER',
/* isGeneratedComment */ true,
);
generatedThread.save();
generatedThread.resolve('Done');
const {view} = await createWidget(commentManager);
view.input.onThreadClick(generatedThread, {left: 16, top: 450});
sinon.assert.calledOnceWithExactly(
revealStub,
new CommentManager.CommentThread.RevealableThread(generatedThread, {left: 16, top: 450}),
false,
);
});
describe('tabs', () => {
it('selects the clicked tab and scrolls to its section', async () => {
const commentManager = new CommentManager.CommentManager.CommentManager();
const [, t2] = seedThreads(commentManager);
t2.sendToAgent();
const {view, viewOutput} = await createWidget(commentManager);
assert.strictEqual(view.input.activeTab, 'queued');
view.input.onTabClick('sent');
assert.strictEqual((await view.nextInput).activeTab, 'sent');
sinon.assert.calledWith(viewOutput.scrollToSection, 'sent', {animate: true});
view.input.onTabClick('queued');
assert.strictEqual((await view.nextInput).activeTab, 'queued');
sinon.assert.calledWith(viewOutput.scrollToSection, 'queued', {animate: true});
});
it('highlights the section that has reached the top while scrolling', async () => {
const commentManager = new CommentManager.CommentManager.CommentManager();
const [, t2] = seedThreads(commentManager);
t2.sendToAgent();
const {view, viewOutput} = await createWidget(commentManager);
viewOutput.sectionAtTop.returns('sent');
view.input.onScroll();
assert.strictEqual((await view.nextInput).activeTab, 'sent');
viewOutput.sectionAtTop.returns('queued');
view.input.onScroll();
assert.strictEqual((await view.nextInput).activeTab, 'queued');
});
it('keeps the clicked tab highlighted until its smooth scroll ends', async () => {
const commentManager = new CommentManager.CommentManager.CommentManager();
const [, t2] = seedThreads(commentManager);
t2.sendToAgent();
const {view, widget, viewOutput} = await createWidget(commentManager);
viewOutput.scrollToSection.returns(true);
view.input.onTabClick('sent');
assert.strictEqual((await view.nextInput).activeTab, 'sent');
// On the way down, the queued section is still at the top.
view.input.onScroll();
await widget.updateComplete;
assert.strictEqual(view.input.activeTab, 'sent');
// The user stopped the scroll before the sent section reached the top.
view.input.onScrollEnd();
assert.strictEqual((await view.nextInput).activeTab, 'queued');
});
it('keeps Queued highlighted when there are no sent comments', async () => {
const commentManager = new CommentManager.CommentManager.CommentManager();
seedThreads(commentManager);
const {view} = await createWidget(commentManager);
view.input.onTabClick('sent');
assert.strictEqual((await view.nextInput).activeTab, 'queued');
});
it('goes back to Queued when the sent comments are cleared', async () => {
const commentManager = new CommentManager.CommentManager.CommentManager();
commentManager.createCommentThread({vePath: 'Panel: elements > div.sent'}, 'Sent').save();
commentManager.sendQueuedThreads();
const {view} = await createWidget(commentManager);
assert.strictEqual(view.input.activeTab, 'sent');
view.input.onClearAll();
assert.strictEqual((await view.nextInput).activeTab, 'queued');
});
});
describe('showing the sent comments when nothing is queued', () => {
it('opens on Sent with the sent comments at the top', async () => {
const commentManager = new CommentManager.CommentManager.CommentManager();
commentManager.createCommentThread({vePath: 'Panel: elements > div.sent'}, 'Sent').save();
commentManager.sendQueuedThreads();
const {view, viewOutput} = await createWidget(commentManager);
assert.strictEqual(view.input.activeTab, 'sent');
sinon.assert.calledOnceWithExactly(viewOutput.scrollToSection, 'sent', {animate: false});
});
it('switches to Sent when the queued comments are sent while the drawer is open', async () => {
const commentManager = new CommentManager.CommentManager.CommentManager();
const threads = seedThreads(commentManager);
const {view, viewOutput} = await createWidget(commentManager);
assert.strictEqual(view.input.activeTab, 'queued');
sinon.assert.notCalled(viewOutput.scrollToSection);
view.input.onSendToAgent();
const updatedInput = await view.nextInput;
assert.isTrue(threads.every(thread => thread.status === 'SENT_TO_AGENT'));
assert.lengthOf(updatedInput.queuedThreads, 0);
assert.lengthOf(updatedInput.sentThreads, 3);
assert.strictEqual(updatedInput.activeTab, 'sent');
sinon.assert.calledOnceWithExactly(viewOutput.scrollToSection, 'sent', {animate: false});
});
it('does not move the list again on later updates', async () => {
const commentManager = new CommentManager.CommentManager.CommentManager();
const thread = commentManager.createCommentThread({vePath: 'Panel: elements > div.sent'}, 'Sent');
thread.save();
thread.sendToAgent();
const {view, viewOutput} = await createWidget(commentManager);
view.input.onTabClick('queued');
await view.nextInput;
viewOutput.scrollToSection.resetHistory();
thread.resolve('Done');
const updatedInput = await view.nextInput;
assert.strictEqual(updatedInput.activeTab, 'queued');
sinon.assert.notCalled(viewOutput.scrollToSection);
});
});
it('keeps the list scroll position when the drawer is hidden and shown again', async () => {
const container = document.createElement('div');
renderElementIntoDOM(container);
const scrollContainer = document.createElement('div');
scrollContainer.style.height = '10px';
scrollContainer.style.overflow = 'auto';
const content = document.createElement('div');
content.style.height = '100px';
scrollContainer.append(content);
container.append(scrollContainer);
const viewOutput = {...createViewOutput(), scrollContainer};
const view = createViewFunctionStub(CommentsPane.CommentsPane, viewOutput);
const widget = new CommentsPane.CommentsPane(undefined, [new CommentManager.CommentManager.CommentManager()], view);
widget.markAsRoot();
widget.show(container);
try {
await view.nextInput;
scrollContainer.scrollTop = 42;
widget.hideWidget();
// The browser resets the scroll position while the drawer is hidden.
scrollContainer.scrollTop = 0;
widget.show(container);
assert.strictEqual(scrollContainer.scrollTop, 42);
} finally {
widget.detach();
}
});
describe('Add a comment', () => {
function stubToggleCommentModeAction() {
const execute = sinon.stub().resolves(true);
const registry = UI.ActionRegistry.ActionRegistry.instance();
sinon.stub(registry, 'hasAction').withArgs('comments.toggle-comment-mode').returns(true);
sinon.stub(registry, 'getAction')
.withArgs('comments.toggle-comment-mode')
.returns({execute} as unknown as UI.ActionRegistration.Action);
return execute;
}
it('turns on comment mode when it is off', async () => {
const execute = stubToggleCommentModeAction();
const {view} = await createWidget();
view.input.onAddComment();
sinon.assert.calledOnce(execute);
});
it('does not toggle comment mode off when it is already on', async () => {
const execute = stubToggleCommentModeAction();
const commentManager = new CommentManager.CommentManager.CommentManager();
commentManager.setAgentAttached(true);
commentManager.setCommentMode(true);
const {view} = await createWidget(commentManager);
view.input.onAddComment();
sinon.assert.notCalled(execute);
});
});
it('updates the sent comments when the agent takes them', async () => {
const commentManager = new CommentManager.CommentManager.CommentManager();
const [t1] = seedThreads(commentManager);
t1.sendToAgent();
const {view} = await createWidget(commentManager);
const sentIds = view.input.sentThreads.map(item => item.thread.id);
assert.include(sentIds, t1.id);
assert.isFalse(t1.transmitted);
commentManager.takeComments();
const takenInput = await view.nextInput;
assert.deepEqual(takenInput.sentThreads.map(item => item.thread.id), sentIds);
assert.isTrue(t1.transmitted);
});
});
describeWithEnvironment('CommentsPane DEFAULT_VIEW', () => {
function renderView(
inputOverrides: Partial<Parameters<typeof DEFAULT_VIEW>[0]> = {},
output: Comments.CommentsPane.ViewOutput = {},
): HTMLElement {
const target = document.createElement('div');
target.style.width = '360px';
target.style.height = '480px';
DEFAULT_VIEW(
{
activeTab: 'queued',
queuedThreads: [],
sentThreads: [],
onTabClick: () => {},
onScroll: () => {},
onScrollEnd: () => {},
onClearAll: () => {},
onThreadClick: () => {},
onDeleteThread: () => {},
onSendToAgent: () => {},
onAddComment: () => {},
...inputOverrides,
},
output,
target,
);
return target;
}
function createMockThread(selectorText: string, commentText: string): Comments.CommentsPane.ThreadViewData {
const thread = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: `Panel: elements > ${selectorText}`,
textSignature: selectorText,
},
comments: [{author: 'DEVELOPER', text: commentText, timestamp: 0}],
});
thread.status = 'ACTIVE';
return {thread, title: {text: selectorText}, commentText};
}
function createQueuedThreads(count: number): Comments.CommentsPane.ThreadViewData[] {
return Array.from({length: count}, (_, i) => createMockThread(`div.queued-${i}`, `Queued comment ${i}`));
}
/** One thread in each sent state: sent, taken by the agent ("Working") and resolved. */
function createSentThreads(): Comments.CommentsPane.ThreadViewData[] {
const sentThread = createMockThread(
'a.cta',
'Left align this button with H2 and use the primary pink colour for the button.',
);
sentThread.thread.sendToAgent();
const workingThread = createMockThread('img.hero', 'Add an animation to the hero image.');
workingThread.thread.sendToAgent();
workingThread.thread.markTransmitted();
const resolvedThread = createMockThread('div.list', 'Remove the animation from H1.');
resolvedThread.thread.resolve('Done');
return [sentThread, workingThread, resolvedThread];
}
async function assertViewScreenshot(target: HTMLElement, goldenPath: string): Promise<void> {
renderElementIntoDOM(target, {includeCommonStyles: true});
// Stop the "Working" spinners so the screenshots are stable.
for (const spinner of target.querySelectorAll('devtools-spinner')) {
spinner.active = false;
}
await assertScreenshot(goldenPath);
}
describe('screenshots', () => {
it('renders the empty state', async () => {
await assertViewScreenshot(renderView(), 'comments/comments_pane_empty.png');
});
it('renders queued comments with the Send to agent footer right after them', async () => {
await assertViewScreenshot(renderView({queuedThreads: createQueuedThreads(3)}),
'comments/comments_pane_list.png');
});
it('renders the "No queued comments" note above the sent comments', async () => {
await assertViewScreenshot(renderView({activeTab: 'sent', sentThreads: createSentThreads()}),
'comments/comments_pane_sent.png');
});
it('renders queued and sent comments separated by the "Sent comments" bar', async () => {
await assertViewScreenshot(renderView({queuedThreads: createQueuedThreads(2), sentThreads: createSentThreads()}),
'comments/comments_pane_queued_and_sent.png');
});
it('renders the Send to agent footer stuck to the bottom when the queued comments overflow', async () => {
await assertViewScreenshot(renderView({queuedThreads: createQueuedThreads(15), sentThreads: createSentThreads()}),
'comments/comments_pane_queued_overflow.png');
});
});
it('highlights the current section and forwards tab clicks and scroll events', () => {
const onTabClick = sinon.spy();
const onScroll = sinon.spy();
const onScrollEnd = sinon.spy();
const output: Comments.CommentsPane.ViewOutput = {};
const target = renderView({
activeTab: 'sent',
queuedThreads: createQueuedThreads(1),
sentThreads: createSentThreads(),
onTabClick,
onScroll,
onScrollEnd,
},
output);
renderElementIntoDOM(target);
const [queuedTab, sentTab] = target.querySelectorAll<HTMLElement>('.tab-button');
assert.isFalse(queuedTab.classList.contains('active'));
assert.strictEqual(queuedTab.getAttribute('aria-current'), 'false');
assert.isTrue(sentTab.classList.contains('active'));
assert.strictEqual(sentTab.getAttribute('aria-current'), 'location');
sentTab.click();
queuedTab.click();
output.scrollContainer?.dispatchEvent(new Event('scroll'));
output.scrollContainer?.dispatchEvent(new Event('scrollend'));
assert.deepEqual(onTabClick.args.map(args => args[0]), ['sent', 'queued']);
sinon.assert.calledOnce(onScroll);
sinon.assert.calledOnce(onScrollEnd);
});
it('scrolls to a section and reports the section at the top', () => {
const output: Comments.CommentsPane.ViewOutput = {};
const target = renderView({queuedThreads: createQueuedThreads(15), sentThreads: createSentThreads()}, output);
renderElementIntoDOM(target, {includeCommonStyles: true});
assert.strictEqual(output.sectionAtTop?.(), 'queued');
assert.isFalse(output.scrollToSection?.('sent', {animate: false}));
assert.strictEqual(output.sectionAtTop?.(), 'sent');
output.scrollToSection?.('queued', {animate: false});
assert.strictEqual(output.scrollContainer?.scrollTop, 0);
assert.strictEqual(output.sectionAtTop?.(), 'queued');
});
it('has no Sent section to scroll to without sent comments', () => {
const output: Comments.CommentsPane.ViewOutput = {};
const target = renderView({queuedThreads: createQueuedThreads(15)}, output);
renderElementIntoDOM(target, {includeCommonStyles: true});
assert.isFalse(output.scrollToSection?.('sent', {animate: true}));
assert.strictEqual(output.scrollContainer?.scrollTop, 0);
assert.strictEqual(output.sectionAtTop?.(), 'queued');
});
it('calls onAddComment when the Add a comment link is clicked', () => {
const onAddComment = sinon.spy();
const target = renderView({onAddComment});
renderElementIntoDOM(target);
target.querySelector<HTMLElement>('.comments-empty-state-link')?.click();
sinon.assert.calledOnce(onAddComment);
});
it('shows "Sent" for a sent thread until the agent takes it, then "Working"', () => {
const threadData = createMockThread('a.cta', 'Test comment');
threadData.thread.sendToAgent();
let target = renderView({sentThreads: [threadData]});
let item = target.querySelector('.comment-thread-item');
assert.isNotNull(item);
assert.isNotNull(item.querySelector('.thread-status-sent'));
assert.isNull(item.querySelector('.thread-status-working'));
assert.isNull(item.querySelector('devtools-spinner'));
threadData.thread.markTransmitted();
target = renderView({sentThreads: [threadData]});
item = target.querySelector('.comment-thread-item');
assert.isNotNull(item);
assert.isNull(item.querySelector('.thread-status-sent'));
assert.isNotNull(item.querySelector('.thread-status-working devtools-spinner'));
});
it('shows "See response" for resolved threads, including generated comments', () => {
const userThread = createMockThread('a.cta', 'User comment');
userThread.thread.resolve('Done');
const generatedThread = new CommentManager.CommentThread.CommentThread({
anchor: {vePath: 'Panel: elements > div.changed', textSignature: 'div.changed'},
comments: [{author: 'DEVELOPER', text: 'Changed color.', timestamp: 0}],
isGeneratedComment: true,
});
generatedThread.status = 'ACTIVE';
generatedThread.resolve('Done');
const target = renderView({
activeTab: 'sent',
sentThreads: [
userThread,
{thread: generatedThread, title: {text: 'div.changed'}, commentText: 'Changed color.'},
],
});
const items = target.querySelectorAll('.comment-thread-item');
assert.lengthOf(items, 2);
assert.isNotNull(items[0].querySelector('devtools-button'));
assert.isNotNull(items[1].querySelector('devtools-button'));
});
it('does not trigger thread selection when pressing Enter or Space on the delete button', () => {
const onThreadClick = sinon.spy();
const [mockThreadData] = createQueuedThreads(1);
const target = renderView({queuedThreads: [mockThreadData], onThreadClick});
renderElementIntoDOM(target);
const deleteButton = target.querySelector('.comment-thread-item devtools-button') as HTMLElement;
assert.isNotNull(deleteButton);
for (const key of ['Enter', ' ']) {
const keydownEvent = new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true});
deleteButton.dispatchEvent(keydownEvent);
assert.isFalse(keydownEvent.defaultPrevented);
}
sinon.assert.notCalled(onThreadClick);
const threadItem = target.querySelector('.comment-thread-item') as HTMLElement;
threadItem.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
sinon.assert.calledOnceWithMatch(onThreadClick, mockThreadData.thread, sinon.match({
left: sinon.match.number,
top: sinon.match.number,
}));
});
});