| // 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, describe, it} from 'mocha'; | |
| import type * as SDK from '../../core/sdk/sdk.js'; | |
| import {updateHostConfig} from '../../testing/EnvironmentHelpers.js'; | |
| import * as ChangeTracker from '../change_tracker/change_tracker.js'; | |
| import * as CommentManager from '../comment_manager/comment_manager.js'; | |
| import * as Elements from './elements.js'; | |
| describe('DOMChanges', () => { | |
| let commentManager: CommentManager.CommentManager.CommentManager; | |
| let tracker: ChangeTracker.ChangeTracker.ChangeTracker; | |
| beforeEach(() => { | |
| updateHostConfig({ | |
| devToolsComments: { | |
| enabled: true, | |
| }, | |
| }); | |
| commentManager = new CommentManager.CommentManager.CommentManager(); | |
| commentManager.setAgentAttached(true); | |
| tracker = new ChangeTracker.ChangeTracker.ChangeTracker(commentManager); | |
| }); | |
| function createNode(backendNodeId = 1, nodeName = 'DIV'): SDK.DOMModel.DOMNode { | |
| return { | |
| backendNodeId: () => backendNodeId, | |
| nodeName: () => nodeName, | |
| domModel: () => ({target: () => ({id: () => 'mock-target-id'})}), | |
| } as unknown as SDK.DOMModel.DOMNode; | |
| } | |
| function lastAnchor(): CommentManager.CommentManager.CommentAnchorSignature|undefined { | |
| return commentManager.getCommentThreads().at(-1)?.anchor; | |
| } | |
| function lastDescription(): string|undefined { | |
| return commentManager.getCommentThreads().at(-1)?.comments[0]?.text; | |
| } | |
| function trackAttributeEdit(edit: Elements.DOMChanges.AttributeEdit): void { | |
| Elements.DOMChanges.trackAttributeEdit(tracker, createNode(), 'div.main', edit); | |
| } | |
| it('records an added attribute together with an anchor for the node', () => { | |
| Elements.DOMChanges.trackAttributeEdit(tracker, createNode(42), 'div.main', | |
| {attributeName: '', oldText: '', newText: 'data-test="value"'}); | |
| assert.strictEqual(lastDescription(), 'Added attribute data-test="value"'); | |
| assert.deepEqual(lastAnchor(), { | |
| vePath: 'Panel: elements > Tree: elements > TreeItem', | |
| textSignature: 'div.main', | |
| node: {backendNodeId: 42, targetId: 'mock-target-id'}, | |
| }); | |
| }); | |
| it('records an added valueless attribute', () => { | |
| trackAttributeEdit({attributeName: '', oldText: '', newText: 'disabled'}); | |
| assert.strictEqual(lastDescription(), 'Added attribute "disabled"'); | |
| }); | |
| it('records a removed attribute', () => { | |
| trackAttributeEdit({attributeName: 'class', oldText: 'class="container"', newText: ''}); | |
| assert.strictEqual(lastDescription(), 'Removed attribute "class"'); | |
| }); | |
| it('records a changed attribute value', () => { | |
| trackAttributeEdit({attributeName: 'class', oldText: 'class="container"', newText: 'class="container active"'}); | |
| assert.strictEqual(lastDescription(), 'Changed attribute "class" from "container" to "container active"'); | |
| }); | |
| it('records a changed attribute value without a previous value', () => { | |
| trackAttributeEdit({attributeName: 'class', oldText: null, newText: 'class="btn primary"'}); | |
| assert.strictEqual(lastDescription(), 'Changed attribute "class" to "btn primary"'); | |
| }); | |
| it('records a renamed attribute', () => { | |
| trackAttributeEdit({attributeName: 'data-foo', oldText: 'data-foo="12"', newText: 'data-bar="12"'}); | |
| assert.strictEqual(lastDescription(), 'Renamed attribute "data-foo" to "data-bar"'); | |
| }); | |
| it('records an attribute that is renamed and given a new value', () => { | |
| trackAttributeEdit({attributeName: 'cls', oldText: 'cls="bar"', newText: 'class="foo"'}); | |
| assert.strictEqual(lastDescription(), 'Renamed attribute "cls"="bar" to "class"="foo"'); | |
| }); | |
| it('records a tag name edit', () => { | |
| Elements.DOMChanges.trackTagNameEdit(tracker, createNode(), 'div.main', 'div', 'span'); | |
| assert.strictEqual(lastDescription(), 'Renamed tag from <div> to <span>'); | |
| }); | |
| it('records a text node edit', () => { | |
| Elements.DOMChanges.trackTextNodeEdit(tracker, createNode(), 'div.main', 'Hello', 'Hello World'); | |
| assert.strictEqual(lastDescription(), 'Changed text from "Hello" to "Hello World"'); | |
| }); | |
| it('records an HTML edit', () => { | |
| Elements.DOMChanges.trackHTMLEdit(tracker, createNode(), 'div.main', '<div></div>', '<span></span>'); | |
| assert.strictEqual(lastDescription(), 'Changed HTML from "<div></div>" to "<span></span>"'); | |
| }); | |
| it('derives the tag name of a removed node from the node itself', () => { | |
| Elements.DOMChanges.trackNodeRemoval(tracker, createNode(1, 'SPAN'), 'span.child'); | |
| assert.strictEqual(lastDescription(), 'Removed node <span>'); | |
| assert.strictEqual(lastAnchor()?.textSignature, 'span.child'); | |
| }); | |
| it('records hiding and unhiding an element', () => { | |
| Elements.DOMChanges.trackVisibilityToggle(tracker, createNode(), 'div.main', /* hidden= */ true); | |
| assert.strictEqual(lastDescription(), 'Hid element <div>'); | |
| Elements.DOMChanges.trackVisibilityToggle(tracker, createNode(), 'div.main', /* hidden= */ false); | |
| assert.strictEqual(lastDescription(), 'Unhid element <div>'); | |
| }); | |
| it('records a duplicated node', () => { | |
| Elements.DOMChanges.trackNodeDuplication(tracker, createNode(1, 'SPAN'), 'span.child'); | |
| assert.strictEqual(lastDescription(), 'Duplicated node <span>'); | |
| }); | |
| it('records a node moved up or down', () => { | |
| Elements.DOMChanges.trackNodeMove(tracker, createNode(), 'div.main', /* directionUp= */ true); | |
| assert.strictEqual(lastDescription(), 'Moved node <div> up'); | |
| Elements.DOMChanges.trackNodeMove(tracker, createNode(), 'div.main', /* directionUp= */ false); | |
| assert.strictEqual(lastDescription(), 'Moved node <div> down'); | |
| }); | |
| it('records a node moved via drag and drop', () => { | |
| Elements.DOMChanges.trackNodeDrop(tracker, createNode(), 'div.main'); | |
| assert.strictEqual(lastDescription(), 'Moved node <div> via drag and drop'); | |
| }); | |
| it('records a pasted node', () => { | |
| Elements.DOMChanges.trackNodePaste(tracker, createNode(), 'div.main', /* isCut= */ false); | |
| assert.strictEqual(lastDescription(), 'Pasted node <div>'); | |
| Elements.DOMChanges.trackNodePaste(tracker, createNode(), 'div.main', /* isCut= */ true); | |
| assert.strictEqual(lastDescription(), 'Pasted (moved) node <div>'); | |
| }); | |
| it('falls back to a generic text signature without a selector', () => { | |
| Elements.DOMChanges.trackNodeRemoval(tracker, createNode(), undefined); | |
| assert.strictEqual(lastAnchor()?.textSignature, 'element'); | |
| }); | |
| it('trims page controlled data that is too long', () => { | |
| const {MAX_VALUE_LENGTH} = Elements.ChangeTrackingUtils; | |
| const longValue = 'a'.repeat(MAX_VALUE_LENGTH * 2); | |
| Elements.DOMChanges.trackHTMLEdit(tracker, createNode(), 'div.main', '<div></div>', `<div>${longValue}</div>`); | |
| const description = lastDescription() ?? ''; | |
| assert.include(description, 'Changed HTML from "<div></div>" to "<div>aaa'); | |
| assert.include(description, '…'); | |
| assert.notInclude(description, longValue); | |
| assert.isBelow(description.length, `Changed HTML from "<div></div>" to "<div>${longValue}</div>"`.length); | |
| }); | |
| it('does not trim page controlled data that fits', () => { | |
| Elements.DOMChanges.trackTextNodeEdit(tracker, createNode(), 'div.main', 'Hello', 'Hello World'); | |
| assert.strictEqual(lastDescription(), 'Changed text from "Hello" to "Hello World"'); | |
| }); | |
| it('does nothing without a tracker', () => { | |
| Elements.DOMChanges.trackNodeRemoval(undefined, createNode(), 'div.main'); | |
| assert.isEmpty(commentManager.getCommentThreads()); | |
| }); | |
| it('does nothing while change tracking is disabled', () => { | |
| updateHostConfig({devToolsComments: {enabled: false}}); | |
| Elements.DOMChanges.trackNodeRemoval(tracker, createNode(), 'div.main'); | |
| assert.isUndefined(lastDescription()); | |
| assert.isEmpty(commentManager.getCommentThreads()); | |
| }); | |
| }); |