blob: 9d4035547827414a37d6cb02dee7571f9e29a17a [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as UI from '../../../front_end/ui/legacy/legacy.js';
import type * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import type * as Protocol from '../../generated/protocol.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
import {renderElementIntoDOM} from '../../testing/DOMHelpers.js';
import {createTarget, registerActions} from '../../testing/EnvironmentHelpers.js';
import {describeWithMockConnection} from '../../testing/MockConnection.js';
import * as Elements from './elements.js';
describe('ElementsTreeElement', () => {
describe('convertUnicodeCharsToHTMLEntities', () => {
it('converts unicode characters to HTML entities', () => {
const input = '\u2002\u2002This string has spaces\xA0\xA0and other\u202Aunicode characters\u200A.';
const expected = {
text: '  This string has spaces  and other‪unicode characters .',
entityRanges: [
new TextUtils.TextRange.SourceRange(0, 6),
new TextUtils.TextRange.SourceRange(6, 6),
new TextUtils.TextRange.SourceRange(34, 6),
new TextUtils.TextRange.SourceRange(40, 6),
new TextUtils.TextRange.SourceRange(55, 8),
new TextUtils.TextRange.SourceRange(81, 8),
],
};
const result = Elements.ElementsTreeElement.convertUnicodeCharsToHTMLEntities(input);
assert.strictEqual(result.text, expected.text);
assert.deepEqual(result.entityRanges, expected.entityRanges);
});
it('returns the original string if no unicode characters are present', () => {
const input = 'ThisStringHasNoWhitespace';
const expected = {
text: 'ThisStringHasNoWhitespace',
entityRanges: [],
};
const result = Elements.ElementsTreeElement.convertUnicodeCharsToHTMLEntities(input);
assert.strictEqual(result.text, expected.text);
assert.deepEqual(result.entityRanges, expected.entityRanges);
});
});
});
describeWithMockConnection('ElementsTreeElement', () => {
const DEFAULT_LAYOUT_PROPERTIES = {
isFlex: false,
isGrid: false,
isSubgrid: false,
isGridLanes: false,
containerType: undefined,
hasScroll: false,
};
beforeEach(() => {
registerActions([{
actionId: 'freestyler.element-panel-context',
title: () => 'Debug with AI' as Platform.UIString.LocalizedString,
category: UI.ActionRegistration.ActionCategory.GLOBAL,
}]);
});
async function getContextMenuForElementWithLayoutProperties(layoutProperties: SDK.CSSModel.LayoutProperties|null):
Promise<UI.ContextMenu.ContextMenu> {
const target = createTarget();
const domModel = target.model(SDK.DOMModel.DOMModel);
const cssModel = target.model(SDK.CSSModel.CSSModel);
assert.exists(domModel);
assert.exists(cssModel);
sinon.stub(cssModel, 'getLayoutPropertiesFromComputedStyle').resolves(layoutProperties);
const node = new SDK.DOMModel.DOMNode(domModel);
const treeOutline = new Elements.ElementsTreeOutline.ElementsTreeOutline();
const treeElement = new Elements.ElementsTreeElement.ElementsTreeElement(node);
treeElement.treeOutline = treeOutline;
const event = new Event('contextmenu');
const contextMenu = new UI.ContextMenu.ContextMenu(event);
await treeElement.populateNodeContextMenu(contextMenu);
return contextMenu;
}
it('shows default submenu items', async () => {
const contextMenu = await getContextMenuForElementWithLayoutProperties(null);
const debugWithAiItem = contextMenu.buildDescriptor().subItems?.find(item => item.label === 'Debug with AI');
assert.exists(debugWithAiItem);
assert.deepEqual(
debugWithAiItem?.subItems?.map(item => item.label), ['Start a chat', 'Assess visibility', 'Center element']);
});
it('shows flexbox submenu items', async () => {
const contextMenu =
await getContextMenuForElementWithLayoutProperties({...DEFAULT_LAYOUT_PROPERTIES, isFlex: true});
const debugWithAiItem = contextMenu.buildDescriptor().subItems?.find(item => item.label === 'Debug with AI');
assert.exists(debugWithAiItem);
assert.deepEqual(
debugWithAiItem?.subItems?.map(item => item.label),
['Start a chat', 'Wrap these items', 'Distribute items evenly', 'Explain flexbox']);
});
it('shows grid submenu items', async () => {
const contextMenu =
await getContextMenuForElementWithLayoutProperties({...DEFAULT_LAYOUT_PROPERTIES, isGrid: true});
const debugWithAiItem = contextMenu.buildDescriptor().subItems?.find(item => item.label === 'Debug with AI');
assert.exists(debugWithAiItem);
assert.deepEqual(
debugWithAiItem?.subItems?.map(item => item.label),
['Start a chat', 'Align items', 'Add padding', 'Explain grid layout']);
});
it('shows subgrid submenu items', async () => {
const contextMenu = await getContextMenuForElementWithLayoutProperties(
{...DEFAULT_LAYOUT_PROPERTIES, isGrid: true, isSubgrid: true});
const debugWithAiItem = contextMenu.buildDescriptor().subItems?.find(item => item.label === 'Debug with AI');
assert.exists(debugWithAiItem);
assert.deepEqual(
debugWithAiItem?.subItems?.map(item => item.label),
['Start a chat', 'Find grid definition', 'Change parent properties', 'Explain subgrids']);
});
it('shows scroll submenu items', async () => {
const contextMenu =
await getContextMenuForElementWithLayoutProperties({...DEFAULT_LAYOUT_PROPERTIES, hasScroll: true});
const debugWithAiItem = contextMenu.buildDescriptor().subItems?.find(item => item.label === 'Debug with AI');
assert.exists(debugWithAiItem);
assert.deepEqual(
debugWithAiItem?.subItems?.map(item => item.label),
['Start a chat', 'Remove scrollbars', 'Style scrollbars', 'Explain scrollbars']);
});
it('shows container submenu items', async () => {
const contextMenu = await getContextMenuForElementWithLayoutProperties(
{...DEFAULT_LAYOUT_PROPERTIES, containerType: 'inline-size'});
const debugWithAiItem = contextMenu.buildDescriptor().subItems?.find(item => item.label === 'Debug with AI');
assert.exists(debugWithAiItem);
assert.deepEqual(
debugWithAiItem?.subItems?.map(item => item.label),
['Start a chat', 'Explain container queries', 'Explain container types', 'Explain container context']);
});
it('updates when persistent overlay state changes', async () => {
const target = createTarget();
const domModel = target.model(SDK.DOMModel.DOMModel);
assert.exists(domModel);
const node = new SDK.DOMModel.DOMNode(domModel);
const treeOutline = new Elements.ElementsTreeOutline.ElementsTreeOutline();
const treeElement = new Elements.ElementsTreeElement.ElementsTreeElement(node);
treeElement.treeOutline = treeOutline;
// Simulate binding to the tree
treeElement.onbind();
const performUpdateSpy = sinon.spy(treeElement, 'performUpdate');
// Trigger event
node.dispatchEventToListeners(SDK.DOMModel.DOMNodeEvents.GRID_OVERLAY_STATE_CHANGED, {enabled: true});
sinon.assert.calledOnce(performUpdateSpy);
// Simulate unbinding
treeElement.onunbind();
performUpdateSpy.resetHistory();
// Trigger event again
node.dispatchEventToListeners(SDK.DOMModel.DOMNodeEvents.GRID_OVERLAY_STATE_CHANGED, {enabled: false});
sinon.assert.notCalled(performUpdateSpy);
});
it('updates when persistent scroll snap overlay state changes', async () => {
const target = createTarget();
const domModel = target.model(SDK.DOMModel.DOMModel);
assert.exists(domModel);
const node = new SDK.DOMModel.DOMNode(domModel);
const treeOutline = new Elements.ElementsTreeOutline.ElementsTreeOutline();
const treeElement = new Elements.ElementsTreeElement.ElementsTreeElement(node);
treeElement.treeOutline = treeOutline;
// Simulate binding to the tree
treeElement.onbind();
const performUpdateSpy = sinon.spy(treeElement, 'performUpdate');
// Trigger event
node.dispatchEventToListeners(SDK.DOMModel.DOMNodeEvents.SCROLL_SNAP_OVERLAY_STATE_CHANGED, {enabled: true});
sinon.assert.calledOnce(performUpdateSpy);
// Simulate unbinding
treeElement.onunbind();
performUpdateSpy.resetHistory();
// Trigger event again
node.dispatchEventToListeners(SDK.DOMModel.DOMNodeEvents.SCROLL_SNAP_OVERLAY_STATE_CHANGED, {enabled: false});
sinon.assert.notCalled(performUpdateSpy);
});
it('initializes the slot adorner if the node has an assigned slot', async () => {
const target = createTarget();
const domModel = target.model(SDK.DOMModel.DOMModel);
assert.exists(domModel);
const node = new SDK.DOMModel.DOMNode(domModel);
const shortcut = {
deferredNode: {
resolve: (callback: (node: SDK.DOMModel.DOMNode) => void) => {
callback(node);
},
resolvePromise: () => Promise.resolve(node),
backendNodeId: () => 1,
highlight: () => {},
},
} as unknown as SDK.DOMModel.DOMNodeShortcut;
const treeOutline = new Elements.ElementsTreeOutline.ElementsTreeOutline();
sinon.stub(node, 'hasAssignedSlot').returns(true);
sinon.stub(node, 'assignedSlot').value(shortcut);
const treeElement = new Elements.ElementsTreeElement.ElementsTreeElement(node);
treeElement.treeOutline = treeOutline;
// Simulate binding to the tree
treeElement.onbind();
treeElement.performUpdate();
const adorner = treeElement.listItemElement.querySelector('devtools-adorner');
assert.exists(adorner);
assert.strictEqual(adorner.name, 'slot');
});
it('renders STARTING_STYLE adorner when enabled', async () => {
const target = createTarget();
const domModel = target.model(SDK.DOMModel.DOMModel);
assert.exists(domModel);
const cssModel = target.model(SDK.CSSModel.CSSModel);
assert.exists(cssModel);
const node = new SDK.DOMModel.DOMNode(domModel);
node.id = 1 as Protocol.DOM.NodeId;
const treeOutline = new Elements.ElementsTreeOutline.ElementsTreeOutline();
sinon.stub(node, 'affectedByStartingStyles').returns(true);
const treeElement = new Elements.ElementsTreeElement.ElementsTreeElement(node);
treeElement.treeOutline = treeOutline;
treeElement.onbind();
treeElement.performUpdate();
const adorner = treeElement.listItemElement.querySelector('.starting-style');
assert.exists(adorner);
const forceSpy = sinon.spy(cssModel, 'forceStartingStyle');
(adorner as HTMLElement).click();
sinon.assert.calledWith(forceSpy, node, true);
(adorner as HTMLElement).click();
sinon.assert.calledWith(forceSpy, node, false);
});
});
describeWithMockConnection('ElementsTreeElement highlighting', () => {
let domModel: SDK.DOMModel.DOMModel;
let treeOutline: Elements.ElementsTreeOutline.ElementsTreeOutline;
let containerNode: SDK.DOMModel.DOMNode;
let attrTestNode: SDK.DOMModel.DOMNode;
let childTestNode: SDK.DOMModel.DOMNode;
let textTestNode: SDK.DOMModel.DOMNode;
let attrTestTreeElement: Elements.ElementsTreeElement.ElementsTreeElement;
let childTestTreeElement: Elements.ElementsTreeElement.ElementsTreeElement;
let textTestTreeElement: Elements.ElementsTreeElement.ElementsTreeElement;
let nodeId = 0;
function createDOMNodePayload(name: string, attrs: Record<string, string> = {}): Protocol.DOM.Node {
const attrList: string[] = [];
for (const [key, value] of Object.entries(attrs)) {
attrList.push(key, value);
}
return {
nodeId: ++nodeId as Protocol.DOM.NodeId,
backendNodeId: ++nodeId as Protocol.DOM.BackendNodeId,
nodeType: Node.ELEMENT_NODE,
nodeName: name.toUpperCase(),
localName: name,
nodeValue: '',
attributes: attrList,
childNodeCount: 0,
};
}
function createTextNodePayload(text: string): Protocol.DOM.Node {
return {
nodeId: ++nodeId as Protocol.DOM.NodeId,
backendNodeId: ++nodeId as Protocol.DOM.BackendNodeId,
nodeType: Node.TEXT_NODE,
nodeName: '#text',
localName: '',
nodeValue: text,
childNodeCount: 0,
};
}
beforeEach(async () => {
const target = createTarget();
domModel = target.model(SDK.DOMModel.DOMModel)!;
const containerPayload = createDOMNodePayload('div', {id: 'container'});
const attrTestPayload = createDOMNodePayload('div', {id: 'attrTest', attrFoo: 'foo'});
const childTestPayload = createDOMNodePayload('div', {id: 'childTest'});
const textTestPayload = createDOMNodePayload('div', {id: 'textTest'});
containerNode = SDK.DOMModel.DOMNode.create(domModel, null, false, containerPayload);
containerNode.setChildrenPayload([attrTestPayload, childTestPayload, textTestPayload]);
attrTestNode = containerNode.children()![0];
childTestNode = containerNode.children()![1];
textTestNode = containerNode.children()![2];
treeOutline = new Elements.ElementsTreeOutline.ElementsTreeOutline();
treeOutline.wireToDOMModel(domModel);
const containerTreeElement = new Elements.ElementsTreeElement.ElementsTreeElement(containerNode);
attrTestTreeElement = new Elements.ElementsTreeElement.ElementsTreeElement(attrTestNode);
containerTreeElement.appendChild(attrTestTreeElement);
childTestTreeElement = new Elements.ElementsTreeElement.ElementsTreeElement(childTestNode);
containerTreeElement.appendChild(childTestTreeElement);
textTestTreeElement = new Elements.ElementsTreeElement.ElementsTreeElement(textTestNode);
containerTreeElement.appendChild(textTestTreeElement);
treeOutline.appendChild(containerTreeElement);
treeOutline.setVisible(true);
renderElementIntoDOM(treeOutline.element);
containerTreeElement.expand();
});
afterEach(() => {
treeOutline.removeChildren();
treeOutline.setVisible(false);
});
let stub: sinon.SinonStub<[], void>|undefined;
async function waitForHighlights(element: Elements.ElementsTreeElement.ElementsTreeElement) {
stub?.restore();
return await new Promise(resolve => {
stub = sinon.stub(treeOutline, 'updateModifiedNodes');
stub.callsFake(() => {
stub?.wrappedMethod.call(treeOutline);
resolve(element.listItemElement.querySelectorAll('.dom-update-highlight').length);
});
});
}
it('highlights attribute value change', async () => {
const highlights = waitForHighlights(attrTestTreeElement);
domModel.attributeModified(attrTestNode.id, 'attrFoo', 'bar');
assert.strictEqual(await highlights, 1);
});
it('highlights attribute set to empty', async () => {
const highlights = waitForHighlights(attrTestTreeElement);
domModel.attributeModified(attrTestNode.id, 'attrFoo', '');
assert.strictEqual(await highlights, 1);
});
it('highlights new attribute', async () => {
const highlights = waitForHighlights(attrTestTreeElement);
domModel.attributeModified(attrTestNode.id, 'attrBar', 'bar');
assert.strictEqual(await highlights, 1);
});
it('highlights attribute removal', async () => {
const highlights = waitForHighlights(attrTestTreeElement);
domModel.attributeRemoved(attrTestNode.id, 'attrFoo');
assert.strictEqual(await highlights, 1);
});
it('highlights appending child to an empty node', async () => {
const highlights = waitForHighlights(childTestTreeElement);
const child1Payload = createDOMNodePayload('span', {id: 'child1'});
const child1Node = SDK.DOMModel.DOMNode.create(domModel, childTestNode.ownerDocument, false, child1Payload);
child1Node.parentNode = childTestNode;
domModel.dispatchEventToListeners(SDK.DOMModel.Events.NodeInserted, child1Node);
assert.strictEqual(await highlights, 1);
});
it('highlights appending child to an expanded node', async () => {
childTestTreeElement.expand();
const child1Payload = createDOMNodePayload('span', {id: 'child1'});
const child1Node = SDK.DOMModel.DOMNode.create(domModel, childTestNode.ownerDocument, false, child1Payload);
child1Node.parentNode = childTestNode;
childTestNode.setChildrenPayload([child1Payload]);
const child1TreeElement = new Elements.ElementsTreeElement.ElementsTreeElement(child1Node);
childTestTreeElement.appendChild(child1TreeElement);
const highlights = waitForHighlights(childTestTreeElement);
const child2Payload = createDOMNodePayload('span', {id: 'child2'});
const child2Node = SDK.DOMModel.DOMNode.create(domModel, childTestNode.ownerDocument, false, child2Payload);
child2Node.parentNode = childTestNode;
domModel.dispatchEventToListeners(SDK.DOMModel.Events.NodeInserted, child2Node);
assert.strictEqual(await highlights, 1);
});
it('highlights child removal', async () => {
const child1Payload = createDOMNodePayload('span', {id: 'child1'});
const child1Node = SDK.DOMModel.DOMNode.create(domModel, childTestNode.ownerDocument, false, child1Payload);
child1Node.parentNode = childTestNode;
childTestNode.setChildrenPayload([child1Payload]);
const child1TreeElement = new Elements.ElementsTreeElement.ElementsTreeElement(child1Node);
childTestTreeElement.appendChild(child1TreeElement);
const highlights = waitForHighlights(childTestTreeElement);
domModel.dispatchEventToListeners(SDK.DOMModel.Events.NodeRemoved, {node: child1Node, parent: childTestNode});
assert.strictEqual(await highlights, 1);
});
it('highlights setting text content', async () => {
const highlights = waitForHighlights(textTestTreeElement);
const textNodePayload = createTextNodePayload('Text');
const textNode = SDK.DOMModel.DOMNode.create(domModel, textTestNode.ownerDocument, false, textNodePayload);
textNode.parentNode = textTestNode;
domModel.dispatchEventToListeners(SDK.DOMModel.Events.NodeInserted, textNode);
assert.strictEqual(await highlights, 1);
});
it('highlights changing text node content', async () => {
const textNodePayload = createTextNodePayload('Text');
const textNode = SDK.DOMModel.DOMNode.create(domModel, textTestNode.ownerDocument, false, textNodePayload);
textTestNode.setChildrenPayload([textNodePayload]);
textNode.parentNode = textTestNode;
const textNodeTreeElement = new Elements.ElementsTreeElement.ElementsTreeElement(textNode);
textTestTreeElement.appendChild(textNodeTreeElement);
const highlights = waitForHighlights(textTestTreeElement);
domModel.characterDataModified(textNode.id, 'Changed');
assert.strictEqual(await highlights, 2);
});
it('highlights removing a text node', async () => {
const textNodePayload = createTextNodePayload('Text');
const textNode = SDK.DOMModel.DOMNode.create(domModel, textTestNode.ownerDocument, false, textNodePayload);
textNode.parentNode = textTestNode;
textTestNode.setChildrenPayload([textNodePayload]);
const textNodeTreeElement = new Elements.ElementsTreeElement.ElementsTreeElement(textNode);
textTestTreeElement.appendChild(textNodeTreeElement);
const highlights = waitForHighlights(textTestTreeElement);
domModel.childNodeRemoved(textTestNode.id, textNode.id);
assert.strictEqual(await highlights, 1);
});
it('highlights clearing text node content', async () => {
const textNodePayload = createTextNodePayload('Text');
const textNode = SDK.DOMModel.DOMNode.create(domModel, textTestNode.ownerDocument, false, textNodePayload);
textTestNode.setChildrenPayload([textNodePayload]);
textNode.parentNode = textTestNode;
const textNodeTreeElement = new Elements.ElementsTreeElement.ElementsTreeElement(textNode);
textTestTreeElement.appendChild(textNodeTreeElement);
const highlights = waitForHighlights(textTestTreeElement);
domModel.characterDataModified(textNode.id, '');
assert.strictEqual(await highlights, 2);
});
it('does not highlight when panel is hidden', async () => {
treeOutline.setVisible(false);
attrTestNode.setAttribute('attrFoo', 'bar');
let highlights = waitForHighlights(attrTestTreeElement);
domModel.dispatchEventToListeners(SDK.DOMModel.Events.AttrModified, {node: attrTestNode, name: 'attrFoo'});
assert.strictEqual(await highlights, 0);
treeOutline.setVisible(true);
highlights = waitForHighlights(attrTestTreeElement);
attrTestNode.setAttribute('attrFoo', 'baz');
domModel.dispatchEventToListeners(SDK.DOMModel.Events.AttrModified, {node: attrTestNode, name: 'attrFoo'});
assert.strictEqual(await highlights, 1);
});
});