blob: d09c7b00239d63edfc628161412f6c696316c88e [file]
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {assert} from 'chai';
import {afterEach, beforeEach, describe, it} from 'mocha';
import sinon from 'sinon';
import * as CommentManager from '../../models/comment_manager/comment_manager.js';
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('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 anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(tabHeader);
assert.isNull(anchorEl);
});
it('excludes top-level Panel from commenting', () => {
const panel = document.createElement('div');
panel.setAttribute('jslog', 'Panel; context: sources');
panel.textContent = 'Sources Panel Content';
container.appendChild(panel);
const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(panel);
assert.isNull(anchorEl);
});
it('returns null when clicking an unanchored child element or background within a Panel', () => {
const panel = document.createElement('div');
panel.setAttribute('jslog', 'Panel; context: network');
const bgWrapper = document.createElement('div');
bgWrapper.classList.add('vbox', 'flex-auto');
const unanchoredElement = document.createElement('div');
unanchoredElement.classList.add('panel-background');
unanchoredElement.textContent = 'Some nested background text';
bgWrapper.appendChild(unanchoredElement);
panel.appendChild(bgWrapper);
container.appendChild(panel);
const anchorEl = Comments.CommentAnchorResolver.resolveCommentAnchorElement(unanchoredElement);
assert.isNull(anchorEl);
});
it('excludes elements inside a disallowed ancestor container (e.g. Popover) from commenting', () => {
const popover = document.createElement('div');
popover.setAttribute('jslog', 'Popover; context: comment-thread');
const link = document.createElement('span');
link.setAttribute('data-backend-node-id', '101');
link.textContent = 'div#my-element';
popover.appendChild(link);
container.appendChild(popover);
assert.isNull(Comments.CommentAnchorResolver.resolveCommentAnchorElement(link));
});
it('excludes standalone disallowed minor controls from commenting', () => {
const link = document.createElement('a');
link.setAttribute('jslog', 'Link; context: docs');
link.textContent = 'Learn more';
container.appendChild(link);
assert.isNull(Comments.CommentAnchorResolver.resolveCommentAnchorElement(link));
});
it('excludes elements inside a Dialog (e.g. Settings or Command Menu across shadow DOM) from commenting', () => {
const dialog = document.createElement('div');
dialog.setAttribute('jslog', 'Dialog; context: settings; track: resize, keydown: Escape');
const host = document.createElement('div');
const shadow = host.attachShadow({mode: 'open'});
const treeItem = document.createElement('div');
treeItem.setAttribute('jslog', 'TreeItem; context: setting-item');
treeItem.textContent = 'Enable setting';
shadow.appendChild(treeItem);
dialog.appendChild(host);
container.appendChild(dialog);
assert.isTrue(Comments.CommentAnchorResolver.hasDisallowedCommentAncestor(treeItem));
assert.isNull(Comments.CommentAnchorResolver.resolveCommentAnchorElement(treeItem));
assert.isNull(Comments.CommentAnchorResolver.resolveCommentAnchorElement(dialog));
});
it('excludes elements inside .comments-overlay-container (e.g. comment pins and popups) from commenting', () => {
const overlayContainer = document.createElement('div');
overlayContainer.classList.add('comments-overlay-container');
const pin = document.createElement('div');
pin.classList.add('comment-pin');
overlayContainer.appendChild(pin);
container.appendChild(overlayContainer);
assert.isTrue(Comments.CommentAnchorResolver.hasDisallowedCommentAncestor(pin));
assert.isNull(Comments.CommentAnchorResolver.resolveCommentAnchorElement(pin));
});
});
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('does not capture node if data-target-id is missing', () => {
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.isUndefined(anchor?.node);
});
it('captures targetId along with 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');
ancestor.setAttribute('data-target-id', 'target-456');
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.deepEqual(anchor?.node, {backendNodeId: 101, targetId: 'target-456'});
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'TextField: editor',
textSignature: 'const variable_150 = 150;',
editor: {
filePath: 'src/large.ts',
lineNumber: 150,
},
},
comments: [],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'Panel: network > TreeItem: req',
textSignature: 'Request 1234',
networkRequestId: 'req-1234',
},
comments: [{
author: 'DEVELOPER',
text: 'Check this request',
timestamp: Date.now(),
}],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'Panel: network > TreeItem: req',
textSignature: 'Special Request',
networkRequestId: 'req"with"quotes.[1]',
},
comments: [],
});
const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container);
assert.strictEqual(rematched, target);
});
it('returns null when element with networkRequestId is not found', () => {
const thread = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'Panel: network > TreeItem: req',
textSignature: 'Request',
networkRequestId: 'non-existent-req',
},
comments: [],
});
const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container);
assert.isNull(rematched);
});
it('finds element using backendNodeId and targetId domain IDs', () => {
const el = document.createElement('div');
el.setAttribute('data-backend-node-id', '99');
el.setAttribute('data-target-id', 'target-1');
el.textContent = '<button>Submit</button>';
container.appendChild(el);
const thread = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'Panel: elements > TreeItem: node',
textSignature: '<button>Submit</button>',
node: {
backendNodeId: 99,
targetId: 'target-1',
},
},
comments: [],
});
const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container);
assert.strictEqual(rematched, el);
});
it('disambiguates elements sharing same backendNodeId with different targetIds', () => {
const el1 = document.createElement('div');
el1.setAttribute('data-backend-node-id', '99');
el1.setAttribute('data-target-id', 'target-1');
el1.textContent = '<button>Submit</button>';
container.appendChild(el1);
const el2 = document.createElement('div');
el2.setAttribute('data-backend-node-id', '99');
el2.setAttribute('data-target-id', 'target-2');
el2.textContent = '<button>Submit</button>';
container.appendChild(el2);
const thread = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'Panel: elements > TreeItem: node',
textSignature: '<button>Submit</button>',
node: {
backendNodeId: 99,
targetId: 'target-2',
},
},
comments: [],
});
const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, container);
assert.strictEqual(rematched, el2);
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'TreeItem: my-rule',
textSignature: 'color: blue;',
},
comments: [{
author: 'DEVELOPER',
text: 'Why blue?',
timestamp: Date.now(),
}],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'Panel: elements > TreeItem: prop',
textSignature: 'color: red;',
siblingIndex: 1,
},
comments: [],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'TreeItem: shadow-prop',
textSignature: 'font-size: 14px;',
siblingIndex: 1,
},
comments: [],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: anchor!,
comments: [],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'TextField: editor',
textSignature: 'const a = 1;',
editor: {
filePath: 'src/fileB.ts',
lineNumber: 1,
},
},
comments: [],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'TextField: editor',
textSignature: 'const a = 1;',
editor: {
filePath: 'src/nonexistent.ts',
lineNumber: 1,
},
},
comments: [],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'TextField: editor',
textSignature: 'const original = 1;',
editor: {
filePath: 'src/app.ts',
lineNumber: 1,
},
},
comments: [],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'TextField: editor',
textSignature: 'const cached = 42;',
editor: {
filePath: 'src/cached.ts',
lineNumber: 1,
},
},
comments: [],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: anchorB2!,
comments: [],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'Panel: elements > TreeItem: rule',
textSignature: 'color: red;', // Old text
siblingIndex: 1,
},
comments: [],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: anchor2!,
comments: [],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'Tree: tree-root',
textSignature: 'Tree root',
},
comments: [],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'TreeItem: spaced',
textSignature: 'Spaced item',
},
comments: [],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'Panel: network > TreeItem: req',
textSignature: 'Shadow Request',
networkRequestId: 'req-root-shadow',
},
comments: [],
});
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 = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'TreeItem: root-shadow-item',
textSignature: 'Shadow Item',
},
comments: [],
});
const rematched = Comments.CommentAnchorResolver.rematchCommentAnchor(thread, host);
assert.strictEqual(rematched, target);
});
it('returns null when no matching candidate exists in the root', () => {
const thread = new CommentManager.CommentThread.CommentThread({
anchor: {
vePath: 'Panel: elements > TreeItem: deleted-node',
textSignature: 'non-existent',
},
comments: [],
});
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));
});
});
describe('computeVisibleRect', () => {
it('returns null for disconnected, hidden, or zero-sized elements', () => {
const disconnected = document.createElement('div');
assert.isNull(Comments.CommentAnchorResolver.computeVisibleRect(disconnected));
const hiddenEl = document.createElement('div');
hiddenEl.style.display = 'none';
container.appendChild(hiddenEl);
assert.isNull(Comments.CommentAnchorResolver.computeVisibleRect(hiddenEl));
const zeroSize = document.createElement('div');
zeroSize.getBoundingClientRect = () => new DOMRect(0, 0, 0, 0);
container.appendChild(zeroSize);
assert.isNull(Comments.CommentAnchorResolver.computeVisibleRect(zeroSize));
});
it('returns unclipped rect when element is inside unconstrained container', () => {
const el = document.createElement('div');
el.textContent = 'Unclipped';
el.getBoundingClientRect = () => new DOMRect(50, 100, 200, 60);
container.appendChild(el);
const rect = Comments.CommentAnchorResolver.computeVisibleRect(el);
assert.isNotNull(rect);
assert.strictEqual(rect?.left, 50);
assert.strictEqual(rect?.top, 100);
assert.strictEqual(rect?.width, 200);
assert.strictEqual(rect?.height, 60);
assert.strictEqual(rect?.right, 250);
assert.strictEqual(rect?.bottom, 160);
});
it('clips element rect to ancestor overflow: hidden / scroll container bounds', () => {
const scrollParent = document.createElement('div');
scrollParent.style.overflow = 'hidden';
scrollParent.style.position = 'relative';
scrollParent.getBoundingClientRect = () => new DOMRect(0, 50, 300, 100);
container.appendChild(scrollParent);
const child = document.createElement('div');
child.textContent = 'Partially visible child';
// Element starts at top: 30 (above parent top: 50) and extends to top: 120 (bottom: 120, inside parent bottom: 150)
child.getBoundingClientRect = () => new DOMRect(20, 30, 200, 90);
scrollParent.appendChild(child);
const rect = Comments.CommentAnchorResolver.computeVisibleRect(child);
assert.isNotNull(rect);
assert.strictEqual(rect?.top, 50);
assert.strictEqual(rect?.bottom, 120);
assert.strictEqual(rect?.height, 70);
assert.strictEqual(rect?.left, 20);
assert.strictEqual(rect?.right, 220);
assert.strictEqual(rect?.width, 200);
});
it('clips element rect extending past bottom of overflow container', () => {
const scrollParent = document.createElement('div');
scrollParent.style.overflowY = 'auto';
scrollParent.getBoundingClientRect = () => new DOMRect(0, 0, 400, 200);
container.appendChild(scrollParent);
const child = document.createElement('div');
child.textContent = 'Bottom overflowing child';
// Element starts at top: 150 and extends to bottom: 300 (past parent bottom: 200)
child.getBoundingClientRect = () => new DOMRect(10, 150, 100, 150);
scrollParent.appendChild(child);
const rect = Comments.CommentAnchorResolver.computeVisibleRect(child);
assert.isNotNull(rect);
assert.strictEqual(rect?.top, 150);
assert.strictEqual(rect?.bottom, 200);
assert.strictEqual(rect?.height, 50);
assert.strictEqual(rect?.left, 10);
assert.strictEqual(rect?.width, 100);
});
it('returns null when element is completely scrolled out of overflow container', () => {
const scrollParent = document.createElement('div');
scrollParent.style.overflow = 'hidden';
scrollParent.getBoundingClientRect = () => new DOMRect(0, 100, 400, 200);
container.appendChild(scrollParent);
const childScrolledAbove = document.createElement('div');
childScrolledAbove.textContent = 'Scrolled above';
childScrolledAbove.getBoundingClientRect = () => new DOMRect(0, 10, 100, 50);
scrollParent.appendChild(childScrolledAbove);
assert.isNull(Comments.CommentAnchorResolver.computeVisibleRect(childScrolledAbove));
const childScrolledBelow = document.createElement('div');
childScrolledBelow.textContent = 'Scrolled below';
childScrolledBelow.getBoundingClientRect = () => new DOMRect(0, 350, 100, 50);
scrollParent.appendChild(childScrolledBelow);
assert.isNull(Comments.CommentAnchorResolver.computeVisibleRect(childScrolledBelow));
});
it('clips correctly across Shadow DOM boundaries', () => {
const scrollParent = document.createElement('div');
scrollParent.style.overflow = 'hidden';
scrollParent.getBoundingClientRect = () => new DOMRect(0, 0, 300, 150);
container.appendChild(scrollParent);
const host = document.createElement('div');
const shadow = host.attachShadow({mode: 'open'});
scrollParent.appendChild(host);
const innerChild = document.createElement('div');
innerChild.textContent = 'Shadow child';
innerChild.getBoundingClientRect = () => new DOMRect(0, 100, 400, 100);
shadow.appendChild(innerChild);
const rect = Comments.CommentAnchorResolver.computeVisibleRect(innerChild);
assert.isNotNull(rect);
assert.strictEqual(rect?.top, 100);
assert.strictEqual(rect?.bottom, 150);
assert.strictEqual(rect?.height, 50);
assert.strictEqual(rect?.left, 0);
assert.strictEqual(rect?.right, 300);
assert.strictEqual(rect?.width, 300);
});
it('supports custom targetRect parameter', () => {
const el = document.createElement('div');
container.appendChild(el);
const customRect = new DOMRect(15, 25, 80, 40);
const rect = Comments.CommentAnchorResolver.computeVisibleRect(el, customRect);
assert.isNotNull(rect);
assert.strictEqual(rect?.top, 25);
assert.strictEqual(rect?.left, 15);
assert.strictEqual(rect?.width, 80);
assert.strictEqual(rect?.height, 40);
});
it('caches clipping ancestors across calls and deduplicates ancestor rects via rectCache', () => {
const scrollContainer = document.createElement('div');
scrollContainer.style.overflow = 'auto';
scrollContainer.getBoundingClientRect = sinon.stub().returns(new DOMRect(0, 0, 200, 200));
container.appendChild(scrollContainer);
const el = document.createElement('div');
el.textContent = 'Cached item';
el.getBoundingClientRect = () => new DOMRect(10, 10, 100, 30);
scrollContainer.appendChild(el);
const getComputedStyleSpy = sinon.spy(window, 'getComputedStyle');
try {
const rectCache = new Map<Element, DOMRect>();
const rect1 = Comments.CommentAnchorResolver.computeVisibleRect(el, undefined, rectCache);
assert.isNotNull(rect1);
const initialComputedStyleCalls = getComputedStyleSpy.callCount;
assert.isAbove(initialComputedStyleCalls, 0);
// Second call for the same element with the same rectCache should not call getComputedStyle
// or re-query scrollContainer.getBoundingClientRect()
const rect2 = Comments.CommentAnchorResolver.computeVisibleRect(el, undefined, rectCache);
assert.isNotNull(rect2);
sinon.assert.callCount(getComputedStyleSpy, initialComputedStyleCalls);
sinon.assert.calledOnce(scrollContainer.getBoundingClientRect as sinon.SinonStub);
// Clearing the cache should re-evaluate getComputedStyle on next call
Comments.CommentAnchorResolver.clearClippingAncestorsCache();
Comments.CommentAnchorResolver.computeVisibleRect(el);
assert.isAbove(getComputedStyleSpy.callCount, initialComputedStyleCalls);
} finally {
getComputedStyleSpy.restore();
}
});
describe('sticky occluders', () => {
beforeEach(() => {
Comments.CommentAnchorResolver.setStickyOccluders(null);
});
afterEach(() => {
Comments.CommentAnchorResolver.setStickyOccluders(null);
});
function markOccluder(element: Element): void {
element.setAttribute(Comments.CommentAnchorResolver.STICKY_OCCLUDER_ATTRIBUTE, '');
}
function createScroller(rect: DOMRect, parent: Node = container): HTMLDivElement {
const scroller = document.createElement('div');
scroller.style.overflow = 'hidden auto';
scroller.getBoundingClientRect = () => rect;
parent.appendChild(scroller);
return scroller;
}
function createChild(parent: Node, text: string, rect: DOMRect): HTMLDivElement {
const child = document.createElement('div');
child.textContent = text;
child.getBoundingClientRect = () => rect;
parent.appendChild(child);
return child;
}
it('trims a row partly under a sticky header', () => {
const scroller = createScroller(new DOMRect(0, 0, 300, 200));
const header = createChild(scroller, 'Header', new DOMRect(0, 0, 300, 20));
markOccluder(header);
const row = createChild(scroller, 'Row', new DOMRect(0, 10, 300, 20));
const rect = Comments.CommentAnchorResolver.computeVisibleRect(row);
assert.isNotNull(rect);
assert.strictEqual(rect?.top, 20);
assert.strictEqual(rect?.bottom, 30);
assert.strictEqual(rect?.height, 10);
});
it('returns null for a row fully under a sticky header', () => {
const scroller = createScroller(new DOMRect(0, 0, 300, 200));
const header = createChild(scroller, 'Header', new DOMRect(0, 0, 300, 20));
markOccluder(header);
const row = createChild(scroller, 'Row', new DOMRect(0, 2, 300, 16));
assert.isNull(Comments.CommentAnchorResolver.computeVisibleRect(row));
});
it('does not trim an anchor inside the sticky header', () => {
const scroller = createScroller(new DOMRect(0, 0, 300, 200));
const header = createChild(scroller, '', new DOMRect(0, 0, 300, 20));
markOccluder(header);
const headerCell = createChild(header, 'Name', new DOMRect(0, 0, 100, 20));
const rect = Comments.CommentAnchorResolver.computeVisibleRect(headerCell);
assert.isNotNull(rect);
assert.strictEqual(rect?.top, 0);
assert.strictEqual(rect?.height, 20);
});
it('uses the current header height when it changes', () => {
const scroller = createScroller(new DOMRect(0, 0, 300, 200));
const header = createChild(scroller, 'Header', new DOMRect(0, 0, 300, 20));
markOccluder(header);
const row = createChild(scroller, 'Row', new DOMRect(0, 10, 300, 40));
assert.strictEqual(Comments.CommentAnchorResolver.computeVisibleRect(row)?.top, 20);
header.getBoundingClientRect = () => new DOMRect(0, 0, 300, 35);
assert.strictEqual(Comments.CommentAnchorResolver.computeVisibleRect(row)?.top, 35);
});
it('ignores an occluder that sticks to a different scroller', () => {
const scrollerA = createScroller(new DOMRect(0, 0, 300, 200));
const scrollerB = createScroller(new DOMRect(0, 0, 300, 200));
const headerB = createChild(scrollerB, 'Header B', new DOMRect(0, 0, 300, 20));
markOccluder(headerB);
const rowA = createChild(scrollerA, 'Row A', new DOMRect(0, 10, 300, 20));
const rect = Comments.CommentAnchorResolver.computeVisibleRect(rowA);
assert.isNotNull(rect);
assert.strictEqual(rect?.top, 10);
assert.strictEqual(rect?.height, 20);
});
it('trims against an occluder inside a shadow root whose scroller is outside it', () => {
const scroller = createScroller(new DOMRect(0, 0, 300, 200));
const host = document.createElement('div');
scroller.appendChild(host);
const shadow = host.attachShadow({mode: 'open'});
const header = createChild(shadow, 'Header', new DOMRect(0, 0, 300, 20));
markOccluder(header);
const row = createChild(shadow, 'Row', new DOMRect(0, 10, 300, 20));
const rect = Comments.CommentAnchorResolver.computeVisibleRect(row);
assert.isNotNull(rect);
assert.strictEqual(rect?.top, 20);
assert.strictEqual(rect?.height, 10);
});
it('stops trimming after the marker attribute is removed', () => {
const scroller = createScroller(new DOMRect(0, 0, 300, 200));
const header = createChild(scroller, 'Header', new DOMRect(0, 0, 300, 20));
markOccluder(header);
const row = createChild(scroller, 'Row', new DOMRect(0, 10, 300, 20));
assert.strictEqual(Comments.CommentAnchorResolver.computeVisibleRect(row)?.top, 20);
header.removeAttribute(Comments.CommentAnchorResolver.STICKY_OCCLUDER_ATTRIBUTE);
Comments.CommentAnchorResolver.clearClippingAncestorsCache();
assert.strictEqual(Comments.CommentAnchorResolver.computeVisibleRect(row)?.top, 10);
});
it('ignores an occluder that sticks to a nested scroller', () => {
const outer = createScroller(new DOMRect(0, 0, 300, 200));
const inner = createScroller(new DOMRect(0, 100, 300, 100), outer);
const innerHeader = createChild(inner, 'Inner header', new DOMRect(0, 0, 300, 20));
markOccluder(innerHeader);
const outerRow = createChild(outer, 'Outer row', new DOMRect(0, 10, 300, 20));
const rect = Comments.CommentAnchorResolver.computeVisibleRect(outerRow);
assert.isNotNull(rect);
assert.strictEqual(rect?.top, 10);
assert.strictEqual(rect?.height, 20);
});
it('uses the provided occluder list instead of scanning the DOM', () => {
const scroller = createScroller(new DOMRect(0, 0, 300, 200));
const markedHeader = createChild(scroller, 'Marked header', new DOMRect(0, 0, 300, 20));
markOccluder(markedHeader);
const providedHeader = createChild(scroller, 'Provided header', new DOMRect(0, 0, 300, 30));
const row = createChild(scroller, 'Row', new DOMRect(0, 10, 300, 40));
Comments.CommentAnchorResolver.setStickyOccluders([providedHeader]);
assert.strictEqual(Comments.CommentAnchorResolver.computeVisibleRect(row)?.top, 30);
Comments.CommentAnchorResolver.setStickyOccluders(null);
assert.strictEqual(Comments.CommentAnchorResolver.computeVisibleRect(row)?.top, 20);
});
it('skips provided occluders that are no longer connected', () => {
const scroller = createScroller(new DOMRect(0, 0, 300, 200));
const header = createChild(scroller, 'Header', new DOMRect(0, 0, 300, 20));
const row = createChild(scroller, 'Row', new DOMRect(0, 10, 300, 20));
Comments.CommentAnchorResolver.setStickyOccluders([header]);
header.remove();
assert.strictEqual(Comments.CommentAnchorResolver.computeVisibleRect(row)?.top, 10);
});
});
});
describe('CustomAnchorResolver integration', () => {
let customElement: HTMLDivElement;
let customResolver: Comments.CommentAnchorResolver.CustomAnchorResolver;
beforeEach(() => {
customElement = document.createElement('div');
customElement.classList.add('custom-canvas-target');
container.appendChild(customElement);
customResolver = {
getAnchorElement(): Element |
null {
return null;
},
matches(element: Element): boolean {
return element.classList.contains('custom-canvas-target') ||
Boolean(element.closest('.custom-canvas-target'));
},
resolve(element: Element, options?: {clientX: number, clientY: number}):
Comments.CommentAnchorResolver.CustomAnchorResult |
null {
if (!this.matches(element)) {
return null;
}
return {
anchor: {
vePath: 'Panel: custom > Canvas: main',
textSignature: 'Custom Event',
timeline: {
traceId: 'trace-1',
traceEventKey: 'custom-event-key',
entryName: 'Custom Event',
startTimeMicro: (options?.clientX ?? 10) * 1000,
chartLocation: 'main',
},
},
anchorElement: customElement,
highlightRect: {
top: options?.clientY ?? 50,
left: options?.clientX ?? 50,
width: 100,
height: 20,
visible: true,
},
};
},
};
Comments.CommentAnchorResolver.registerCustomAnchorResolver(customResolver);
});
afterEach(() => {
Comments.CommentAnchorResolver.clearCustomAnchorResolversForTest();
});
it('identifies custom resolver for matching element', () => {
const resolver = Comments.CommentAnchorResolver.getCustomAnchorResolverForElement(customElement);
assert.strictEqual(resolver, customResolver);
const otherDiv = document.createElement('div');
assert.isNull(Comments.CommentAnchorResolver.getCustomAnchorResolverForElement(otherDiv));
});
it('delegates resolveCommentAnchorElement to custom resolver', () => {
const anchorEl =
Comments.CommentAnchorResolver.resolveCommentAnchorElement(customElement, {clientX: 20, clientY: 40});
assert.strictEqual(anchorEl, customElement);
});
it('delegates resolveCommentAnchor to custom resolver with coordinates', () => {
const anchor =
Comments.CommentAnchorResolver.resolveCommentAnchor(customElement, document, {clientX: 35, clientY: 70});
assert.isNotNull(anchor);
assert.strictEqual(anchor?.vePath, 'Panel: custom > Canvas: main');
assert.strictEqual(anchor?.timeline?.traceId, 'trace-1');
assert.strictEqual(anchor?.timeline?.startTimeMicro, 35000);
});
it('restores default behavior when custom resolver is unregistered', () => {
Comments.CommentAnchorResolver.unregisterCustomAnchorResolver(customResolver);
const resolver = Comments.CommentAnchorResolver.getCustomAnchorResolverForElement(customElement);
assert.isNull(resolver);
});
it('returns the anchor element from the resolver that handles the anchor', () => {
const anchorBox = document.createElement('div');
const handledAnchor: CommentManager.CommentManager.CommentAnchorSignature = {
vePath: 'Panel: custom > Canvas: main',
textSignature: 'Handled Event',
};
const unhandledAnchor: CommentManager.CommentManager.CommentAnchorSignature = {
vePath: 'Panel: custom > Canvas: main',
textSignature: 'Unhandled Event',
};
Comments.CommentAnchorResolver.registerCustomAnchorResolver({
matches: () => false,
resolve: () => null,
getAnchorElement: anchor => anchor === handledAnchor ? anchorBox : null,
});
assert.strictEqual(Comments.CommentAnchorResolver.getCustomAnchorElement(handledAnchor), anchorBox);
assert.isNull(Comments.CommentAnchorResolver.getCustomAnchorElement(unhandledAnchor));
});
it('calls custom anchors moved listeners until they are removed', () => {
const listener = sinon.spy();
Comments.CommentAnchorResolver.addCustomAnchorsMovedListener(listener);
Comments.CommentAnchorResolver.notifyCustomAnchorsMoved();
sinon.assert.calledOnce(listener);
Comments.CommentAnchorResolver.removeCustomAnchorsMovedListener(listener);
Comments.CommentAnchorResolver.notifyCustomAnchorsMoved();
sinon.assert.calledOnce(listener);
});
});
describe('extractPanelId', () => {
it('extracts panel ID from valid VE paths', () => {
assert.strictEqual(
Comments.CommentAnchorResolver.extractPanelId('Panel: timeline > FlameChart: main'),
'timeline',
);
assert.strictEqual(
Comments.CommentAnchorResolver.extractPanelId('Panel: elements > Pane: styles'),
'elements',
);
assert.strictEqual(
Comments.CommentAnchorResolver.extractPanelId('Panel: console-view'),
'console-view',
);
});
it('returns undefined if no Panel component is present', () => {
assert.isUndefined(Comments.CommentAnchorResolver.extractPanelId('TreeItem: foo'));
assert.isUndefined(Comments.CommentAnchorResolver.extractPanelId(undefined));
assert.isUndefined(Comments.CommentAnchorResolver.extractPanelId(''));
});
});
describe('isDomTrackedAnchor', () => {
it('returns true for standard DOM anchors', () => {
const domAnchor: Comments.CommentAnchorResolver.CommentAnchorSignature = {
vePath: 'Panel: elements > TreeItem',
textSignature: 'div.header',
};
assert.isTrue(Comments.CommentAnchorResolver.isDomTrackedAnchor(domAnchor));
});
it('returns false for canvas timeline anchors', () => {
const timelineAnchor: Comments.CommentAnchorResolver.CommentAnchorSignature = {
vePath: 'Panel: timeline > FlameChart: main',
textSignature: 'Task',
timeline: {
traceId: 'trace-1',
traceEventKey: 'e-1',
entryName: 'Task',
startTimeMicro: 1000,
chartLocation: 'main',
},
};
assert.isFalse(Comments.CommentAnchorResolver.isDomTrackedAnchor(timelineAnchor));
});
});
});