| // 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 {renderElementIntoDOM} from '../../testing/DOMHelpers.js'; |
| import {describeWithEnvironment} from '../../testing/EnvironmentHelpers.js'; |
| import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js'; |
| import * as TextEditor from '../components/text_editor/text_editor.js'; |
| |
| import * as Comments from './comments.js'; |
| |
| function createTextEditor(doc: string, filePath?: string): TextEditor.TextEditor.TextEditor { |
| const state = CodeMirror.EditorState.create({ |
| doc, |
| extensions: [ |
| CodeMirror.lineNumbers(), |
| TextEditor.Config.baseConfiguration(doc), |
| ], |
| }); |
| const textEditor = new TextEditor.TextEditor.TextEditor(state); |
| textEditor.editor.dom.setAttribute('jslog', 'TextField; context: editor'); |
| if (filePath) { |
| textEditor.editor.dom.setAttribute('data-file-path', filePath); |
| } |
| return textEditor; |
| } |
| |
| describeWithEnvironment('CommentAnchorResolver', () => { |
| let container: HTMLElement; |
| |
| beforeEach(() => { |
| container = document.createElement('div'); |
| renderElementIntoDOM(container); |
| }); |
| |
| afterEach(() => { |
| container.remove(); |
| }); |
| |
| describe('closestAcrossShadow', () => { |
| it('returns the element itself if it matches the selector', () => { |
| const el = document.createElement('div'); |
| el.classList.add('my-target'); |
| container.appendChild(el); |
| |
| const matched = Comments.CommentAnchorResolver.closestAcrossShadow(el, '.my-target'); |
| assert.strictEqual(matched, el); |
| }); |
| |
| it('finds matching ancestor across shadow DOM boundary', () => { |
| const parent = document.createElement('div'); |
| parent.setAttribute('data-outer-id', 'outer-123'); |
| |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| const innerChild = document.createElement('span'); |
| shadow.appendChild(innerChild); |
| |
| parent.appendChild(host); |
| container.appendChild(parent); |
| |
| const matched = Comments.CommentAnchorResolver.closestAcrossShadow(innerChild, '[data-outer-id]'); |
| assert.strictEqual(matched, parent); |
| }); |
| |
| it('returns null when no matching element is found', () => { |
| const el = document.createElement('div'); |
| container.appendChild(el); |
| |
| const matched = Comments.CommentAnchorResolver.closestAcrossShadow(el, '.non-existent'); |
| assert.isNull(matched); |
| }); |
| }); |
| |
| describe('isNonEmptyItem', () => { |
| it('returns true when element has non-empty text content', () => { |
| const el = document.createElement('div'); |
| el.textContent = 'Hello World'; |
| assert.isTrue(Comments.CommentAnchorResolver.isNonEmptyItem(el)); |
| }); |
| |
| it('returns false when element is empty or only whitespace', () => { |
| const el = document.createElement('div'); |
| el.textContent = ' \n\t '; |
| assert.isFalse(Comments.CommentAnchorResolver.isNonEmptyItem(el)); |
| }); |
| |
| it('returns true when element contains non-empty text inside shadow root', () => { |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| const span = document.createElement('span'); |
| span.textContent = 'Shadow Text'; |
| shadow.appendChild(span); |
| |
| assert.isTrue(Comments.CommentAnchorResolver.isNonEmptyItem(host)); |
| }); |
| }); |
| |
| describe('isTabTitle', () => { |
| it('identifies PanelTabHeader VE elements', () => { |
| const tabHeader = document.createElement('div'); |
| tabHeader.setAttribute('jslog', 'PanelTabHeader; context: console'); |
| assert.isTrue(Comments.CommentAnchorResolver.isTabTitle(tabHeader)); |
| }); |
| |
| it('identifies elements with role="tab"', () => { |
| const tab = document.createElement('div'); |
| tab.setAttribute('role', 'tab'); |
| assert.isTrue(Comments.CommentAnchorResolver.isTabTitle(tab)); |
| }); |
| |
| it('identifies elements with tab CSS classes', () => { |
| const tab1 = document.createElement('div'); |
| tab1.classList.add('tab-element'); |
| assert.isTrue(Comments.CommentAnchorResolver.isTabTitle(tab1)); |
| |
| const tab2 = document.createElement('div'); |
| tab2.classList.add('tab-header'); |
| assert.isTrue(Comments.CommentAnchorResolver.isTabTitle(tab2)); |
| }); |
| |
| it('identifies child elements within tab headers across shadow boundaries', () => { |
| const host = document.createElement('div'); |
| host.setAttribute('role', 'tab'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| const child = document.createElement('span'); |
| shadow.appendChild(child); |
| |
| assert.isTrue(Comments.CommentAnchorResolver.isTabTitle(child)); |
| }); |
| |
| it('returns false for non-tab elements', () => { |
| const el = document.createElement('div'); |
| el.setAttribute('jslog', 'TreeItem; context: item'); |
| assert.isFalse(Comments.CommentAnchorResolver.isTabTitle(el)); |
| }); |
| }); |
| |
| describe('extractVeName', () => { |
| it('extracts trailing VE name from full visual logging path', () => { |
| assert.strictEqual( |
| Comments.CommentAnchorResolver.extractVeName('Panel: elements > Pane: styles > TreeItem: color'), 'TreeItem'); |
| assert.strictEqual(Comments.CommentAnchorResolver.extractVeName('Action: toggle'), 'Action'); |
| assert.strictEqual(Comments.CommentAnchorResolver.extractVeName('TableRow'), 'TableRow'); |
| }); |
| |
| it('returns empty string for empty path', () => { |
| assert.strictEqual(Comments.CommentAnchorResolver.extractVeName(''), ''); |
| }); |
| }); |
| |
| describe('matchesVePath', () => { |
| it('returns true when element matches full VE path', () => { |
| const panel = document.createElement('div'); |
| panel.setAttribute('jslog', 'Panel; context: elements'); |
| const item = document.createElement('div'); |
| item.setAttribute('jslog', 'TreeItem; context: rule'); |
| panel.appendChild(item); |
| container.appendChild(panel); |
| |
| assert.isTrue(Comments.CommentAnchorResolver.matchesVePath(item, 'Panel: elements > TreeItem: rule')); |
| }); |
| |
| it('returns false when element does not match VE path or lacks jslog', () => { |
| const el = document.createElement('div'); |
| container.appendChild(el); |
| assert.isFalse(Comments.CommentAnchorResolver.matchesVePath(el, 'TreeItem: rule')); |
| |
| el.setAttribute('jslog', 'Action; context: btn'); |
| assert.isFalse(Comments.CommentAnchorResolver.matchesVePath(el, 'TreeItem: rule')); |
| }); |
| |
| it('falls back to getVePath when element lacks jslog DOM attribute or value', () => { |
| const panel = document.createElement('div'); |
| panel.setAttribute('jslog', 'Panel; context: elements'); |
| const item = document.createElement('div'); |
| item.setAttribute('jslog', ''); |
| panel.appendChild(item); |
| container.appendChild(panel); |
| |
| assert.isTrue(Comments.CommentAnchorResolver.matchesVePath(item, 'Panel: elements', 'TreeItem')); |
| }); |
| }); |
| |
| describe('getSiblingIndex', () => { |
| it('returns sequential 0-based indices for elements sharing the same VE path in document order', () => { |
| const panel = document.createElement('div'); |
| panel.setAttribute('jslog', 'Panel; context: elements'); |
| |
| const item1 = document.createElement('div'); |
| item1.setAttribute('jslog', 'TreeItem; context: rule'); |
| panel.appendChild(item1); |
| |
| const item2 = document.createElement('div'); |
| item2.setAttribute('jslog', 'TreeItem; context: rule'); |
| panel.appendChild(item2); |
| |
| const item3 = document.createElement('div'); |
| item3.setAttribute('jslog', 'TreeItem; context: rule'); |
| panel.appendChild(item3); |
| |
| container.appendChild(panel); |
| |
| assert.strictEqual(Comments.CommentAnchorResolver.getSiblingIndex(item1, 'Panel: elements > TreeItem: rule'), 0); |
| assert.strictEqual(Comments.CommentAnchorResolver.getSiblingIndex(item2, 'Panel: elements > TreeItem: rule'), 1); |
| assert.strictEqual(Comments.CommentAnchorResolver.getSiblingIndex(item3, 'Panel: elements > TreeItem: rule'), 2); |
| }); |
| |
| it('correctly calculates indices when elements are inside separate DOM container wrappers', () => { |
| const panel = document.createElement('div'); |
| panel.setAttribute('jslog', 'Panel; context: elements'); |
| |
| // Each item is in its own container div (DOM parent only has 1 child) |
| const wrap1 = document.createElement('div'); |
| const item1 = document.createElement('div'); |
| item1.setAttribute('jslog', 'TreeItem; context: rule'); |
| wrap1.appendChild(item1); |
| panel.appendChild(wrap1); |
| |
| const wrap2 = document.createElement('div'); |
| const item2 = document.createElement('div'); |
| item2.setAttribute('jslog', 'TreeItem; context: rule'); |
| wrap2.appendChild(item2); |
| panel.appendChild(wrap2); |
| |
| container.appendChild(panel); |
| |
| assert.strictEqual(Comments.CommentAnchorResolver.getSiblingIndex(item1, 'Panel: elements > TreeItem: rule'), 0); |
| assert.strictEqual(Comments.CommentAnchorResolver.getSiblingIndex(item2, 'Panel: elements > TreeItem: rule'), 1); |
| }); |
| |
| it('correctly calculates indices across shadow DOM boundaries', () => { |
| const host = document.createElement('div'); |
| host.setAttribute('jslog', 'Panel; context: shadow-panel'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| |
| const item1 = document.createElement('div'); |
| item1.setAttribute('jslog', 'TreeItem; context: item'); |
| shadow.appendChild(item1); |
| |
| const item2 = document.createElement('div'); |
| item2.setAttribute('jslog', 'TreeItem; context: item'); |
| shadow.appendChild(item2); |
| |
| container.appendChild(host); |
| |
| assert.strictEqual(Comments.CommentAnchorResolver.getSiblingIndex(item1, 'Panel: shadow-panel > TreeItem: item'), |
| 0); |
| assert.strictEqual(Comments.CommentAnchorResolver.getSiblingIndex(item2, 'Panel: shadow-panel > TreeItem: item'), |
| 1); |
| }); |
| }); |
| |
| describe('resolveCommentAnchorElement', () => { |
| it('escalates interactive controls to semantic containers', () => { |
| const parent = document.createElement('div'); |
| parent.setAttribute('jslog', 'TreeItem; context: color-item'); |
| parent.textContent = 'color: red;'; |
| |
| const button = document.createElement('button'); |
| button.setAttribute('jslog', 'Action; context: toggle'); |
| button.textContent = 'Toggle'; |
| parent.appendChild(button); |
| container.appendChild(parent); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(button); |
| assert.strictEqual(anchorEl, parent); |
| }); |
| |
| it('escalates across shadow DOM boundaries to semantic containers in outer document', () => { |
| const parent = document.createElement('div'); |
| parent.setAttribute('jslog', 'TreeItem; context: shadow-tree-parent'); |
| parent.textContent = 'outer text'; |
| |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| const innerAction = document.createElement('button'); |
| innerAction.setAttribute('jslog', 'Action; context: inner-btn'); |
| innerAction.textContent = 'Click'; |
| shadow.appendChild(innerAction); |
| |
| parent.appendChild(host); |
| container.appendChild(parent); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(innerAction); |
| assert.strictEqual(anchorEl, parent); |
| }); |
| |
| it('stops escalation immediately when element has data-network-request-id', () => { |
| const row = document.createElement('div'); |
| row.setAttribute('jslog', 'Action; context: request-row'); |
| row.setAttribute('data-network-request-id', 'req-999'); |
| row.textContent = 'GET /api/data'; |
| container.appendChild(row); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(row); |
| assert.strictEqual(anchorEl, row); |
| }); |
| |
| it('returns the element that has data-network-request-id when resolving a descendant', () => { |
| const row = document.createElement('div'); |
| row.setAttribute('data-network-request-id', 'req-999'); |
| const cell = document.createElement('div'); |
| cell.setAttribute('jslog', 'TableCell; context: cell'); |
| cell.textContent = 'GET /api/data'; |
| row.appendChild(cell); |
| container.appendChild(row); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(cell); |
| assert.strictEqual(anchorEl, row); |
| }); |
| |
| it('stops escalation immediately when element has data-backend-node-id', () => { |
| const nodeEl = document.createElement('div'); |
| nodeEl.setAttribute('jslog', 'Action; context: dom-node'); |
| nodeEl.setAttribute('data-backend-node-id', '42'); |
| nodeEl.textContent = '<div>Hello</div>'; |
| container.appendChild(nodeEl); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(nodeEl); |
| assert.strictEqual(anchorEl, nodeEl); |
| }); |
| |
| it('returns the element that has data-backend-node-id when resolving a descendant', () => { |
| const nodeEl = document.createElement('div'); |
| nodeEl.setAttribute('data-backend-node-id', '42'); |
| const childSpan = document.createElement('span'); |
| childSpan.setAttribute('jslog', 'Action; context: tag-name'); |
| childSpan.textContent = 'div'; |
| nodeEl.appendChild(childSpan); |
| container.appendChild(nodeEl); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(childSpan); |
| assert.strictEqual(anchorEl, nodeEl); |
| }); |
| |
| it('escalates table cell to entire TableRow in grids/tables', () => { |
| const row = document.createElement('div'); |
| row.setAttribute('jslog', 'TableRow; context: grid-row'); |
| const cell = document.createElement('div'); |
| cell.setAttribute('jslog', 'TableCell; context: cell'); |
| cell.textContent = 'Data value'; |
| row.appendChild(cell); |
| container.appendChild(row); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(cell); |
| assert.strictEqual(anchorEl, row); |
| }); |
| |
| it('escalates inner item to entire TreeItem in trees', () => { |
| const treeItem = document.createElement('div'); |
| treeItem.setAttribute('jslog', 'TreeItem; context: tree-row'); |
| const valueSpan = document.createElement('span'); |
| valueSpan.setAttribute('jslog', 'Value; context: val'); |
| valueSpan.textContent = 'property value'; |
| treeItem.appendChild(valueSpan); |
| container.appendChild(treeItem); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(valueSpan); |
| assert.strictEqual(anchorEl, treeItem); |
| }); |
| |
| it('returns fallback candidate for standalone controls without semantic parent', () => { |
| const preview = document.createElement('div'); |
| preview.setAttribute('jslog', 'Preview; context: live-preview'); |
| preview.textContent = 'Live Output'; |
| container.appendChild(preview); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(preview); |
| assert.strictEqual(anchorEl, preview); |
| }); |
| |
| it('returns null and prevents leaving comments on empty items with no text content', () => { |
| const emptyRow = document.createElement('div'); |
| emptyRow.setAttribute('jslog', 'TableRow; context: empty-grid-row'); |
| emptyRow.textContent = ' '; |
| container.appendChild(emptyRow); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(emptyRow); |
| assert.isNull(anchorEl); |
| }); |
| |
| it('excludes tab titles from commenting', () => { |
| const tabHeader = document.createElement('div'); |
| tabHeader.setAttribute('jslog', 'PanelTabHeader; context: console'); |
| tabHeader.textContent = 'Console'; |
| container.appendChild(tabHeader); |
| |
| const anchorEl1 = Comments.CommentAnchorResolver.resolveCommentAnchorElement(tabHeader); |
| assert.isNull(anchorEl1); |
| |
| const ariaTab = document.createElement('div'); |
| ariaTab.setAttribute('role', 'tab'); |
| ariaTab.textContent = 'Network'; |
| container.appendChild(ariaTab); |
| |
| const anchorEl2 = Comments.CommentAnchorResolver.resolveCommentAnchorElement(ariaTab); |
| assert.isNull(anchorEl2); |
| |
| const classTab = document.createElement('div'); |
| classTab.classList.add('tab-header'); |
| classTab.textContent = 'Sources'; |
| container.appendChild(classTab); |
| |
| const anchorEl3 = Comments.CommentAnchorResolver.resolveCommentAnchorElement(classTab); |
| assert.isNull(anchorEl3); |
| }); |
| }); |
| |
| describe('resolveCommentAnchor', () => { |
| it('constructs vePath, captures textSignature, and calculates sibling index without redundant panelName', () => { |
| const panel = document.createElement('div'); |
| panel.setAttribute('jslog', 'Panel; context: elements'); |
| |
| const item1 = document.createElement('div'); |
| item1.setAttribute('jslog', 'TreeItem; context: rule'); |
| item1.textContent = 'margin: 0;'; |
| panel.appendChild(item1); |
| |
| const item2 = document.createElement('div'); |
| item2.setAttribute('jslog', 'TreeItem; context: rule'); |
| item2.textContent = 'margin: 0;'; |
| panel.appendChild(item2); |
| |
| container.appendChild(panel); |
| |
| const anchor1 = Comments.CommentAnchorResolver.resolveCommentAnchor(item1); |
| const anchor2 = Comments.CommentAnchorResolver.resolveCommentAnchor(item2); |
| |
| assert.isNotNull(anchor1); |
| assert.isNotNull(anchor2); |
| assert.strictEqual(anchor1?.vePath, 'Panel: elements > TreeItem: rule'); |
| assert.strictEqual(anchor2?.vePath, 'Panel: elements > TreeItem: rule'); |
| assert.strictEqual(anchor1?.siblingIndex, 0); |
| assert.strictEqual(anchor2?.siblingIndex, 1); |
| assert.strictEqual(anchor1?.textSignature, 'margin: 0;'); |
| }); |
| |
| it('captures networkRequestId from element or closest ancestor', () => { |
| const row = document.createElement('div'); |
| row.setAttribute('jslog', 'TableRow; context: network-row'); |
| row.setAttribute('data-network-request-id', 'req-1234'); |
| const cell = document.createElement('div'); |
| cell.setAttribute('jslog', 'TableCell; context: cell'); |
| cell.textContent = 'GET /api/test'; |
| row.appendChild(cell); |
| container.appendChild(row); |
| |
| const anchor = Comments.CommentAnchorResolver.resolveCommentAnchor(cell); |
| assert.isNotNull(anchor); |
| assert.strictEqual(anchor?.networkRequestId, 'req-1234'); |
| }); |
| |
| it('calculates sibling index correctly for elements inside a shadow root', () => { |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| |
| const item1 = document.createElement('div'); |
| item1.setAttribute('jslog', 'TreeItem; context: rule'); |
| item1.textContent = 'display: flex;'; |
| shadow.appendChild(item1); |
| |
| const item2 = document.createElement('div'); |
| item2.setAttribute('jslog', 'TreeItem; context: rule'); |
| item2.textContent = 'display: flex;'; |
| shadow.appendChild(item2); |
| |
| container.appendChild(host); |
| |
| const anchor1 = Comments.CommentAnchorResolver.resolveCommentAnchor(item1); |
| const anchor2 = Comments.CommentAnchorResolver.resolveCommentAnchor(item2); |
| |
| assert.isNotNull(anchor1); |
| assert.isNotNull(anchor2); |
| assert.strictEqual(anchor1?.siblingIndex, 0); |
| assert.strictEqual(anchor2?.siblingIndex, 1); |
| }); |
| |
| it('calculates sibling index for direct children of shadow root with interspersed text/comment nodes', () => { |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| |
| shadow.appendChild(document.createComment('leading comment')); |
| shadow.appendChild(document.createTextNode('\n ')); |
| |
| const item1 = document.createElement('div'); |
| item1.setAttribute('jslog', 'TreeItem; context: rule'); |
| item1.textContent = 'color: red;'; |
| shadow.appendChild(item1); |
| |
| shadow.appendChild(document.createTextNode('\n ')); |
| shadow.appendChild(document.createComment('middle comment')); |
| |
| const item2 = document.createElement('div'); |
| item2.setAttribute('jslog', 'TreeItem; context: rule'); |
| item2.textContent = 'color: red;'; |
| shadow.appendChild(item2); |
| |
| container.appendChild(host); |
| |
| const anchor1 = Comments.CommentAnchorResolver.resolveCommentAnchor(item1); |
| const anchor2 = Comments.CommentAnchorResolver.resolveCommentAnchor(item2); |
| |
| assert.isNotNull(anchor1); |
| assert.isNotNull(anchor2); |
| assert.strictEqual(anchor1?.siblingIndex, 0); |
| assert.strictEqual(anchor2?.siblingIndex, 1); |
| }); |
| |
| it('captures backendNodeId from element or closest ancestor', () => { |
| const ancestor = document.createElement('div'); |
| ancestor.setAttribute('jslog', 'TreeItem; context: dom-node'); |
| ancestor.setAttribute('data-backend-node-id', '101'); |
| const node = document.createElement('div'); |
| node.setAttribute('jslog', 'TreeItem; context: child-node'); |
| node.textContent = '<div>Hello</div>'; |
| ancestor.appendChild(node); |
| container.appendChild(ancestor); |
| |
| const anchor = Comments.CommentAnchorResolver.resolveCommentAnchor(node); |
| assert.isNotNull(anchor); |
| assert.strictEqual(anchor?.backendNodeId, 101); |
| }); |
| |
| it('returns null if element cannot be resolved to an anchor element', () => { |
| const emptyEl = document.createElement('div'); |
| container.appendChild(emptyEl); |
| |
| const anchor = Comments.CommentAnchorResolver.resolveCommentAnchor(emptyEl); |
| assert.isNull(anchor); |
| }); |
| |
| it('returns null if resolved element has no visual logging path', () => { |
| const el = document.createElement('div'); |
| el.textContent = 'Some content without jslog'; |
| container.appendChild(el); |
| |
| const anchor = Comments.CommentAnchorResolver.resolveCommentAnchor(el); |
| assert.isNull(anchor); |
| }); |
| |
| it('resolves CodeMirror line elements to .cm-editor with line number and file path', () => { |
| const textEditor = createTextEditor('import * as Foo from "./foo.js";\nexport const bar = 123;', 'src/index.ts'); |
| container.appendChild(textEditor); |
| |
| const lines = textEditor.editor.dom.querySelectorAll('.cm-content > .cm-line'); |
| const line2 = lines[1]; |
| assert.isDefined(line2); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(line2); |
| assert.strictEqual(anchorEl, textEditor.editor.dom); |
| |
| const anchor = Comments.CommentAnchorResolver.resolveCommentAnchor(line2); |
| assert.isNotNull(anchor); |
| assert.strictEqual(anchor?.editor?.lineNumber, 2); |
| assert.strictEqual(anchor?.textSignature, 'export const bar = 123;'); |
| assert.strictEqual(anchor?.editor?.filePath, 'src/index.ts'); |
| }); |
| |
| it('resolves CodeMirror gutter elements to corresponding line element', () => { |
| const textEditor = createTextEditor('const a = 1;\nconst b = 2;'); |
| container.appendChild(textEditor); |
| |
| const gutters = textEditor.editor.dom.querySelectorAll('.cm-lineNumbers .cm-gutterElement'); |
| const g2 = Array.from(gutters).find(g => g.textContent?.trim() === '2'); |
| assert.isDefined(g2); |
| |
| const anchor = Comments.CommentAnchorResolver.resolveCommentAnchor(g2!); |
| assert.isNotNull(anchor); |
| assert.strictEqual(anchor?.editor?.lineNumber, 2); |
| assert.strictEqual(anchor?.textSignature, 'const b = 2;'); |
| }); |
| |
| it('resolves CodeMirror line elements when clicking nested token child elements inside a line', () => { |
| const textEditor = createTextEditor('const x = 42;', 'src/tokens.ts'); |
| container.appendChild(textEditor); |
| |
| const line = textEditor.editor.dom.querySelector('.cm-content > .cm-line'); |
| assert.isNotNull(line); |
| |
| const tokenSpan = document.createElement('span'); |
| tokenSpan.textContent = 'x'; |
| line!.appendChild(tokenSpan); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(tokenSpan); |
| assert.strictEqual(anchorEl, textEditor.editor.dom); |
| |
| const anchor = Comments.CommentAnchorResolver.resolveCommentAnchor(tokenSpan); |
| assert.isNotNull(anchor); |
| assert.strictEqual(anchor?.editor?.lineNumber, 1); |
| assert.strictEqual(anchor?.textSignature, 'const x = 42;'); |
| assert.strictEqual(anchor?.editor?.filePath, 'src/tokens.ts'); |
| }); |
| |
| it('resolves and rematches lines accurately in a large virtualized CodeMirror editor', () => { |
| const linesArray: string[] = []; |
| for (let i = 1; i <= 200; i++) { |
| linesArray.push(`const variable_${i} = ${i};`); |
| } |
| const doc = linesArray.join('\n'); |
| const textEditor = createTextEditor(doc, 'src/large.ts'); |
| container.appendChild(textEditor); |
| |
| // Verify rematching line 150 (outside initial viewport) directly via document model |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'virtualized-line-thread', |
| anchor: { |
| vePath: 'TextField: editor', |
| textSignature: 'const variable_150 = 150;', |
| editor: { |
| filePath: 'src/large.ts', |
| lineNumber: 150, |
| }, |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.strictEqual(rematched, textEditor.editor.dom); |
| }); |
| |
| it('returns null when CodeMirror editor is empty with only whitespace', () => { |
| const textEditor = createTextEditor(' \n '); |
| container.appendChild(textEditor); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(textEditor.editor.dom); |
| assert.isNull(anchorEl); |
| |
| const anchor = Comments.CommentAnchorResolver.resolveCommentAnchor(textEditor.editor.dom); |
| assert.isNull(anchor); |
| }); |
| |
| it('returns null when clicking on an empty/whitespace line in a non-empty CodeMirror editor', () => { |
| const textEditor = createTextEditor('const a = 1;\n\nconst b = 2;'); |
| container.appendChild(textEditor); |
| |
| const lines = textEditor.editor.dom.querySelectorAll('.cm-content > .cm-line'); |
| const emptyLine = lines[1]; |
| assert.isDefined(emptyLine); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(emptyLine); |
| assert.isNull(anchorEl); |
| |
| const anchor = Comments.CommentAnchorResolver.resolveCommentAnchor(emptyLine); |
| assert.isNull(anchor); |
| }); |
| |
| it('returns null when clicking on gutter element without a corresponding document line', () => { |
| const textEditor = createTextEditor('single line'); |
| container.appendChild(textEditor); |
| |
| // Create a standalone detached gutter element to test the out-of-bounds / no-line fallback |
| const gutter = textEditor.editor.dom.querySelector('.cm-lineNumbers'); |
| assert.isNotNull(gutter); |
| const extraGutter = document.createElement('div'); |
| extraGutter.classList.add('cm-gutterElement'); |
| extraGutter.textContent = '99'; |
| gutter!.appendChild(extraGutter); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(extraGutter); |
| assert.isNull(anchorEl); |
| |
| const anchor = Comments.CommentAnchorResolver.resolveCommentAnchor(extraGutter); |
| assert.isNull(anchor); |
| }); |
| |
| it('returns null when clicking on a non-numeric gutter marker', () => { |
| const textEditor = createTextEditor('const a = 100;\nconst b = 200;', 'src/gutters.ts'); |
| container.appendChild(textEditor); |
| |
| const gutters = textEditor.editor.dom.querySelector('.cm-gutters'); |
| assert.isNotNull(gutters); |
| |
| // Create a breakpoint/icon gutter with no numeric text |
| const iconGutter = document.createElement('div'); |
| iconGutter.classList.add('cm-gutterElement'); |
| const icon = document.createElement('span'); |
| icon.classList.add('cm-breakpoint-icon'); |
| iconGutter.appendChild(icon); |
| gutters!.appendChild(iconGutter); |
| |
| const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(icon); |
| assert.isNull(anchorEl); |
| |
| const anchor = Comments.CommentAnchorResolver.resolveCommentAnchor(icon); |
| assert.isNull(anchor); |
| }); |
| |
| it('throws an error in resolveCommentAnchor if CodeMirror EditorView cannot be found for .cm-editor element', |
| () => { |
| const detachedEditor = document.createElement('div'); |
| detachedEditor.classList.add('cm-editor'); |
| detachedEditor.setAttribute('jslog', 'TextField; context: editor'); |
| |
| assert.throws(() => { |
| Comments.CommentAnchorResolver.resolveCommentAnchor(detachedEditor); |
| }, 'Could not find CodeMirror EditorView from .cm-editor element'); |
| }); |
| }); |
| |
| describe('deepQuerySelectorAll', () => { |
| it('returns all matching descendant elements across light and shadow DOM in document order, ignoring root element', |
| () => { |
| const parent = document.createElement('div'); |
| parent.setAttribute('data-test-item', 'item-1'); |
| |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| const shadowChild = document.createElement('div'); |
| shadowChild.setAttribute('data-test-item', 'item-2'); |
| shadow.appendChild(shadowChild); |
| parent.appendChild(host); |
| |
| const sibling = document.createElement('div'); |
| sibling.setAttribute('data-test-item', 'item-3'); |
| parent.appendChild(sibling); |
| |
| container.appendChild(parent); |
| |
| const matched = Comments.CommentAnchorResolver.deepQuerySelectorAll(parent, '[data-test-item]'); |
| assert.deepEqual(matched, [shadowChild, sibling]); |
| }); |
| |
| it('finds matching elements inside the root element\'s own shadowRoot', () => { |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| |
| const shadowChild1 = document.createElement('div'); |
| shadowChild1.setAttribute('data-test-item', 'shadow-1'); |
| shadow.appendChild(shadowChild1); |
| |
| const shadowChild2 = document.createElement('span'); |
| shadowChild2.setAttribute('data-test-item', 'shadow-2'); |
| shadow.appendChild(shadowChild2); |
| |
| container.appendChild(host); |
| |
| const matched = Comments.CommentAnchorResolver.deepQuerySelectorAll(host, '[data-test-item]'); |
| assert.deepEqual(matched, [shadowChild1, shadowChild2]); |
| }); |
| |
| it('finds matching elements inside nested shadow roots when root element itself has a shadowRoot', () => { |
| const rootHost = document.createElement('div'); |
| const rootShadow = rootHost.attachShadow({mode: 'open'}); |
| |
| const nestedHost = document.createElement('div'); |
| const nestedShadow = nestedHost.attachShadow({mode: 'open'}); |
| |
| const nestedChild = document.createElement('div'); |
| nestedChild.setAttribute('data-test-item', 'nested-shadow-item'); |
| nestedShadow.appendChild(nestedChild); |
| |
| rootShadow.appendChild(nestedHost); |
| container.appendChild(rootHost); |
| |
| const matched = Comments.CommentAnchorResolver.deepQuerySelectorAll(rootHost, '[data-test-item]'); |
| assert.deepEqual(matched, [nestedChild]); |
| }); |
| |
| it('preserves document order across root\'s shadowRoot matches and light DOM matches (descendants only)', () => { |
| const host = document.createElement('div'); |
| host.setAttribute('data-test-item', 'root-host'); |
| |
| const shadow = host.attachShadow({mode: 'open'}); |
| const shadowChild = document.createElement('div'); |
| shadowChild.setAttribute('data-test-item', 'shadow-child'); |
| shadow.appendChild(shadowChild); |
| |
| const lightChild = document.createElement('div'); |
| lightChild.setAttribute('data-test-item', 'light-child'); |
| host.appendChild(lightChild); |
| |
| container.appendChild(host); |
| |
| const matched = Comments.CommentAnchorResolver.deepQuerySelectorAll(host, '[data-test-item]'); |
| assert.deepEqual(matched, [shadowChild, lightChild]); |
| }); |
| |
| it('returns an empty array when no elements match, including within shadow roots', () => { |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| const shadowChild = document.createElement('div'); |
| shadow.appendChild(shadowChild); |
| container.appendChild(host); |
| |
| const matched = Comments.CommentAnchorResolver.deepQuerySelectorAll(host, '[data-nonexistent]'); |
| assert.deepEqual(matched, []); |
| }); |
| |
| it('works when searching from Document root across elements containing shadow roots', () => { |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| const shadowChild = document.createElement('div'); |
| shadowChild.setAttribute('data-doc-search', 'found'); |
| shadow.appendChild(shadowChild); |
| container.appendChild(host); |
| |
| const matched = Comments.CommentAnchorResolver.deepQuerySelectorAll(document, '[data-doc-search="found"]'); |
| assert.include(matched, shadowChild); |
| }); |
| |
| it('stops early when limit is reached in deepQuerySelectorAll', () => { |
| const parent = document.createElement('div'); |
| for (let i = 0; i < 5; i++) { |
| const child = document.createElement('div'); |
| child.setAttribute('data-test-item', `item-${i}`); |
| parent.appendChild(child); |
| } |
| container.appendChild(parent); |
| |
| const matched2 = Comments.CommentAnchorResolver.deepQuerySelectorAll(parent, '[data-test-item]', 2); |
| assert.lengthOf(matched2, 2); |
| assert.strictEqual(matched2[0].getAttribute('data-test-item'), 'item-0'); |
| assert.strictEqual(matched2[1].getAttribute('data-test-item'), 'item-1'); |
| |
| const matched0 = Comments.CommentAnchorResolver.deepQuerySelectorAll(parent, '[data-test-item]', 0); |
| assert.deepEqual(matched0, []); |
| |
| const matchedNaN = Comments.CommentAnchorResolver.deepQuerySelectorAll(parent, '[data-test-item]', NaN); |
| assert.deepEqual(matchedNaN, []); |
| }); |
| |
| it('stops early across shadow roots when limit is reached', () => { |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| |
| const shadowChild1 = document.createElement('div'); |
| shadowChild1.setAttribute('data-test-item', 'shadow-1'); |
| shadow.appendChild(shadowChild1); |
| |
| const shadowChild2 = document.createElement('div'); |
| shadowChild2.setAttribute('data-test-item', 'shadow-2'); |
| shadow.appendChild(shadowChild2); |
| |
| const lightChild = document.createElement('div'); |
| lightChild.setAttribute('data-test-item', 'light-1'); |
| host.appendChild(lightChild); |
| |
| container.appendChild(host); |
| |
| const matched = Comments.CommentAnchorResolver.deepQuerySelectorAll(host, '[data-test-item]', 1); |
| assert.deepEqual(matched, [shadowChild1]); |
| }); |
| }); |
| |
| describe('deepQuerySelector', () => { |
| it('ignores root element when root matches selector and returns first matching descendant', () => { |
| const root = document.createElement('div'); |
| root.setAttribute('data-network-request-id', 'req-root'); |
| |
| const child = document.createElement('div'); |
| child.setAttribute('data-network-request-id', 'req-child'); |
| root.appendChild(child); |
| |
| container.appendChild(root); |
| |
| const matched = Comments.CommentAnchorResolver.deepQuerySelector(root, '[data-network-request-id]'); |
| assert.strictEqual(matched, child); |
| }); |
| |
| it('finds element inside root element\'s own shadowRoot', () => { |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| |
| const target = document.createElement('div'); |
| target.setAttribute('data-network-request-id', 'req-in-shadow'); |
| shadow.appendChild(target); |
| |
| container.appendChild(host); |
| |
| const matched = |
| Comments.CommentAnchorResolver.deepQuerySelector(host, '[data-network-request-id="req-in-shadow"]'); |
| assert.strictEqual(matched, target); |
| }); |
| |
| it('finds element inside nested shadow roots within root element\'s shadowRoot', () => { |
| const rootHost = document.createElement('div'); |
| const rootShadow = rootHost.attachShadow({mode: 'open'}); |
| |
| const nestedHost = document.createElement('div'); |
| const nestedShadow = nestedHost.attachShadow({mode: 'open'}); |
| |
| const target = document.createElement('div'); |
| target.setAttribute('data-network-request-id', 'req-deep-nested'); |
| nestedShadow.appendChild(target); |
| |
| rootShadow.appendChild(nestedHost); |
| container.appendChild(rootHost); |
| |
| const matched = |
| Comments.CommentAnchorResolver.deepQuerySelector(rootHost, '[data-network-request-id="req-deep-nested"]'); |
| assert.strictEqual(matched, target); |
| }); |
| |
| it('returns first matching element in document order when root has shadow and light DOM matches', () => { |
| const host = document.createElement('div'); |
| |
| const shadow = host.attachShadow({mode: 'open'}); |
| const shadowTarget = document.createElement('div'); |
| shadowTarget.setAttribute('data-item', 'shadow-first'); |
| shadow.appendChild(shadowTarget); |
| |
| const lightTarget = document.createElement('div'); |
| lightTarget.setAttribute('data-item', 'light-second'); |
| host.appendChild(lightTarget); |
| |
| container.appendChild(host); |
| |
| const matched = Comments.CommentAnchorResolver.deepQuerySelector(host, '[data-item]'); |
| assert.strictEqual(matched, shadowTarget); |
| }); |
| |
| it('returns null when no matching element exists in the tree', () => { |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| const child = document.createElement('div'); |
| shadow.appendChild(child); |
| container.appendChild(host); |
| |
| const matched = Comments.CommentAnchorResolver.deepQuerySelector(host, '[data-missing]'); |
| assert.isNull(matched); |
| }); |
| }); |
| |
| describe('rematchCommentAnchor', () => { |
| it('finds element using primary domain ID fast-path across shadow DOM', () => { |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| |
| const target = document.createElement('div'); |
| target.setAttribute('data-network-request-id', 'req-1234'); |
| target.textContent = 'Request 1234'; |
| shadow.appendChild(target); |
| container.appendChild(host); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'comment-1', |
| anchor: { |
| vePath: 'Panel: network > TreeItem: req', |
| textSignature: 'Request 1234', |
| networkRequestId: 'req-1234', |
| }, |
| comments: [{ |
| author: 'DEVELOPER', |
| text: 'Check this request', |
| timestamp: Date.now(), |
| }], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.strictEqual(rematched, target); |
| }); |
| |
| it('handles special characters in networkRequestId selector with CSS.escape', () => { |
| const target = document.createElement('div'); |
| target.setAttribute('data-network-request-id', 'req"with"quotes.[1]'); |
| target.textContent = 'Special Request'; |
| container.appendChild(target); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'comment-special-id', |
| anchor: { |
| vePath: 'Panel: network > TreeItem: req', |
| textSignature: 'Special Request', |
| networkRequestId: 'req"with"quotes.[1]', |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.strictEqual(rematched, target); |
| }); |
| |
| it('returns null when element with networkRequestId is not found', () => { |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'comment-missing-id', |
| anchor: { |
| vePath: 'Panel: network > TreeItem: req', |
| textSignature: 'Request', |
| networkRequestId: 'non-existent-req', |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.isNull(rematched); |
| }); |
| |
| it('finds element using backendNodeId domain ID', () => { |
| const el = document.createElement('div'); |
| el.setAttribute('data-backend-node-id', '99'); |
| el.textContent = '<button>Submit</button>'; |
| container.appendChild(el); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'comment-dom', |
| anchor: { |
| vePath: 'Panel: elements > TreeItem: node', |
| textSignature: '<button>Submit</button>', |
| backendNodeId: 99, |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.strictEqual(rematched, el); |
| }); |
| |
| it('finds element using VE path and textSignature fallback', () => { |
| const item = document.createElement('div'); |
| item.setAttribute('jslog', 'TreeItem; context: my-rule'); |
| item.textContent = 'color: blue;'; |
| container.appendChild(item); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'comment-2', |
| anchor: { |
| vePath: 'TreeItem: my-rule', |
| textSignature: 'color: blue;', |
| }, |
| comments: [{ |
| author: 'DEVELOPER', |
| text: 'Why blue?', |
| timestamp: Date.now(), |
| }], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.strictEqual(rematched, item); |
| }); |
| |
| it('disambiguates identical text items using siblingIndex', () => { |
| const panel = document.createElement('div'); |
| panel.setAttribute('jslog', 'Panel; context: elements'); |
| |
| const item1 = document.createElement('div'); |
| item1.setAttribute('jslog', 'TreeItem; context: prop'); |
| item1.textContent = 'color: red;'; |
| const item2 = document.createElement('div'); |
| item2.setAttribute('jslog', 'TreeItem; context: prop'); |
| item2.textContent = 'color: red;'; |
| |
| panel.appendChild(item1); |
| panel.appendChild(item2); |
| container.appendChild(panel); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'comment-sibling', |
| anchor: { |
| vePath: 'Panel: elements > TreeItem: prop', |
| textSignature: 'color: red;', |
| siblingIndex: 1, |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.strictEqual(rematched, item2); |
| }); |
| |
| it('disambiguates identical text items inside shadow root using siblingIndex with interspersed nodes', () => { |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| |
| shadow.appendChild(document.createComment('comment-1')); |
| shadow.appendChild(document.createTextNode('\n ')); |
| |
| const item1 = document.createElement('div'); |
| item1.setAttribute('jslog', 'TreeItem; context: shadow-prop'); |
| item1.textContent = 'font-size: 14px;'; |
| shadow.appendChild(item1); |
| |
| shadow.appendChild(document.createTextNode('\n ')); |
| shadow.appendChild(document.createComment('comment-2')); |
| |
| const item2 = document.createElement('div'); |
| item2.setAttribute('jslog', 'TreeItem; context: shadow-prop'); |
| item2.textContent = 'font-size: 14px;'; |
| shadow.appendChild(item2); |
| |
| container.appendChild(host); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'comment-shadow-sibling', |
| anchor: { |
| vePath: 'TreeItem: shadow-prop', |
| textSignature: 'font-size: 14px;', |
| siblingIndex: 1, |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.strictEqual(rematched, item2); |
| }); |
| |
| it('scopes CodeMirror editor re-attachment to the panel encoded in vePath', () => { |
| const sourcesPanel = document.createElement('div'); |
| sourcesPanel.setAttribute('jslog', 'Panel; context: sources'); |
| const sourcesEditor = createTextEditor('const x = 10;'); |
| sourcesPanel.appendChild(sourcesEditor); |
| container.appendChild(sourcesPanel); |
| |
| const consolePanel = document.createElement('div'); |
| consolePanel.setAttribute('jslog', 'Panel; context: console'); |
| const consoleEditor = createTextEditor('const x = 10;'); |
| consolePanel.appendChild(consoleEditor); |
| container.appendChild(consolePanel); |
| |
| const line = sourcesEditor.editor.dom.querySelector('.cm-content > .cm-line'); |
| assert.isNotNull(line); |
| |
| const anchor = Comments.CommentAnchorResolver.resolveCommentAnchor(line!); |
| assert.isNotNull(anchor); |
| assert.strictEqual(anchor?.vePath, 'Panel: sources > TextField: editor'); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'cm-thread', |
| anchor: anchor!, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.strictEqual(rematched, sourcesEditor.editor.dom); |
| }); |
| |
| it('rematches CodeMirror editor matching editorFilePath among multiple open editors', () => { |
| const editor1 = createTextEditor('const a = 1;', 'src/fileA.ts'); |
| container.appendChild(editor1); |
| |
| const editor2 = createTextEditor('const a = 1;', 'src/fileB.ts'); |
| container.appendChild(editor2); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'file-b-thread', |
| anchor: { |
| vePath: 'TextField: editor', |
| textSignature: 'const a = 1;', |
| editor: { |
| filePath: 'src/fileB.ts', |
| lineNumber: 1, |
| }, |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.strictEqual(rematched, editor2.editor.dom); |
| }); |
| |
| it('returns null when no open editor matches editor.filePath', () => { |
| const editor = createTextEditor('const a = 1;', 'src/foo.ts'); |
| container.appendChild(editor); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'missing-file-thread', |
| anchor: { |
| vePath: 'TextField: editor', |
| textSignature: 'const a = 1;', |
| editor: { |
| filePath: 'src/nonexistent.ts', |
| lineNumber: 1, |
| }, |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.isNull(rematched); |
| }); |
| |
| it('rematches CodeMirror editor by lineNumber even when line text content has changed', () => { |
| const editor = createTextEditor('const modified = 999;', 'src/app.ts'); |
| container.appendChild(editor); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'modified-line-thread', |
| anchor: { |
| vePath: 'TextField: editor', |
| textSignature: 'const original = 1;', |
| editor: { |
| filePath: 'src/app.ts', |
| lineNumber: 1, |
| }, |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.strictEqual(rematched, editor.editor.dom); |
| }); |
| |
| it('rematches CodeMirror editor using cachedJslogElements when provided', () => { |
| const editor = createTextEditor('const cached = 42;', 'src/cached.ts'); |
| container.appendChild(editor); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'cached-editor-thread', |
| anchor: { |
| vePath: 'TextField: editor', |
| textSignature: 'const cached = 42;', |
| editor: { |
| filePath: 'src/cached.ts', |
| lineNumber: 1, |
| }, |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const cachedElements = Comments.CommentAnchorResolver.deepQuerySelectorAll(container, '[jslog]'); |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container, cachedElements); |
| assert.strictEqual(rematched, editor.editor.dom); |
| }); |
| |
| it('disambiguates identical items across multiple parent containers using parentTextSignature and siblingIndex', |
| () => { |
| const parentA = document.createElement('div'); |
| parentA.setAttribute('jslog', 'Pane; context: sectionA'); |
| const itemA1 = document.createElement('div'); |
| itemA1.setAttribute('jslog', 'TreeItem; context: item'); |
| itemA1.textContent = 'value'; |
| const itemA2 = document.createElement('div'); |
| itemA2.setAttribute('jslog', 'TreeItem; context: item'); |
| itemA2.textContent = 'value'; |
| parentA.appendChild(itemA1); |
| parentA.appendChild(itemA2); |
| |
| const parentB = document.createElement('div'); |
| parentB.setAttribute('jslog', 'Pane; context: sectionB'); |
| const itemB1 = document.createElement('div'); |
| itemB1.setAttribute('jslog', 'TreeItem; context: item'); |
| itemB1.textContent = 'value'; |
| const itemB2 = document.createElement('div'); |
| itemB2.setAttribute('jslog', 'TreeItem; context: item'); |
| itemB2.textContent = 'value'; |
| parentB.appendChild(itemB1); |
| parentB.appendChild(itemB2); |
| |
| container.appendChild(parentA); |
| container.appendChild(parentB); |
| |
| const anchorB2 = Comments.CommentAnchorResolver.resolveCommentAnchor(itemB2); |
| assert.isNotNull(anchorB2); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'comment-b2', |
| anchor: anchorB2!, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.strictEqual(rematched, itemB2); |
| }); |
| |
| it('disambiguates identical items using siblingIndex even if text content has changed', () => { |
| const panel = document.createElement('div'); |
| panel.setAttribute('jslog', 'Panel; context: elements'); |
| |
| const item1 = document.createElement('div'); |
| item1.setAttribute('jslog', 'TreeItem; context: rule'); |
| item1.textContent = 'color: green;'; // Text modified from red |
| const item2 = document.createElement('div'); |
| item2.setAttribute('jslog', 'TreeItem; context: rule'); |
| item2.textContent = 'color: yellow;'; // Text modified from red |
| |
| panel.appendChild(item1); |
| panel.appendChild(item2); |
| container.appendChild(panel); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'comment-modified-text', |
| anchor: { |
| vePath: 'Panel: elements > TreeItem: rule', |
| textSignature: 'color: red;', // Old text |
| siblingIndex: 1, |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.strictEqual(rematched, item2); |
| }); |
| |
| it('correctly rematches using siblingIndex when preceding siblings have different text and text has changed', |
| () => { |
| const panel = document.createElement('div'); |
| panel.setAttribute('jslog', 'Panel; context: elements'); |
| |
| const item0 = document.createElement('div'); |
| item0.setAttribute('jslog', 'TreeItem; context: prop'); |
| item0.textContent = 'margin: 0;'; |
| |
| const item1 = document.createElement('div'); |
| item1.setAttribute('jslog', 'TreeItem; context: prop'); |
| item1.textContent = 'padding: 0;'; |
| |
| const item2 = document.createElement('div'); |
| item2.setAttribute('jslog', 'TreeItem; context: prop'); |
| item2.textContent = 'padding: 0;'; |
| |
| panel.appendChild(item0); |
| panel.appendChild(item1); |
| panel.appendChild(item2); |
| container.appendChild(panel); |
| |
| // Create anchor for item2 (which is at siblingIndex 2 among TreeItem siblings) |
| const anchor2 = Comments.CommentAnchorResolver.resolveCommentAnchor(item2); |
| assert.isNotNull(anchor2); |
| assert.strictEqual(anchor2?.siblingIndex, 2); |
| |
| // Simulate text modification in DevTools |
| item1.textContent = 'padding: 5px;'; |
| item2.textContent = 'padding: 10px;'; |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'comment-text-changed', |
| anchor: anchor2!, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.strictEqual(rematched, item2); |
| }); |
| |
| it('avoids VE prefix collisions during fast filtering', () => { |
| const tree = document.createElement('div'); |
| tree.setAttribute('jslog', 'Tree; context: tree-root'); |
| tree.textContent = 'Tree root'; |
| container.appendChild(tree); |
| |
| const treeItem = document.createElement('div'); |
| treeItem.setAttribute('jslog', 'TreeItem; context: tree-item'); |
| treeItem.textContent = 'Tree item'; |
| container.appendChild(treeItem); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'comment-tree', |
| anchor: { |
| vePath: 'Tree: tree-root', |
| textSignature: 'Tree root', |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.strictEqual(rematched, tree); |
| }); |
| |
| it('matches elements with leading whitespace in jslog', () => { |
| const el = document.createElement('div'); |
| el.setAttribute('jslog', ' TreeItem; context: spaced'); |
| el.textContent = 'Spaced item'; |
| container.appendChild(el); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'comment-spaced', |
| anchor: { |
| vePath: 'TreeItem: spaced', |
| textSignature: 'Spaced item', |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.strictEqual(rematched, el); |
| }); |
| |
| it('finds element when root passed to rematchCommentAnchor has a shadowRoot (domain ID path)', () => { |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| |
| const target = document.createElement('div'); |
| target.setAttribute('data-network-request-id', 'req-root-shadow'); |
| target.textContent = 'Shadow Request'; |
| shadow.appendChild(target); |
| container.appendChild(host); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'comment-root-shadow-req', |
| anchor: { |
| vePath: 'Panel: network > TreeItem: req', |
| textSignature: 'Shadow Request', |
| networkRequestId: 'req-root-shadow', |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, host); |
| assert.strictEqual(rematched, target); |
| }); |
| |
| it('finds element when root passed to rematchCommentAnchor has a shadowRoot (VE fallback path)', () => { |
| const host = document.createElement('div'); |
| const shadow = host.attachShadow({mode: 'open'}); |
| |
| const target = document.createElement('div'); |
| target.setAttribute('jslog', 'TreeItem; context: root-shadow-item'); |
| target.textContent = 'Shadow Item'; |
| shadow.appendChild(target); |
| container.appendChild(host); |
| |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'comment-root-shadow-ve', |
| anchor: { |
| vePath: 'TreeItem: root-shadow-item', |
| textSignature: 'Shadow Item', |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, host); |
| assert.strictEqual(rematched, target); |
| }); |
| |
| it('returns null when no matching candidate exists in the root', () => { |
| const thread: Comments.CommentAnchorResolver.CommentThread = { |
| id: 'missing', |
| anchor: { |
| vePath: 'Panel: elements > TreeItem: deleted-node', |
| textSignature: 'non-existent', |
| }, |
| comments: [], |
| status: 'ACTIVE', |
| }; |
| |
| const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container); |
| assert.isNull(rematched); |
| }); |
| }); |
| |
| describe('isElementVisible', () => { |
| it('returns false for disconnected elements, zero-sized elements, hidden elements, and true for visible elements', |
| () => { |
| const disconnected = document.createElement('div'); |
| disconnected.textContent = 'Disconnected'; |
| assert.isFalse(Comments.CommentAnchorResolver.isElementVisible(disconnected)); |
| |
| const zeroSize = document.createElement('div'); |
| zeroSize.textContent = 'Zero'; |
| zeroSize.getBoundingClientRect = () => new DOMRect(0, 0, 0, 0); |
| container.appendChild(zeroSize); |
| assert.isFalse(Comments.CommentAnchorResolver.isElementVisible(zeroSize)); |
| |
| const hiddenEl = document.createElement('div'); |
| hiddenEl.style.display = 'none'; |
| container.appendChild(hiddenEl); |
| assert.isFalse(Comments.CommentAnchorResolver.isElementVisible(hiddenEl)); |
| |
| const visibleEl = document.createElement('div'); |
| visibleEl.textContent = 'Visible'; |
| visibleEl.getBoundingClientRect = () => new DOMRect(10, 10, 100, 30); |
| container.appendChild(visibleEl); |
| assert.isTrue(Comments.CommentAnchorResolver.isElementVisible(visibleEl)); |
| }); |
| }); |
| }); |