blob: 6e5e3e6858e8214bb606d86e9c080f513d8ee383 [file] [edit]
// 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 sinon from 'sinon';
import * as Common from '../../core/common/common.js';
import * as SDK from '../../core/sdk/sdk.js';
import {assertScreenshot, raf, renderElementIntoDOM} from '../../testing/DOMHelpers.js';
import {describeWithEnvironment} from '../../testing/EnvironmentHelpers.js';
import * as ObjectUI from '../../ui/legacy/components/object_ui/object_ui.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as Sources from './sources.js';
describeWithEnvironment('WatchExpression', () => {
it('creates read-only object properties for watch expression', async () => {
const object = SDK.RemoteObject.RemoteObject.fromLocalObject({foo: 'bar'});
const executionContext = sinon.createStubInstance(SDK.RuntimeModel.ExecutionContext);
const debuggerModel = sinon.createStubInstance(SDK.DebuggerModel.DebuggerModel);
debuggerModel.selectedCallFrame.returns(null);
executionContext.debuggerModel = debuggerModel;
executionContext.evaluate.resolves({object, exceptionDetails: undefined});
sinon.stub(UI.Context.Context.instance(), 'flavor').returns(executionContext);
const expansionTracker = new ObjectUI.ObjectPropertiesSection.ObjectTreeExpansionTracker();
const watchExpression = new Sources.WatchExpressionsSidebarPane.WatchExpression();
await watchExpression.setExpression('obj', expansionTracker);
assert.isTrue(watchExpression.result?.readOnly);
});
it('shows "No watch expressions" when empty', async () => {
Common.Settings.Settings.instance().createLocalSetting<string[]>('watch-expressions', []).set([]);
const pane = new Sources.WatchExpressionsSidebarPane.WatchExpressionsSidebarPane();
renderElementIntoDOM(pane);
await raf();
await pane.updateComplete;
const emptyElement = pane.contentElement.querySelector('.gray-info-message');
assert.exists(emptyElement);
assert.strictEqual(emptyElement.textContent, 'No watch expressions');
});
it('adds expression via action', async () => {
const setting = Common.Settings.Settings.instance().createLocalSetting<string[]>('watch-expressions', []);
setting.set([]);
const pane = new Sources.WatchExpressionsSidebarPane.WatchExpressionsSidebarPane();
renderElementIntoDOM(pane);
await raf();
await pane.updateComplete;
const frame = sinon.createStubInstance(Sources.UISourceCodeFrame.UISourceCodeFrame);
sinon.stub(frame, 'textEditor').value({state: {sliceDoc: () => '1 + 1', selection: {main: {from: 0, to: 5}}}});
UI.Context.Context.instance().setFlavor(Sources.UISourceCodeFrame.UISourceCodeFrame, frame);
sinon.stub(UI.ViewManager.ViewManager.instance(), 'showView').resolves();
pane.handleAction(UI.Context.Context.instance(), 'sources.add-to-watch');
await raf();
await pane.updateComplete;
assert.deepEqual(setting.get(), ['1 + 1']);
});
it('edits an expression and saves to settings', async () => {
const setting = Common.Settings.Settings.instance().createLocalSetting<string[]>('watch-expressions', []);
setting.set(['1 + 1']);
const executionContext = sinon.createStubInstance(SDK.RuntimeModel.ExecutionContext);
const debuggerModel = sinon.createStubInstance(SDK.DebuggerModel.DebuggerModel);
debuggerModel.selectedCallFrame.returns(null);
executionContext.debuggerModel = debuggerModel;
executionContext.evaluate.resolves(
{object: SDK.RemoteObject.RemoteObject.fromLocalObject(2), exceptionDetails: undefined});
sinon.stub(UI.Context.Context.instance(), 'flavor').returns(executionContext);
const pane = new Sources.WatchExpressionsSidebarPane.WatchExpressionsSidebarPane();
renderElementIntoDOM(pane);
await raf();
await pane.updateComplete;
const treeElement = pane.contentElement.querySelector('devtools-tree');
const listItemElement = treeElement?.shadowRoot?.querySelector('.watch-expression-tree-item') as HTMLElement;
assert.exists(listItemElement);
const headerElement = listItemElement.querySelector('.watch-expression-header') as HTMLElement;
assert.exists(headerElement);
// Double click to start editing
headerElement.dispatchEvent(new MouseEvent('dblclick', {bubbles: true}));
await raf();
await pane.updateComplete;
const textPromptElement = listItemElement.querySelector('devtools-prompt') as HTMLElement;
assert.exists(textPromptElement);
textPromptElement.dispatchEvent(new CustomEvent('commit', {detail: '2 + 2'}));
await raf();
await pane.updateComplete;
assert.deepEqual(setting.get(), ['2 + 2']);
});
it('deletes an expression and saves to settings', async () => {
const setting = Common.Settings.Settings.instance().createLocalSetting<string[]>('watch-expressions', []);
setting.set(['1 + 1']);
const executionContext = sinon.createStubInstance(SDK.RuntimeModel.ExecutionContext);
const debuggerModel = sinon.createStubInstance(SDK.DebuggerModel.DebuggerModel);
debuggerModel.selectedCallFrame.returns(null);
executionContext.debuggerModel = debuggerModel;
executionContext.evaluate.resolves(
{object: SDK.RemoteObject.RemoteObject.fromLocalObject(2), exceptionDetails: undefined});
sinon.stub(UI.Context.Context.instance(), 'flavor').returns(executionContext);
const pane = new Sources.WatchExpressionsSidebarPane.WatchExpressionsSidebarPane();
renderElementIntoDOM(pane);
await raf();
await pane.updateComplete;
const treeElement = pane.contentElement.querySelector('devtools-tree');
const listItemElement = treeElement?.shadowRoot?.querySelector('.watch-expression-tree-item') as HTMLElement;
assert.exists(listItemElement);
const deleteButton = listItemElement.querySelector('.watch-expression-delete-button') as HTMLElement;
assert.exists(deleteButton);
deleteButton.dispatchEvent(new MouseEvent('click', {bubbles: true}));
await raf();
await pane.updateComplete;
assert.deepEqual(setting.get(), []);
});
it('screenshot for empty state', async () => {
Common.Settings.Settings.instance().createLocalSetting<string[]>('watch-expressions', []).set([]);
const pane = new Sources.WatchExpressionsSidebarPane.WatchExpressionsSidebarPane();
pane.element.style.width = '300px';
pane.element.style.height = '200px';
renderElementIntoDOM(pane, {includeCommonStyles: true});
await raf();
await pane.updateComplete;
await assertScreenshot('sources/watch-expressions-empty.png');
});
it('screenshot for list of expressions', async () => {
Common.Settings.Settings.instance().createLocalSetting<string[]>('watch-expressions', []).set(['1 + 1', '2 + 2']);
const object1 = SDK.RemoteObject.RemoteObject.fromLocalObject(2);
const object2 = SDK.RemoteObject.RemoteObject.fromLocalObject(4);
const executionContext = sinon.createStubInstance(SDK.RuntimeModel.ExecutionContext);
const debuggerModel = sinon.createStubInstance(SDK.DebuggerModel.DebuggerModel);
debuggerModel.selectedCallFrame.returns(null);
executionContext.debuggerModel = debuggerModel;
executionContext.evaluate.callsFake(async (options: SDK.RuntimeModel.EvaluationOptions) => {
if (options.expression === '1 + 1') {
return {object: object1, exceptionDetails: undefined};
}
return {object: object2, exceptionDetails: undefined};
});
sinon.stub(UI.Context.Context.instance(), 'flavor').returns(executionContext);
const pane = Sources.WatchExpressionsSidebarPane.WatchExpressionsSidebarPane.instance();
UI.Context.Context.instance().setFlavor(SDK.RuntimeModel.ExecutionContext, executionContext);
pane.element.style.width = '300px';
pane.element.style.height = '200px';
renderElementIntoDOM(pane, {includeCommonStyles: true});
await raf();
await pane.updateComplete;
const watchExpressions = pane.watchExpressions;
assert.lengthOf(watchExpressions, 2);
await assertScreenshot('sources/watch-expressions-list.png');
});
it('screenshot for watch expression with delete button visible', async () => {
Common.Settings.Settings.instance().createLocalSetting<string[]>('watch-expressions', []).set(['1 + 1']);
const object1 = SDK.RemoteObject.RemoteObject.fromLocalObject(2);
const executionContext = sinon.createStubInstance(SDK.RuntimeModel.ExecutionContext);
const debuggerModel = sinon.createStubInstance(SDK.DebuggerModel.DebuggerModel);
debuggerModel.selectedCallFrame.returns(null);
executionContext.debuggerModel = debuggerModel;
executionContext.evaluate.resolves({object: object1, exceptionDetails: undefined});
sinon.stub(UI.Context.Context.instance(), 'flavor').returns(executionContext);
const pane = new Sources.WatchExpressionsSidebarPane.WatchExpressionsSidebarPane();
pane.element.style.width = '300px';
pane.element.style.height = '200px';
renderElementIntoDOM(pane);
await raf();
await pane.updateComplete;
const watchExpressions = pane.watchExpressions;
assert.lengthOf(watchExpressions, 1);
const treeElement = pane.contentElement.querySelector('devtools-tree');
const listItemElement = treeElement?.shadowRoot?.querySelector('.watch-expression-tree-item') as HTMLElement;
assert.exists(listItemElement);
const deleteButton = listItemElement.querySelector('.watch-expression-delete-button') as HTMLElement;
assert.exists(deleteButton);
// Focus the delete button to make it visible via :focus-within on the title
deleteButton.focus();
await pane.updateComplete;
await assertScreenshot('sources/watch-expression-delete-button.png');
});
it('preserves expansion state across updates', async () => {
const object = SDK.RemoteObject.RemoteObject.fromLocalObject({foo: {bar: 'baz'}});
const executionContext = sinon.createStubInstance(SDK.RuntimeModel.ExecutionContext);
const debuggerModel = sinon.createStubInstance(SDK.DebuggerModel.DebuggerModel);
debuggerModel.selectedCallFrame.returns(null);
executionContext.debuggerModel = debuggerModel;
executionContext.evaluate.resolves({object, exceptionDetails: undefined});
sinon.stub(UI.Context.Context.instance(), 'flavor').returns(executionContext);
const setting = Common.Settings.Settings.instance().createLocalSetting<string[]>('watch-expressions', []);
setting.set(['obj']);
const pane = new Sources.WatchExpressionsSidebarPane.WatchExpressionsSidebarPane();
renderElementIntoDOM(pane);
await raf();
await pane.updateComplete;
const watchExpression = pane.watchExpressions[0];
const tree = watchExpression.result as ObjectUI.ObjectPropertiesSection.ObjectTree;
assert.exists(tree);
assert.isFalse(tree.expanded);
// Expand the root
tree.expanded = true;
assert.isTrue(tree.expanded);
// Expand the 'foo' property
const children = await tree.populateChildrenIfNeeded();
const fooProperty = children.properties?.find(p => p.name === 'foo');
assert.exists(fooProperty);
fooProperty.expanded = true;
assert.isTrue(fooProperty.expanded);
// Trigger update by refreshing
UI.Context.Context.instance().setFlavor(SDK.RuntimeModel.ExecutionContext, executionContext);
await raf();
await pane.updateComplete;
const newWatchExpression = pane.watchExpressions[0];
const newTree = newWatchExpression.result as ObjectUI.ObjectPropertiesSection.ObjectTree;
assert.notStrictEqual(tree, newTree);
assert.isTrue(newTree.expanded);
const newChildren = await newTree.populateChildrenIfNeeded();
const newFooProperty = newChildren.properties?.find(p => p.name === 'foo');
assert.exists(newFooProperty);
assert.notStrictEqual(fooProperty, newFooProperty);
assert.isTrue(newFooProperty.expanded);
});
it('clears expansion state when expression changes', async () => {
const object = SDK.RemoteObject.RemoteObject.fromLocalObject({foo: {bar: 'baz'}});
const executionContext = sinon.createStubInstance(SDK.RuntimeModel.ExecutionContext);
const debuggerModel = sinon.createStubInstance(SDK.DebuggerModel.DebuggerModel);
debuggerModel.selectedCallFrame.returns(null);
executionContext.debuggerModel = debuggerModel;
executionContext.evaluate.resolves({object, exceptionDetails: undefined});
sinon.stub(UI.Context.Context.instance(), 'flavor').returns(executionContext);
const setting = Common.Settings.Settings.instance().createLocalSetting<string[]>('watch-expressions', []);
setting.set(['obj1']);
const pane = new Sources.WatchExpressionsSidebarPane.WatchExpressionsSidebarPane();
renderElementIntoDOM(pane);
await raf();
await pane.updateComplete;
const watchExpression = pane.watchExpressions[0];
const tree = watchExpression.result as ObjectUI.ObjectPropertiesSection.ObjectTree;
assert.exists(tree);
// Expand the root and a property
tree.expanded = true;
const children = await tree.populateChildrenIfNeeded();
const fooProperty = children.properties?.find(p => p.name === 'foo');
assert.exists(fooProperty);
fooProperty.expanded = true;
assert.isTrue(tree.expanded);
assert.isTrue(fooProperty.expanded);
// Change expression
const treeElement = pane.contentElement.querySelector('devtools-tree');
const listItemElement = treeElement?.shadowRoot?.querySelector('.watch-expression-tree-item') as HTMLElement;
const headerElement = listItemElement.querySelector('.watch-expression-header') as HTMLElement;
headerElement.dispatchEvent(new MouseEvent('dblclick', {bubbles: true}));
await raf();
await pane.updateComplete;
const textPromptElement = listItemElement.querySelector('devtools-prompt') as HTMLElement;
textPromptElement.dispatchEvent(new CustomEvent('commit', {detail: 'obj2'}));
await raf();
await pane.updateComplete;
const newWatchExpression = pane.watchExpressions[0];
const newTree = newWatchExpression.result as ObjectUI.ObjectPropertiesSection.ObjectTree;
assert.notStrictEqual(tree, newTree);
assert.isFalse(newTree.expanded);
// Re-query fooProperty and check it's not expanded
const newChildren = await newTree.populateChildrenIfNeeded();
const newFooProperty = newChildren.properties?.find(p => p.name === 'foo');
assert.exists(newFooProperty);
assert.notStrictEqual(fooProperty, newFooProperty);
assert.isFalse(newFooProperty.expanded);
});
it('clears expansion state for the root when expression changes and changes back', async () => {
const object = SDK.RemoteObject.RemoteObject.fromLocalObject({foo: {bar: 'baz'}});
const executionContext = sinon.createStubInstance(SDK.RuntimeModel.ExecutionContext);
const debuggerModel = sinon.createStubInstance(SDK.DebuggerModel.DebuggerModel);
debuggerModel.selectedCallFrame.returns(null);
executionContext.debuggerModel = debuggerModel;
executionContext.evaluate.resolves({object, exceptionDetails: undefined});
sinon.stub(UI.Context.Context.instance(), 'flavor').returns(executionContext);
const setting = Common.Settings.Settings.instance().createLocalSetting<string[]>('watch-expressions', []);
setting.set(['obj1']);
const pane = new Sources.WatchExpressionsSidebarPane.WatchExpressionsSidebarPane();
renderElementIntoDOM(pane);
await raf();
await pane.updateComplete;
const watchExpression = pane.watchExpressions[0];
const tree = watchExpression.result as ObjectUI.ObjectPropertiesSection.ObjectTree;
assert.exists(tree);
tree.expanded = true;
const children = await tree.populateChildrenIfNeeded();
const fooProperty = children.properties?.find(p => p.name === 'foo');
assert.exists(fooProperty);
fooProperty.expanded = true;
assert.isTrue(tree.expanded);
assert.isTrue(fooProperty.expanded);
// Change expression to obj2
const treeElement = pane.contentElement.querySelector('devtools-tree');
let listItemElement = treeElement?.shadowRoot?.querySelector('.watch-expression-tree-item') as HTMLElement;
let headerElement = listItemElement.querySelector('.watch-expression-header') as HTMLElement;
headerElement.dispatchEvent(new MouseEvent('dblclick', {bubbles: true}));
await raf();
await pane.updateComplete;
let textPromptElement = listItemElement.querySelector('devtools-prompt') as HTMLElement;
textPromptElement.dispatchEvent(new CustomEvent('commit', {detail: 'obj2'}));
await raf();
await pane.updateComplete;
// Change expression back to obj1
listItemElement = treeElement?.shadowRoot?.querySelector('.watch-expression-tree-item') as HTMLElement;
headerElement = listItemElement.querySelector('.watch-expression-header') as HTMLElement;
headerElement.dispatchEvent(new MouseEvent('dblclick', {bubbles: true}));
await raf();
await pane.updateComplete;
textPromptElement = listItemElement.querySelector('devtools-prompt') as HTMLElement;
textPromptElement.dispatchEvent(new CustomEvent('commit', {detail: 'obj1'}));
await raf();
await pane.updateComplete;
const newWatchExpression = pane.watchExpressions[0];
const finalTree = newWatchExpression.result as ObjectUI.ObjectPropertiesSection.ObjectTree;
assert.isFalse(finalTree.expanded, 'Expansion state of root should be cleared when expression changes');
const finalChildren = await finalTree.populateChildrenIfNeeded();
const finalFooProperty = finalChildren.properties?.find(p => p.name === 'foo');
assert.exists(finalFooProperty);
assert.notStrictEqual(fooProperty, finalFooProperty);
assert.isFalse(finalFooProperty.expanded);
});
});