| // 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(); | |
| } | |
| }); | |
| }); |