blob: a760386e2cca85b8a9484b071ab1eac25e06ece5 [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 {beforeEach, it} from 'mocha';
import sinon from 'sinon';
import * as SDK from '../../core/sdk/sdk.js';
import type * as Protocol from '../../generated/protocol.js';
import {createTarget, describeWithEnvironment} from '../../testing/EnvironmentHelpers.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as Elements from './elements.js';
describeWithEnvironment('DOMTreeContextMenu', () => {
let target: SDK.Target.Target;
let domModel: SDK.DOMModel.DOMModel;
beforeEach(() => {
const actionRegistry = UI.ActionRegistry.ActionRegistry.instance({forceNew: true});
UI.ShortcutRegistry.ShortcutRegistry.instance({forceNew: true, actionRegistry});
target = createTarget();
domModel = target.model(SDK.DOMModel.DOMModel) as SDK.DOMModel.DOMModel;
});
function createTestNode(nodeType: number = Node.ELEMENT_NODE): SDK.DOMModel.DOMNode {
const node = new SDK.DOMModel.DOMNode(domModel);
sinon.stub(node, 'nodeType').returns(nodeType);
sinon.stub(node, 'nodeNameInCorrectCase').returns(nodeType === Node.TEXT_NODE ? '#text' : 'div');
sinon.stub(node, 'nodeName').returns(nodeType === Node.TEXT_NODE ? '#text' : 'DIV');
sinon.stub(node, 'id').value(1 as unknown as Protocol.DOM.NodeId);
return node;
}
function createContextMenuEvent(): MouseEvent {
const element = document.createElement('div');
const event = new MouseEvent('contextmenu');
Object.defineProperty(event, 'target', {value: element});
return event;
}
function getFlatLabels(items?: UI.SoftContextMenu.SoftContextMenuDescriptor[]): string[] {
const labels: string[] = [];
if (!items) {
return labels;
}
for (const item of items) {
if (item.label) {
labels.push(item.label);
}
if (item.subItems) {
labels.push(...getFlatLabels(item.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[]));
}
}
return labels;
}
function findItem(items?: UI.SoftContextMenu.SoftContextMenuDescriptor[],
label?: string): UI.SoftContextMenu.SoftContextMenuDescriptor|undefined {
if (!items) {
return undefined;
}
for (const item of items) {
if (item.label === label) {
return item;
}
if (item.subItems) {
const found = findItem(item.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[], label);
if (found) {
return found;
}
}
}
return undefined;
}
it('populates context menu with standard actions for element nodes', async () => {
const domTree = new Elements.DOMTreeWidget.DOMTreeWidget();
let contextMenu: UI.ContextMenu.ContextMenu|undefined;
try {
const node = createTestNode();
const event = createContextMenuEvent();
contextMenu = await domTree.showContextMenu(node, event);
assert.exists(contextMenu);
const descriptor = contextMenu.buildDescriptor() as UI.SoftContextMenu.SoftContextMenuDescriptor;
const labels = getFlatLabels(descriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[]);
assert.include(labels, 'Cut');
assert.include(labels, 'Copy');
assert.include(labels, 'Hide element');
assert.include(labels, 'Delete element');
assert.include(labels, 'Scroll into view');
assert.include(labels, 'Focus');
assert.include(labels, 'Add attribute');
} finally {
contextMenu?.discard();
domTree.detach();
}
});
it('does not show context menu when enableContextMenu is false', async () => {
const domTree = new Elements.DOMTreeWidget.DOMTreeWidget();
try {
domTree.enableContextMenu = false;
const node = createTestNode();
const event = createContextMenuEvent();
const contextMenu = await domTree.showContextMenu(node, event);
assert.isUndefined(contextMenu);
} finally {
domTree.detach();
}
});
it('triggers performCopyOrCut, toggleHideElement, and removeNode from context menu actions', async () => {
const domTree = new Elements.DOMTreeWidget.DOMTreeWidget();
let contextMenu: UI.ContextMenu.ContextMenu|undefined;
try {
const node = createTestNode();
const copyOrCutSpy = sinon.spy(domTree, 'performCopyOrCut');
const hideSpy = sinon.spy(domTree, 'toggleHideElement');
const removeSpy = sinon.spy(domTree, 'removeNode');
const event = createContextMenuEvent();
contextMenu = await domTree.showContextMenu(node, event);
assert.exists(contextMenu);
const descriptor = contextMenu.buildDescriptor() as UI.SoftContextMenu.SoftContextMenuDescriptor;
// Trigger cut
const cutItem = findItem(descriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[], 'Cut');
assert.exists(cutItem);
assert.exists(cutItem.id);
contextMenu.invokeHandler(cutItem.id);
sinon.assert.calledWith(copyOrCutSpy, true, node);
// Trigger hide
const hideItem = findItem(descriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[], 'Hide element');
assert.exists(hideItem);
assert.exists(hideItem.id);
contextMenu.invokeHandler(hideItem.id);
sinon.assert.calledWith(hideSpy, node);
// Trigger delete
const deleteItem =
findItem(descriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[], 'Delete element');
assert.exists(deleteItem);
assert.exists(deleteItem.id);
contextMenu.invokeHandler(deleteItem.id);
sinon.assert.calledWith(removeSpy, node);
} finally {
contextMenu?.discard();
domTree.detach();
}
});
it('triggers expandRecursively and collapseChildren on DOMTreeWidget', async () => {
const domTree = new Elements.DOMTreeWidget.DOMTreeWidget();
let contextMenu: UI.ContextMenu.ContextMenu|undefined;
try {
const node = createTestNode();
const expandSpy = sinon.spy(domTree, 'expandRecursively');
const collapseSpy = sinon.spy(domTree, 'collapseChildren');
const event = createContextMenuEvent();
contextMenu = await domTree.showContextMenu(node, event);
assert.exists(contextMenu);
const descriptor = contextMenu.buildDescriptor() as UI.SoftContextMenu.SoftContextMenuDescriptor;
const expandItem =
findItem(descriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[], 'Expand recursively');
assert.exists(expandItem);
assert.exists(expandItem.id);
contextMenu.invokeHandler(expandItem.id);
sinon.assert.calledWith(expandSpy, node);
const collapseItem =
findItem(descriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[], 'Collapse children');
assert.exists(collapseItem);
assert.exists(collapseItem.id);
contextMenu.invokeHandler(collapseItem.id);
sinon.assert.calledWith(collapseSpy, node);
} finally {
contextMenu?.discard();
domTree.detach();
}
});
it('triggers addNewAttribute on DOMTreeWidget when Add attribute is clicked', async () => {
const domTree = new Elements.DOMTreeWidget.DOMTreeWidget();
let contextMenu: UI.ContextMenu.ContextMenu|undefined;
try {
const node = createTestNode();
const addNewAttributeSpy = sinon.spy(domTree, 'addNewAttribute');
const event = createContextMenuEvent();
contextMenu = await domTree.showContextMenu(node, event);
assert.exists(contextMenu);
const descriptor = contextMenu.buildDescriptor() as UI.SoftContextMenu.SoftContextMenuDescriptor;
const addItem = findItem(descriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[], 'Add attribute');
assert.exists(addItem);
assert.exists(addItem.id);
contextMenu.invokeHandler(addItem.id);
sinon.assert.calledOnceWithExactly(addNewAttributeSpy, node);
} finally {
contextMenu?.discard();
domTree.detach();
}
});
it('triggers startEditing on DOMTreeWidget when Edit attribute is clicked', async () => {
const domTree = new Elements.DOMTreeWidget.DOMTreeWidget();
let contextMenu: UI.ContextMenu.ContextMenu|undefined;
try {
const node = createTestNode();
const startEditingSpy = sinon.spy(domTree, 'startEditing');
const attrSpan = document.createElement('span');
attrSpan.className = 'webkit-html-attribute';
const nameSpan = document.createElement('span');
nameSpan.className = 'webkit-html-attribute-name';
nameSpan.textContent = 'class';
attrSpan.appendChild(nameSpan);
const event = new MouseEvent('contextmenu');
Object.defineProperty(event, 'target', {value: nameSpan});
contextMenu = await domTree.showContextMenu(node, event);
assert.exists(contextMenu);
const descriptor = contextMenu.buildDescriptor() as UI.SoftContextMenu.SoftContextMenuDescriptor;
const editItem =
findItem(descriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[], 'Edit attribute');
assert.exists(editItem);
assert.exists(editItem.id);
contextMenu.invokeHandler(editItem.id);
sinon.assert.calledOnceWithExactly(startEditingSpy, node, 'class');
} finally {
contextMenu?.discard();
domTree.detach();
}
});
it('triggers startEditingTextNode on DOMTreeWidget when Edit text is clicked', async () => {
const domTree = new Elements.DOMTreeWidget.DOMTreeWidget();
let contextMenu: UI.ContextMenu.ContextMenu|undefined;
try {
const node = createTestNode(Node.TEXT_NODE);
const startEditingTextNodeSpy = sinon.spy(domTree, 'startEditingTextNode');
const textSpan = document.createElement('span');
textSpan.className = 'webkit-html-text-node';
const event = new MouseEvent('contextmenu');
Object.defineProperty(event, 'target', {value: textSpan});
contextMenu = await domTree.showContextMenu(node, event);
assert.exists(contextMenu);
const descriptor = contextMenu.buildDescriptor() as UI.SoftContextMenu.SoftContextMenuDescriptor;
const editItem = findItem(descriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[], 'Edit text');
assert.exists(editItem);
assert.isTrue(editItem.enabled);
assert.exists(editItem.id);
contextMenu.invokeHandler(editItem.id);
sinon.assert.calledOnceWithExactly(startEditingTextNodeSpy, node);
} finally {
contextMenu?.discard();
domTree.detach();
}
});
it('disables Edit text when text node value exceeds MaxEditableTextSize', async () => {
const domTree = new Elements.DOMTreeWidget.DOMTreeWidget();
let contextMenu: UI.ContextMenu.ContextMenu|undefined;
try {
const node = createTestNode(Node.TEXT_NODE);
sinon.stub(node, 'nodeValue').returns('a'.repeat(Elements.DOMTreeElementWidget.MaxEditableTextSize) + '\u2026');
const textSpan = document.createElement('span');
textSpan.className = 'webkit-html-text-node';
const event = new MouseEvent('contextmenu');
Object.defineProperty(event, 'target', {value: textSpan});
contextMenu = await domTree.showContextMenu(node, event);
assert.exists(contextMenu);
const descriptor = contextMenu.buildDescriptor() as UI.SoftContextMenu.SoftContextMenuDescriptor;
const editItem = findItem(descriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[], 'Edit text');
assert.exists(editItem);
assert.isFalse(editItem.enabled);
} finally {
contextMenu?.discard();
domTree.detach();
}
});
it('enables Edit data for processing instruction nodes up to MaxEditableTextSize and disables it above', async () => {
const domTree = new Elements.DOMTreeWidget.DOMTreeWidget();
let editableMenu: UI.ContextMenu.ContextMenu|undefined;
let truncatedMenu: UI.ContextMenu.ContextMenu|undefined;
try {
const editableNode = createTestNode(Node.PROCESSING_INSTRUCTION_NODE);
sinon.stub(editableNode, 'nodeValue').returns('a'.repeat(Elements.DOMTreeElementWidget.MaxEditableTextSize));
editableMenu = await domTree.showContextMenu(editableNode, createContextMenuEvent());
assert.exists(editableMenu);
const editableDescriptor = editableMenu.buildDescriptor() as UI.SoftContextMenu.SoftContextMenuDescriptor;
const editableItem =
findItem(editableDescriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[], 'Edit data');
assert.exists(editableItem);
assert.isTrue(editableItem.enabled);
const truncatedNode = createTestNode(Node.PROCESSING_INSTRUCTION_NODE);
sinon.stub(truncatedNode, 'nodeValue')
.returns('a'.repeat(Elements.DOMTreeElementWidget.MaxEditableTextSize) + '\u2026');
truncatedMenu = await domTree.showContextMenu(truncatedNode, createContextMenuEvent());
assert.exists(truncatedMenu);
const truncatedDescriptor = truncatedMenu.buildDescriptor() as UI.SoftContextMenu.SoftContextMenuDescriptor;
const truncatedItem =
findItem(truncatedDescriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[], 'Edit data');
assert.exists(truncatedItem);
assert.isFalse(truncatedItem.enabled);
} finally {
editableMenu?.discard();
truncatedMenu?.discard();
domTree.detach();
}
});
it('populates Copy submenu and Hide element for pseudo-elements', async () => {
const domTree = new Elements.DOMTreeWidget.DOMTreeWidget();
let contextMenu: UI.ContextMenu.ContextMenu|undefined;
try {
const node = createTestNode();
sinon.stub(node, 'pseudoType').returns('before' as Protocol.DOM.PseudoType);
const copyCSSPathSpy = sinon.spy(domTree, 'copyCSSPath');
const copyStylesSpy = sinon.spy(domTree, 'copyStyles');
const hideSpy = sinon.spy(domTree, 'toggleHideElement');
const event = createContextMenuEvent();
contextMenu = await domTree.showContextMenu(node, event);
assert.exists(contextMenu);
const descriptor = contextMenu.buildDescriptor() as UI.SoftContextMenu.SoftContextMenuDescriptor;
const labels = getFlatLabels(descriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[]);
assert.include(labels, 'Copy');
assert.include(labels, 'Copy selector');
assert.include(labels, 'Copy styles');
assert.include(labels, 'Hide element');
assert.include(labels, 'Scroll into view');
const copySelectorItem =
findItem(descriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[], 'Copy selector');
assert.exists(copySelectorItem?.id);
contextMenu.invokeHandler(copySelectorItem.id);
sinon.assert.calledOnceWithExactly(copyCSSPathSpy, node);
const copyStylesItem =
findItem(descriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[], 'Copy styles');
assert.exists(copyStylesItem?.id);
contextMenu.invokeHandler(copyStylesItem.id);
sinon.assert.calledOnceWithExactly(copyStylesSpy, node);
const hideItem = findItem(descriptor.subItems as UI.SoftContextMenu.SoftContextMenuDescriptor[], 'Hide element');
assert.exists(hideItem?.id);
contextMenu.invokeHandler(hideItem.id);
sinon.assert.calledOnceWithExactly(hideSpy, node);
} finally {
contextMenu?.discard();
domTree.detach();
}
});
it('expandRecursively calls getSubtree on the node', async () => {
const domTree = new Elements.DOMTreeWidget.DOMTreeWidget();
try {
const node = createTestNode();
const getSubtreeStub = sinon.stub(node, 'getSubtree').resolves(null);
await domTree.expandRecursively(node);
sinon.assert.calledWith(getSubtreeStub, 100, true);
} finally {
domTree.detach();
}
});
});