| // 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 CommentManager from '../../models/comment_manager/comment_manager.js'; |
| import {renderElementIntoDOM} from '../../testing/DOMHelpers.js'; |
| |
| import * as Comments from './comments.js'; |
| |
| describe('CommentOverlayManager', () => { |
| let container: HTMLElement; |
| let manager: Comments.CommentOverlayManager.CommentOverlayManager; |
| |
| beforeEach(() => { |
| container = document.createElement('div'); |
| renderElementIntoDOM(container); |
| const commentManager = new CommentManager.CommentManager.CommentManager(); |
| manager = new Comments.CommentOverlayManager.CommentOverlayManager(commentManager); |
| }); |
| |
| afterEach(() => { |
| manager.clear(); |
| container.remove(); |
| }); |
| |
| it('creates and retrieves comment threads and computes pin positions', () => { |
| const item = document.createElement('div'); |
| item.setAttribute('jslog', 'TreeItem; context: test'); |
| item.textContent = 'font-size: 14px;'; |
| container.appendChild(item); |
| |
| const thread = manager.createComment(item, 'Needs adjustment'); |
| assert.isNotNull(thread); |
| assert.lengthOf(manager.getCommentThreads(), 1); |
| assert.strictEqual(manager.getCommentThread(thread!.id), thread); |
| assert.strictEqual(thread?.comments[0].text, 'Needs adjustment'); |
| assert.strictEqual(thread?.comments[0].author, 'DEVELOPER'); |
| assert.strictEqual(thread?.status, 'ACTIVE'); |
| |
| const pins = manager.getPinPositions(); |
| assert.lengthOf(pins, 1); |
| assert.isTrue(pins[0].visible); |
| }); |
| |
| it('returns null when creating comment on non-anchorable element', () => { |
| const item = document.createElement('div'); |
| container.appendChild(item); |
| |
| const thread = manager.createComment(item, 'Invalid anchor'); |
| assert.isNull(thread); |
| assert.lengthOf(manager.getCommentThreads(), 0); |
| }); |
| |
| it('supports AGENT author and custom changes metadata in created comment threads', () => { |
| const item = document.createElement('div'); |
| item.setAttribute('jslog', 'TreeItem; context: agent-item'); |
| item.textContent = 'color: #333;'; |
| container.appendChild(item); |
| |
| const changes = [{property: 'color', oldValue: '#333', newValue: '#000'}]; |
| const thread = manager.createComment(item, 'Auto-fixed color', 'AGENT', changes); |
| |
| assert.isNotNull(thread); |
| assert.strictEqual(thread?.comments[0].author, 'AGENT'); |
| assert.strictEqual(thread?.comments[0].text, 'Auto-fixed color'); |
| assert.deepEqual(thread?.changes, changes); |
| }); |
| |
| it('removes comment threads and cleans up DOM observer and pin positions', () => { |
| const unobserveSpy = sinon.spy(IntersectionObserver.prototype, 'unobserve'); |
| try { |
| const item = document.createElement('div'); |
| item.setAttribute('jslog', 'TreeItem; context: delete-test'); |
| item.textContent = 'delete me'; |
| container.appendChild(item); |
| |
| const thread = manager.createComment(item, 'To delete'); |
| assert.isNotNull(thread); |
| assert.lengthOf(manager.getCommentThreads(), 1); |
| assert.lengthOf(manager.getPinPositions(), 1); |
| |
| let eventCount = 0; |
| manager.addEventListener(Comments.CommentOverlayManager.Events.POSITIONS_UPDATED, () => { |
| eventCount++; |
| }); |
| |
| manager.removeCommentThread(thread!.id); |
| assert.lengthOf(manager.getCommentThreads(), 0); |
| assert.lengthOf(manager.getPinPositions(), 0); |
| assert.strictEqual(eventCount, 1); |
| sinon.assert.calledWith(unobserveSpy, item); |
| } finally { |
| unobserveSpy.restore(); |
| } |
| }); |
| |
| it('clears all comment threads, pin positions, and resets cursor when clear() is called', () => { |
| manager.setCommentMode(true); |
| const item = document.createElement('div'); |
| item.setAttribute('jslog', 'TreeItem; context: clear-test'); |
| item.textContent = 'clear me'; |
| container.appendChild(item); |
| |
| manager.createComment(item, 'To clear'); |
| assert.lengthOf(manager.getCommentThreads(), 1); |
| assert.lengthOf(manager.getPinPositions(), 1); |
| assert.isTrue(manager.isCommentMode()); |
| assert.strictEqual(document.body.style.cursor, 'crosshair'); |
| |
| manager.clear(); |
| assert.lengthOf(manager.getCommentThreads(), 0); |
| assert.lengthOf(manager.getPinPositions(), 0); |
| assert.isFalse(manager.isCommentMode()); |
| assert.strictEqual(document.body.style.cursor, ''); |
| }); |
| |
| it('toggles comment mode and updates document cursor', () => { |
| manager.setCommentMode(true); |
| assert.isTrue(manager.isCommentMode()); |
| assert.strictEqual(document.body.style.cursor, 'crosshair'); |
| |
| manager.setCommentMode(false); |
| assert.isFalse(manager.isCommentMode()); |
| assert.strictEqual(document.body.style.cursor, ''); |
| }); |
| |
| it('handles element clicks in comment mode', () => { |
| const item = document.createElement('div'); |
| item.setAttribute('jslog', 'TreeItem; context: click-item'); |
| item.textContent = 'display: block;'; |
| container.appendChild(item); |
| |
| const threadInactive = manager.handleElementClick(item, 'Not in mode'); |
| assert.isNull(threadInactive); |
| |
| manager.setCommentMode(true); |
| const threadActive = manager.handleElementClick(item, 'In mode'); |
| assert.isNotNull(threadActive); |
| assert.strictEqual(threadActive?.comments[0].text, 'In mode'); |
| }); |
| |
| it('creates comments when clicking elements with start() in Comment Mode', () => { |
| manager.start(container, 'Clicked comment'); |
| manager.setCommentMode(true); |
| |
| const el = document.createElement('div'); |
| el.setAttribute('jslog', 'TreeItem; context: clickable'); |
| el.textContent = 'line-height: 1.5;'; |
| container.appendChild(el); |
| |
| el.click(); |
| |
| const threads = manager.getCommentThreads(); |
| assert.lengthOf(threads, 1); |
| assert.strictEqual(threads[0].comments[0].text, 'Clicked comment'); |
| assert.include(threads[0].anchor.vePath, 'TreeItem: clickable'); |
| }); |
| |
| it('does not create comments when clicking elements in Comment Mode if not anchorable', () => { |
| manager.start(container, 'Clicked comment'); |
| manager.setCommentMode(true); |
| |
| const emptyDiv = document.createElement('div'); |
| container.appendChild(emptyDiv); |
| emptyDiv.click(); |
| |
| assert.lengthOf(manager.getCommentThreads(), 0); |
| }); |
| |
| it('creates comments when clicking elements inside Shadow DOM using composed target', () => { |
| manager.start(container, 'Shadow comment'); |
| manager.setCommentMode(true); |
| |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| const innerEl = document.createElement('div'); |
| innerEl.setAttribute('jslog', 'TreeItem; context: shadow-item'); |
| innerEl.textContent = 'shadow content'; |
| shadow.appendChild(innerEl); |
| container.appendChild(host); |
| |
| innerEl.click(); |
| |
| const threads = manager.getCommentThreads(); |
| assert.lengthOf(threads, 1); |
| assert.strictEqual(threads[0].comments[0].text, 'Shadow comment'); |
| assert.include(threads[0].anchor.vePath, 'TreeItem: shadow-item'); |
| }); |
| |
| it('suppresses pointer and mouse events on anchorable elements in Comment Mode', () => { |
| manager.start(container, 'Suppress test'); |
| manager.setCommentMode(true); |
| |
| const el = document.createElement('div'); |
| el.setAttribute('jslog', 'TreeItem; context: suppress-item'); |
| el.textContent = 'target content'; |
| container.appendChild(el); |
| |
| for (const eventType of ['mousedown', 'pointerdown', 'mouseup', 'pointerup', 'dblclick']) { |
| const ev = new MouseEvent(eventType, {bubbles: true, cancelable: true}); |
| el.dispatchEvent(ev); |
| assert.isTrue(ev.defaultPrevented, `Expected ${eventType} to be defaultPrevented`); |
| } |
| }); |
| |
| it('sets hover highlight data on mouseover in Comment Mode and clears it on mouseleave', () => { |
| manager.start(container, 'Highlight test'); |
| manager.setCommentMode(true); |
| |
| const el = document.createElement('div'); |
| el.setAttribute('jslog', 'TreeItem; context: highlighted'); |
| el.textContent = 'padding: 8px;'; |
| container.appendChild(el); |
| |
| const mouseoverEvent = new MouseEvent('mouseover', {bubbles: true, cancelable: true}); |
| el.dispatchEvent(mouseoverEvent); |
| assert.isTrue(mouseoverEvent.defaultPrevented); |
| |
| const hoverData = manager.getHoverHighlight(); |
| assert.isNotNull(hoverData); |
| assert.isTrue(hoverData?.visible); |
| |
| const mouseleaveEvent = new MouseEvent('mouseleave', {bubbles: true, cancelable: true}); |
| el.dispatchEvent(mouseleaveEvent); |
| assert.isTrue(mouseleaveEvent.defaultPrevented); |
| assert.isNull(manager.getHoverHighlight()); |
| }); |
| |
| it('does not consume mouseleave event on non-anchorable elements in Comment Mode', () => { |
| manager.start(container, 'Hover test'); |
| manager.setCommentMode(true); |
| |
| const nonAnchorEl = document.createElement('div'); |
| container.appendChild(nonAnchorEl); |
| |
| const mouseleaveEvent = new MouseEvent('mouseleave', {bubbles: true, cancelable: true}); |
| nonAnchorEl.dispatchEvent(mouseleaveEvent); |
| assert.isFalse(mouseleaveEvent.defaultPrevented); |
| assert.isNull(manager.getHoverHighlight()); |
| }); |
| |
| it('does not clear hover highlight when moving pointer between children of the same anchor element', () => { |
| manager.start(container, 'Child hover test'); |
| manager.setCommentMode(true); |
| |
| const anchorEl = document.createElement('div'); |
| anchorEl.setAttribute('jslog', 'TreeItem; context: parent-anchor'); |
| const child1 = document.createElement('span'); |
| child1.textContent = 'child 1'; |
| const child2 = document.createElement('span'); |
| child2.textContent = 'child 2'; |
| anchorEl.appendChild(child1); |
| anchorEl.appendChild(child2); |
| container.appendChild(anchorEl); |
| |
| const mouseoverEvent = new MouseEvent('mouseover', {bubbles: true, cancelable: true}); |
| child1.dispatchEvent(mouseoverEvent); |
| assert.isNotNull(manager.getHoverHighlight()); |
| |
| const mouseleaveEvent = new MouseEvent('mouseleave', {bubbles: true, cancelable: true, relatedTarget: child2}); |
| child1.dispatchEvent(mouseleaveEvent); |
| |
| assert.isNotNull(manager.getHoverHighlight()); |
| }); |
| |
| it('deduplicates HOVER_HIGHLIGHT_CHANGED events when hover data has not changed', () => { |
| manager.start(container, 'Deduplication test'); |
| manager.setCommentMode(true); |
| |
| const anchorEl = document.createElement('div'); |
| anchorEl.setAttribute('jslog', 'TreeItem; context: dedup-anchor'); |
| anchorEl.textContent = 'dedup content'; |
| container.appendChild(anchorEl); |
| |
| let eventCount = 0; |
| manager.addEventListener(Comments.CommentOverlayManager.Events.HOVER_HIGHLIGHT_CHANGED, () => { |
| eventCount++; |
| }); |
| |
| const mouseoverEvent1 = new MouseEvent('mouseover', {bubbles: true, cancelable: true}); |
| anchorEl.dispatchEvent(mouseoverEvent1); |
| assert.strictEqual(eventCount, 1); |
| |
| // Repeated mousemove or mouseover on the same element with identical bounds should not dispatch new events |
| const mousemoveEvent = new MouseEvent('mousemove', {bubbles: true, cancelable: true}); |
| anchorEl.dispatchEvent(mousemoveEvent); |
| assert.strictEqual(eventCount, 1); |
| |
| // Leaving should fire event once |
| const mouseleaveEvent = new MouseEvent('mouseleave', {bubbles: true, cancelable: true}); |
| anchorEl.dispatchEvent(mouseleaveEvent); |
| assert.strictEqual(eventCount, 2); |
| |
| // Repeated leave or clear when already null should not dispatch new events |
| anchorEl.dispatchEvent(mouseleaveEvent); |
| assert.strictEqual(eventCount, 2); |
| }); |
| |
| it('starts and stops listeners and observers cleanly', () => { |
| manager.start({root: container, defaultText: 'Test'}); |
| manager.stop(); |
| }); |
| |
| it('staggers pin vertical offsets when multiple comments are on the same element', () => { |
| const item = document.createElement('div'); |
| item.setAttribute('jslog', 'TreeItem; context: multi-pin'); |
| item.textContent = 'display: grid;'; |
| item.getBoundingClientRect = () => new DOMRect(50, 100, 200, 30); |
| container.appendChild(item); |
| |
| manager.setCommentMode(true); |
| const thread1 = manager.createComment(item, 'First comment'); |
| const thread2 = manager.createComment(item, 'Second comment'); |
| |
| assert.isNotNull(thread1); |
| assert.isNotNull(thread2); |
| |
| const pins = manager.getPinPositions(); |
| assert.lengthOf(pins, 2); |
| assert.isTrue(pins[0].visible); |
| assert.isTrue(pins[1].visible); |
| |
| // Second pin should have a 26px vertical offset compared to first pin |
| assert.strictEqual(pins[1].top - pins[0].top, 26); |
| }); |
| |
| it('observes connected elements with IntersectionObserver even when hidden', () => { |
| const observeSpy = sinon.spy(IntersectionObserver.prototype, 'observe'); |
| try { |
| const hiddenEl = document.createElement('div'); |
| hiddenEl.setAttribute('jslog', 'TreeItem; context: hidden-item'); |
| hiddenEl.textContent = 'hidden item'; |
| hiddenEl.style.display = 'none'; |
| container.appendChild(hiddenEl); |
| |
| manager.setCommentMode(true); |
| const thread = manager.createComment(hiddenEl, 'Hidden comment'); |
| assert.isNotNull(thread); |
| sinon.assert.calledWith(observeSpy, hiddenEl); |
| |
| // Pins should be empty because element is hidden |
| assert.lengthOf(manager.getPinPositions(), 0); |
| } finally { |
| observeSpy.restore(); |
| } |
| }); |
| |
| it('keeps observing element when other comment threads remain on it', () => { |
| const unobserveSpy = sinon.spy(IntersectionObserver.prototype, 'unobserve'); |
| try { |
| const el = document.createElement('div'); |
| el.setAttribute('jslog', 'TreeItem; context: shared-element'); |
| el.textContent = 'shared comment target'; |
| container.appendChild(el); |
| |
| manager.setCommentMode(true); |
| const thread1 = manager.createComment(el, 'Comment 1'); |
| const thread2 = manager.createComment(el, 'Comment 2'); |
| assert.isNotNull(thread1); |
| assert.isNotNull(thread2); |
| assert.lengthOf(manager.getCommentThreads(), 2); |
| |
| manager.removeCommentThread(thread1!.id); |
| assert.lengthOf(manager.getCommentThreads(), 1); |
| assert.isFalse(unobserveSpy.calledWith(el)); |
| |
| manager.removeCommentThread(thread2!.id); |
| assert.lengthOf(manager.getCommentThreads(), 0); |
| sinon.assert.calledWith(unobserveSpy, el); |
| } finally { |
| unobserveSpy.restore(); |
| } |
| }); |
| |
| it('rematches comments across DOM re-renders and updates pin visibility for orphaned comments', async () => { |
| const clock = sinon.useFakeTimers(); |
| try { |
| manager.start(container); |
| manager.setCommentMode(true); |
| |
| const oldItem = document.createElement('div'); |
| oldItem.setAttribute('jslog', 'TreeItem; context: dynamic'); |
| oldItem.textContent = 'display: flex;'; |
| container.appendChild(oldItem); |
| |
| const thread = manager.createComment(oldItem, 'Flex bug'); |
| assert.isNotNull(thread); |
| assert.lengthOf(manager.getPinPositions(), 1); |
| assert.isTrue(manager.getPinPositions()[0].visible); |
| |
| // Simulate DOM re-render by replacing oldItem with a newly recreated DOM node |
| oldItem.remove(); |
| const newItem = document.createElement('div'); |
| newItem.setAttribute('jslog', 'TreeItem; context: dynamic'); |
| newItem.textContent = 'display: flex;'; |
| container.appendChild(newItem); |
| |
| await Promise.resolve(); |
| clock.tick(250); |
| |
| assert.lengthOf(manager.getPinPositions(), 1); |
| assert.isTrue(manager.getPinPositions()[0].visible); |
| |
| // Simulate item scrolling out of view / folder collapsing (node removed) |
| newItem.remove(); |
| |
| await Promise.resolve(); |
| clock.tick(250); |
| |
| assert.lengthOf(manager.getPinPositions(), 0); |
| assert.lengthOf(manager.getHighlightRects(), 0); |
| } finally { |
| clock.restore(); |
| } |
| }); |
| |
| it('unobserves old elements when rematching to a new element for recycled nodes', async () => { |
| const clock = sinon.useFakeTimers(); |
| const unobserveSpy = sinon.spy(IntersectionObserver.prototype, 'unobserve'); |
| try { |
| manager.start(container); |
| manager.setCommentMode(true); |
| |
| const oldItem = document.createElement('div'); |
| oldItem.setAttribute('jslog', 'TreeItem; context: recycled'); |
| oldItem.textContent = 'item 1'; |
| container.appendChild(oldItem); |
| |
| const thread = manager.createComment(oldItem, 'Recycled test'); |
| assert.isNotNull(thread); |
| |
| oldItem.setAttribute('jslog', 'TreeItem; context: recycled-different'); |
| const newItem = document.createElement('div'); |
| newItem.setAttribute('jslog', 'TreeItem; context: recycled'); |
| newItem.textContent = 'item 1 recycled'; |
| container.appendChild(newItem); |
| |
| await Promise.resolve(); |
| clock.tick(250); |
| |
| sinon.assert.calledWith(unobserveSpy, oldItem); |
| } finally { |
| unobserveSpy.restore(); |
| clock.restore(); |
| } |
| }); |
| }); |