| // 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 sinon from 'sinon'; |
| |
| import * as Common from '../../core/common/common.js'; |
| import * as Host from '../../core/host/host.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import type * as Logs from '../logs/logs.js'; |
| |
| import * as CommentManager from './comment_manager.js'; |
| |
| describe('CD4ABridge', () => { |
| let commentManager: CommentManager.CommentManager.CommentManager; |
| |
| beforeEach(() => { |
| commentManager = new CommentManager.CommentManager.CommentManager(); |
| }); |
| |
| it('formats comment threads with anchor text, DOM node selector, and editor info', () => { |
| const mockNode = { |
| backendNodeId: () => 10, |
| simpleSelector: () => 'div.card#main', |
| } as unknown as SDK.DOMModel.DOMNode; |
| const mockDomModel = { |
| idToDOMNode: new Map([[1, mockNode]]), |
| }; |
| const mockTarget = { |
| model: sinon.stub().withArgs(SDK.DOMModel.DOMModel).returns(mockDomModel), |
| }; |
| const mockTargetManager = { |
| targetById: sinon.stub().withArgs('target-1').returns(mockTarget), |
| primaryPageTarget: () => null, |
| } as unknown as SDK.TargetManager.TargetManager; |
| |
| const bridge = new CommentManager.CD4ABridge.CD4ABridge(commentManager, mockTargetManager); |
| |
| const thread = commentManager.createCommentThread( |
| { |
| vePath: 'Panel: elements > Pane: styles', |
| textSignature: 'color: red', |
| networkRequestId: 'req-1', |
| node: { |
| backendNodeId: 10, |
| targetId: 'target-1', |
| }, |
| editor: { |
| filePath: 'index.html', |
| lineNumber: 42, |
| }, |
| }, |
| 'Need color contrast fix', |
| 'DEVELOPER', |
| ); |
| thread.save(); |
| // Saved (ACTIVE) threads are not exposed over the bridge until sent to agent |
| assert.isEmpty(bridge.getCommentThreads()); |
| |
| thread.sendToAgent(); |
| |
| const threads = bridge.getCommentThreads(); |
| assert.lengthOf(threads, 1); |
| assert.strictEqual(threads[0].id, thread.id); |
| assert.strictEqual( |
| threads[0].text, |
| 'Need color contrast fix\n\n- DevTools VEPath: Panel: elements > Pane: styles\n- DevTools element: color: red\n- DOM node selector: div.card#main\n- Editor: index.html:42', |
| ); |
| assert.strictEqual(threads[0].networkRequestId, 'req-1'); |
| assert.deepEqual(threads[0].node, { |
| backendNodeId: 10, |
| targetId: 'target-1', |
| }); |
| assert.isUndefined((threads[0] as unknown as Record<string, unknown>).editor); |
| assert.isUndefined((threads[0] as unknown as Record<string, unknown>).backendNodeId); |
| |
| // Second retrieval returns empty array as unsent comments were taken |
| const threadsAfter = bridge.getCommentThreads(); |
| assert.lengthOf(threadsAfter, 0); |
| }); |
| |
| it('formats editor without filePath and handles missing DOM node gracefully', () => { |
| const bridge = new CommentManager.CD4ABridge.CD4ABridge(commentManager); |
| |
| const thread = commentManager.createCommentThread( |
| { |
| vePath: 'Panel: sources', |
| textSignature: 'const x = 1;', |
| node: { |
| backendNodeId: 99, |
| targetId: 'target-1', |
| }, |
| editor: { |
| lineNumber: 15, |
| }, |
| }, |
| 'Check variable', |
| ); |
| thread.sendToAgent(); |
| |
| const threads = bridge.getCommentThreads(); |
| assert.lengthOf(threads, 1); |
| assert.strictEqual( |
| threads[0].text, |
| 'Check variable\n\n- DevTools VEPath: Panel: sources\n- DevTools element: const x = 1;\n- Editor: line 15', |
| ); |
| assert.deepEqual(threads[0].node, { |
| backendNodeId: 99, |
| targetId: 'target-1', |
| }); |
| }); |
| |
| it('does not format comment with unrelated node when target is missing', () => { |
| const mockPrimaryNode = { |
| backendNodeId: () => 10, |
| simpleSelector: () => 'div.unrelated', |
| } as unknown as SDK.DOMModel.DOMNode; |
| const mockPrimaryDomModel = { |
| idToDOMNode: new Map([[10, mockPrimaryNode]]), |
| }; |
| const mockPrimaryTarget = { |
| model: sinon.stub().withArgs(SDK.DOMModel.DOMModel).returns(mockPrimaryDomModel), |
| }; |
| const mockTargetManager = { |
| targetById: sinon.stub().withArgs('destroyed-target').returns(null), |
| primaryPageTarget: () => mockPrimaryTarget, |
| } as unknown as SDK.TargetManager.TargetManager; |
| |
| const bridge = new CommentManager.CD4ABridge.CD4ABridge(commentManager, mockTargetManager); |
| |
| const thread = commentManager.createCommentThread( |
| { |
| vePath: 'Panel: elements', |
| textSignature: 'color: red', |
| node: { |
| backendNodeId: 10, |
| targetId: 'destroyed-target', |
| }, |
| }, |
| 'Missing target comment', |
| 'DEVELOPER', |
| ); |
| thread.sendToAgent(); |
| |
| const threads = bridge.getCommentThreads(); |
| assert.lengthOf(threads, 1); |
| assert.strictEqual( |
| threads[0].text, |
| 'Missing target comment\n\n- DevTools VEPath: Panel: elements\n- DevTools element: color: red', |
| ); |
| }); |
| |
| it('only takes the first comment text', () => { |
| const bridge = new CommentManager.CD4ABridge.CD4ABridge(commentManager); |
| |
| const thread = commentManager.createCommentThread( |
| { |
| vePath: 'Panel: elements', |
| textSignature: 'h1', |
| }, |
| 'First comment', |
| ); |
| thread.comments.push({ |
| author: 'DEVELOPER', |
| text: 'Second comment', |
| timestamp: Date.now(), |
| }); |
| thread.sendToAgent(); |
| |
| const threads = bridge.getCommentThreads(); |
| assert.lengthOf(threads, 1); |
| assert.strictEqual( |
| threads[0].text, |
| 'First comment\n\n- DevTools VEPath: Panel: elements\n- DevTools element: h1', |
| ); |
| }); |
| |
| it('formats recorded change threads using their comment text', () => { |
| const bridge = new CommentManager.CD4ABridge.CD4ABridge(commentManager); |
| |
| const changeOnlyThread = commentManager.createCommentThread( |
| { |
| vePath: 'Panel: elements > Tree: elements > TreeItem', |
| textSignature: '', |
| }, |
| 'Changed attribute "class" from "old" to "new"', |
| 'DEVELOPER', |
| true, |
| ); |
| changeOnlyThread.sendToAgent(); |
| |
| const commentAndElementThread = commentManager.createCommentThread( |
| { |
| vePath: 'Panel: elements > Tree: elements > TreeItem', |
| textSignature: 'button.cta', |
| }, |
| 'Changed text from "Submit" to "Send"', |
| 'DEVELOPER', |
| true, |
| ); |
| commentAndElementThread.sendToAgent(); |
| |
| const threads = bridge.getCommentThreads(); |
| assert.lengthOf(threads, 2); |
| assert.strictEqual( |
| threads[0].text, |
| 'Changed attribute "class" from "old" to "new"\n\n- DevTools VEPath: Panel: elements > Tree: elements > TreeItem', |
| ); |
| assert.strictEqual( |
| threads[1].text, |
| 'Changed text from "Submit" to "Send"\n\n- DevTools VEPath: Panel: elements > Tree: elements > TreeItem\n- DevTools element: button.cta', |
| ); |
| }); |
| |
| it('delegates resolve to CommentManager and dispatches events', () => { |
| const bridge = new CommentManager.CD4ABridge.CD4ABridge(commentManager); |
| |
| const thread = commentManager.createCommentThread( |
| { |
| vePath: 'Panel: elements', |
| textSignature: 'h1', |
| }, |
| 'Fix heading', |
| ); |
| |
| let eventFired = false; |
| bridge.addEventListener(CommentManager.CD4ABridge.Events.COMMENT_THREADS_CHANGED, () => { |
| eventFired = true; |
| }); |
| |
| const success = bridge.resolveCommentThread(thread.id, 'Fixed heading'); |
| assert.isTrue(success); |
| assert.isTrue(eventFired); |
| |
| const updated = commentManager.getCommentThread(thread.id); |
| assert.strictEqual(updated?.status, 'RESOLVED'); |
| assert.lengthOf(updated?.comments || [], 2); |
| assert.strictEqual(updated?.comments[1].author, 'AGENT'); |
| assert.strictEqual(updated?.comments[1].text, 'Fixed heading'); |
| }); |
| |
| it('removes listeners on dispose', () => { |
| const bridge = new CommentManager.CD4ABridge.CD4ABridge(commentManager); |
| let eventFired = false; |
| bridge.addEventListener(CommentManager.CD4ABridge.Events.COMMENT_THREADS_CHANGED, () => { |
| eventFired = true; |
| }); |
| |
| bridge.dispose(); |
| |
| commentManager.createCommentThread( |
| { |
| vePath: 'Panel: elements', |
| textSignature: 'h1', |
| }, |
| 'Fix heading', |
| ); |
| |
| assert.isFalse(eventFired); |
| }); |
| |
| it('delegates setAgentAttached to CommentManager', () => { |
| const bridge = new CommentManager.CD4ABridge.CD4ABridge(commentManager); |
| assert.isFalse(commentManager.isAgentAttached()); |
| |
| bridge.setAgentAttached(true); |
| assert.isTrue(commentManager.isAgentAttached()); |
| |
| bridge.setAgentAttached(false); |
| assert.isFalse(commentManager.isAgentAttached()); |
| }); |
| |
| describe('reveal', () => { |
| let mockHost: Host.InspectorFrontendHostAPI.InspectorFrontendHostAPI; |
| let showPanelSpy: sinon.SinonSpy; |
| |
| beforeEach(() => { |
| showPanelSpy = sinon.spy(); |
| const hostEvents = new Common.ObjectWrapper.ObjectWrapper<Host.InspectorFrontendHostAPI.EventTypes>(); |
| hostEvents.addEventListener(Host.InspectorFrontendHostAPI.Events.ShowPanel, event => { |
| showPanelSpy(event.data); |
| }); |
| mockHost = { |
| events: hostEvents, |
| } as unknown as Host.InspectorFrontendHostAPI.InspectorFrontendHostAPI; |
| }); |
| |
| afterEach(() => { |
| sinon.restore(); |
| Common.Revealer.RevealerRegistry.removeInstance(); |
| }); |
| |
| it('shows panel for any given panel name', async () => { |
| const bridge = new CommentManager.CD4ABridge.CD4ABridge(commentManager, undefined, undefined, mockHost); |
| await bridge.reveal('custom_panel'); |
| |
| assert.isTrue(showPanelSpy.calledOnceWith('custom_panel')); |
| }); |
| |
| it('reveals network request in addition to panel reveal', async () => { |
| const mockRequest = {requestId: () => 'req-1'} as SDK.NetworkRequest.NetworkRequest; |
| const mockNetworkLog = { |
| requestsForId: (id: string) => id === 'req-1' ? [mockRequest] : [], |
| } as unknown as Logs.NetworkLog.NetworkLog; |
| |
| const revealStub = sinon.stub(Common.Revealer.RevealerRegistry.instance(), 'reveal').resolves(); |
| |
| const bridge = new CommentManager.CD4ABridge.CD4ABridge(commentManager, undefined, mockNetworkLog, mockHost); |
| await bridge.reveal('network', {networkRequestId: 'req-1'}); |
| |
| assert.isTrue(showPanelSpy.calledOnceWith('network')); |
| assert.isTrue(revealStub.calledOnceWith(mockRequest)); |
| }); |
| |
| it('reveals DOM node in addition to panel reveal', async () => { |
| const mockNode = {} as SDK.DOMModel.DOMNode; |
| const mockDomModel = { |
| pushNodesByBackendIdsToFrontend: sinon.stub().resolves(new Map([[10, mockNode]])), |
| }; |
| const mockTarget = { |
| model: sinon.stub().withArgs(SDK.DOMModel.DOMModel).returns(mockDomModel), |
| }; |
| const mockTargetManager = { |
| targetById: sinon.stub().withArgs('target-1').returns(mockTarget), |
| primaryPageTarget: () => null, |
| } as unknown as SDK.TargetManager.TargetManager; |
| |
| const revealStub = sinon.stub(Common.Revealer.RevealerRegistry.instance(), 'reveal').resolves(); |
| |
| const bridge = new CommentManager.CD4ABridge.CD4ABridge(commentManager, mockTargetManager, undefined, mockHost); |
| await bridge.reveal('elements', {node: {backendNodeId: 10, targetId: 'target-1'}}); |
| |
| assert.isTrue(showPanelSpy.calledOnceWith('elements')); |
| assert.isTrue(revealStub.calledOnceWith(mockNode)); |
| }); |
| |
| it('does not reveal unrelated DOM node from primary page target when node target is missing', async () => { |
| const mockNode = {} as SDK.DOMModel.DOMNode; |
| const mockDomModel = { |
| pushNodesByBackendIdsToFrontend: sinon.stub().resolves(new Map([[10, mockNode]])), |
| }; |
| const mockPrimaryTarget = { |
| model: sinon.stub().withArgs(SDK.DOMModel.DOMModel).returns(mockDomModel), |
| }; |
| const mockTargetManager = { |
| targetById: sinon.stub().withArgs('destroyed-target').returns(null), |
| primaryPageTarget: () => mockPrimaryTarget, |
| } as unknown as SDK.TargetManager.TargetManager; |
| |
| const revealStub = sinon.stub(Common.Revealer.RevealerRegistry.instance(), 'reveal').resolves(); |
| |
| const bridge = new CommentManager.CD4ABridge.CD4ABridge(commentManager, mockTargetManager, undefined, mockHost); |
| await bridge.reveal('elements', {node: {backendNodeId: 10, targetId: 'destroyed-target'}}); |
| |
| assert.isTrue(showPanelSpy.calledOnceWith('elements')); |
| sinon.assert.notCalled(revealStub); |
| sinon.assert.notCalled(mockDomModel.pushNodesByBackendIdsToFrontend); |
| }); |
| |
| it('reveals both network request and DOM node if both are present in target', async () => { |
| const mockRequest = {requestId: () => 'req-1'} as SDK.NetworkRequest.NetworkRequest; |
| const mockNetworkLog = { |
| requestsForId: (id: string) => id === 'req-1' ? [mockRequest] : [], |
| } as unknown as Logs.NetworkLog.NetworkLog; |
| |
| const mockNode = {} as SDK.DOMModel.DOMNode; |
| const mockDomModel = { |
| pushNodesByBackendIdsToFrontend: sinon.stub().resolves(new Map([[10, mockNode]])), |
| }; |
| const mockPrimaryTarget = { |
| model: sinon.stub().withArgs(SDK.DOMModel.DOMModel).returns(mockDomModel), |
| }; |
| const mockTargetManager = { |
| targetById: sinon.stub().withArgs('target-1').returns(mockPrimaryTarget), |
| primaryPageTarget: () => mockPrimaryTarget, |
| } as unknown as SDK.TargetManager.TargetManager; |
| |
| const revealStub = sinon.stub(Common.Revealer.RevealerRegistry.instance(), 'reveal').resolves(); |
| |
| const bridge = |
| new CommentManager.CD4ABridge.CD4ABridge(commentManager, mockTargetManager, mockNetworkLog, mockHost); |
| await bridge.reveal('summary_panel', { |
| networkRequestId: 'req-1', |
| node: {backendNodeId: 10, targetId: 'target-1'}, |
| }); |
| |
| assert.isTrue(showPanelSpy.calledOnceWith('summary_panel')); |
| sinon.assert.callCount(revealStub, 2); |
| sinon.assert.calledWith(revealStub.firstCall, mockRequest); |
| sinon.assert.calledWith(revealStub.secondCall, mockNode); |
| }); |
| }); |
| }); |