diff options
Diffstat (limited to 'devtools/client/dom')
35 files changed, 1928 insertions, 0 deletions
diff --git a/devtools/client/dom/.eslintrc.js b/devtools/client/dom/.eslintrc.js new file mode 100644 index 0000000000..8e2ca9a042 --- /dev/null +++ b/devtools/client/dom/.eslintrc.js @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + globals: { + XMLHttpRequest: true, + window: true, + define: true, + addEventListener: true, + document: true, + dispatchEvent: true, + MessageEvent: true, + }, +}; diff --git a/devtools/client/dom/content/actions/filter.js b/devtools/client/dom/content/actions/filter.js new file mode 100644 index 0000000000..dbd6ca7ac4 --- /dev/null +++ b/devtools/client/dom/content/actions/filter.js @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const constants = require("resource://devtools/client/dom/content/constants.js"); + +/** + * Used to filter DOM panel content. + */ +function setVisibilityFilter(filter) { + return { + filter, + type: constants.SET_VISIBILITY_FILTER, + }; +} + +// Exports from this module +exports.setVisibilityFilter = setVisibilityFilter; diff --git a/devtools/client/dom/content/actions/grips.js b/devtools/client/dom/content/actions/grips.js new file mode 100644 index 0000000000..3f25d46d3e --- /dev/null +++ b/devtools/client/dom/content/actions/grips.js @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* globals DomProvider */ +"use strict"; + +const constants = require("resource://devtools/client/dom/content/constants.js"); + +/** + * Used to fetch grip prototype and properties from the backend. + */ +function requestProperties(grip) { + return { + grip, + type: constants.FETCH_PROPERTIES, + status: "start", + error: false, + }; +} + +/** + * Executed when grip properties are received from the backend. + */ +function receiveProperties(grip, response, error) { + return { + grip, + type: constants.FETCH_PROPERTIES, + status: "done", + response, + error, + }; +} + +/** + * Used to get properties from the backend and fire an action + * when they are received. + */ +function fetchProperties(grip) { + return async ({ dispatch }) => { + try { + // Use 'DomProvider' object exposed from the chrome scope. + const response = await DomProvider.getPrototypeAndProperties(grip); + dispatch(receiveProperties(grip, response)); + } catch (e) { + console.error("Error while fetching properties", e); + } + DomProvider.onPropertiesFetched(); + }; +} + +// Exports from this module +exports.requestProperties = requestProperties; +exports.receiveProperties = receiveProperties; +exports.fetchProperties = fetchProperties; diff --git a/devtools/client/dom/content/actions/moz.build b/devtools/client/dom/content/actions/moz.build new file mode 100644 index 0000000000..8c1c56d39a --- /dev/null +++ b/devtools/client/dom/content/actions/moz.build @@ -0,0 +1,9 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "filter.js", + "grips.js", +) diff --git a/devtools/client/dom/content/components/DomTree.js b/devtools/client/dom/content/components/DomTree.js new file mode 100644 index 0000000000..b82a2785dd --- /dev/null +++ b/devtools/client/dom/content/components/DomTree.js @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global DomProvider */ + +"use strict"; + +// React & Redux +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const TreeView = createFactory( + require("resource://devtools/client/shared/components/tree/TreeView.js") +); +// Reps +const { + REPS, + MODE, +} = require("resource://devtools/client/shared/components/reps/index.js"); +const { Rep } = REPS; + +const Grip = REPS.Grip; +// DOM Panel +const { + GripProvider, +} = require("resource://devtools/client/dom/content/grip-provider.js"); + +const { + DomDecorator, +} = require("resource://devtools/client/dom/content/dom-decorator.js"); + +/** + * Renders DOM panel tree. + */ +class DomTree extends Component { + static get propTypes() { + return { + dispatch: PropTypes.func.isRequired, + filter: PropTypes.string, + grips: PropTypes.object, + object: PropTypes.any, + openLink: PropTypes.func, + }; + } + + constructor(props) { + super(props); + this.onFilter = this.onFilter.bind(this); + } + + /** + * Filter DOM properties. Return true if the object + * should be visible in the tree. + */ + onFilter(object) { + if (!this.props.filter) { + return true; + } + + return object.name && object.name.indexOf(this.props.filter) > -1; + } + + /** + * Render DOM panel content + */ + render() { + const { dispatch, grips, object, openLink } = this.props; + + const columns = [ + { + id: "value", + }, + ]; + + let onDOMNodeMouseOver; + let onDOMNodeMouseOut; + let onInspectIconClick; + const toolbox = DomProvider.getToolbox(); + if (toolbox) { + const highlighter = toolbox.getHighlighter(); + onDOMNodeMouseOver = async (grip, options = {}) => { + return highlighter.highlight(grip, options); + }; + onDOMNodeMouseOut = async () => { + return highlighter.unhighlight(); + }; + onInspectIconClick = async grip => { + return toolbox.viewElementInInspector(grip, "inspect_dom"); + }; + } + + // This is the integration point with Reps. The DomTree is using + // Reps to render all values. The code also specifies default rep + // used for data types that don't have its own specific template. + const renderValue = props => { + const repProps = Object.assign({}, props, { + onDOMNodeMouseOver, + onDOMNodeMouseOut, + onInspectIconClick, + defaultRep: Grip, + cropLimit: 50, + }); + + // Object can be an objectFront, while Rep always expect grips. + if (props?.object?.getGrip) { + repProps.object = props.object.getGrip(); + } + + return Rep(repProps); + }; + + return TreeView({ + columns, + decorator: new DomDecorator(), + mode: MODE.SHORT, + object, + onFilter: this.onFilter, + openLink, + provider: new GripProvider(grips, dispatch), + renderValue, + }); + } +} + +const mapStateToProps = state => { + return { + grips: state.grips, + filter: state.filter, + }; +}; + +// Exports from this module +module.exports = connect(mapStateToProps)(DomTree); diff --git a/devtools/client/dom/content/components/MainFrame.js b/devtools/client/dom/content/components/MainFrame.js new file mode 100644 index 0000000000..fedbb09bb8 --- /dev/null +++ b/devtools/client/dom/content/components/MainFrame.js @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* globals DomProvider */ + +"use strict"; + +// React & Redux +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +// DOM Panel +const DomTree = createFactory( + require("resource://devtools/client/dom/content/components/DomTree.js") +); + +const MainToolbar = createFactory( + require("resource://devtools/client/dom/content/components/MainToolbar.js") +); +// Shortcuts +const { div } = dom; + +/** + * Renders basic layout of the DOM panel. The DOM panel content consists + * from two main parts: toolbar and tree. + */ +class MainFrame extends Component { + static get propTypes() { + return { + dispatch: PropTypes.func.isRequired, + filter: PropTypes.string, + object: PropTypes.any, + }; + } + + /** + * Render DOM panel content + */ + render() { + const { filter, object } = this.props; + + return div( + { className: "mainFrame" }, + MainToolbar({ + dispatch: this.props.dispatch, + object: this.props.object, + }), + div( + { className: "treeTableBox devtools-monospace" }, + DomTree({ + filter, + object, + openLink: url => DomProvider.openLink(url), + }) + ) + ); + } +} + +// Transform state into props +// Note: use https://github.com/faassen/reselect for better performance. +const mapStateToProps = state => { + return { + filter: state.filter, + }; +}; + +// Exports from this module +module.exports = connect(mapStateToProps)(MainFrame); diff --git a/devtools/client/dom/content/components/MainToolbar.js b/devtools/client/dom/content/components/MainToolbar.js new file mode 100644 index 0000000000..f3f018741a --- /dev/null +++ b/devtools/client/dom/content/components/MainToolbar.js @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +// React +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const SearchBox = createFactory( + require("resource://devtools/client/shared/components/SearchBox.js") +); + +const { l10n } = require("resource://devtools/client/dom/content/utils.js"); + +// Actions +const { + fetchProperties, +} = require("resource://devtools/client/dom/content/actions/grips.js"); +const { + setVisibilityFilter, +} = require("resource://devtools/client/dom/content/actions/filter.js"); + +/** + * This template is responsible for rendering a toolbar + * within the 'Headers' panel. + */ +class MainToolbar extends Component { + static get propTypes() { + return { + object: PropTypes.any.isRequired, + dispatch: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + this.onRefresh = this.onRefresh.bind(this); + this.onSearch = this.onSearch.bind(this); + } + + onRefresh() { + this.props.dispatch(fetchProperties(this.props.object)); + } + + onSearch(value) { + this.props.dispatch(setVisibilityFilter(value)); + } + + render() { + return dom.div( + { className: "devtools-toolbar devtools-input-toolbar" }, + SearchBox({ + key: "filter", + delay: 250, + onChange: this.onSearch, + placeholder: l10n.getStr("dom.filterDOMPanel"), + type: "filter", + }), + dom.span({ className: "devtools-separator" }), + dom.button({ + key: "refresh", + className: "refresh devtools-button", + id: "dom-refresh-button", + title: l10n.getStr("dom.refresh"), + onClick: this.onRefresh, + }) + ); + } +} + +// Exports from this module +module.exports = MainToolbar; diff --git a/devtools/client/dom/content/components/moz.build b/devtools/client/dom/content/components/moz.build new file mode 100644 index 0000000000..5b669bac24 --- /dev/null +++ b/devtools/client/dom/content/components/moz.build @@ -0,0 +1,6 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules("DomTree.js", "MainFrame.js", "MainToolbar.js") diff --git a/devtools/client/dom/content/constants.js b/devtools/client/dom/content/constants.js new file mode 100644 index 0000000000..c7bb8b325c --- /dev/null +++ b/devtools/client/dom/content/constants.js @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +exports.FETCH_PROPERTIES = "FETCH_PROPERTIES"; +exports.SET_VISIBILITY_FILTER = "SET_VISIBILITY_FILTER"; diff --git a/devtools/client/dom/content/dom-decorator.js b/devtools/client/dom/content/dom-decorator.js new file mode 100644 index 0000000000..a711a95d83 --- /dev/null +++ b/devtools/client/dom/content/dom-decorator.js @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + Property, +} = require("resource://devtools/client/dom/content/reducers/grips.js"); + +// Implementation + +function DomDecorator() {} + +/** + * Decorator for DOM panel tree component. It's responsible for + * appending an icon to read only properties. + */ +DomDecorator.prototype = { + getRowClass(object) { + if (object instanceof Property) { + const value = object.value; + const names = []; + + if (value.enumerable) { + names.push("enumerable"); + } + if (value.writable) { + names.push("writable"); + } + if (value.configurable) { + names.push("configurable"); + } + + return names; + } + + return null; + }, + + /** + * Return custom React template for specified object. The template + * might depend on specified column. + */ + getValueRep(value, colId) {}, +}; + +// Exports from this module +exports.DomDecorator = DomDecorator; diff --git a/devtools/client/dom/content/dom-view.css b/devtools/client/dom/content/dom-view.css new file mode 100644 index 0000000000..d2fd77e525 --- /dev/null +++ b/devtools/client/dom/content/dom-view.css @@ -0,0 +1,109 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/******************************************************************************/ +/* General */ + +body { + padding: 0; + margin: 0; + overflow: hidden; + background-color: var(--theme-body-background); +} + +.mainFrame { + display: flex; + flex-direction: column; + height: 100vh; +} + +.mainFrame > .treeTableBox { + flex: 1 1 auto; + overflow: auto; +} + +/******************************************************************************/ +/* TreeView Customization */ + +.treeTable { + width: 100%; +} + +/* Space for read only properties icon */ +.treeTable td.treeValueCell { + padding-inline-start: 16px; +} + +.treeTable .treeLabel, +.treeTable td.treeValueCell .objectBox { + direction: ltr; /* Don't change the direction of english labels */ +} + +/* Read only properties have a padlock icon */ +.treeTable tr:not(.writable) td.treeValueCell { + background: url("chrome://devtools/skin/images/lock.svg") no-repeat; + background-position: 1px 4px; + background-size: 12px 12px; + -moz-context-properties: fill; + fill: var(--theme-icon-dimmed-color); +} + +.treeTable tr:not(.writable) td.treeValueCell:dir(rtl) { + background-position-x: right 1px; +} + +.treeTable tr:not(.writable).selected td.treeValueCell { + fill: var(--theme-selection-color); +} + +/* Non-enumerable properties are grayed out */ +.treeTable tr:not(.enumerable) td.treeValueCell { + opacity: 0.7; +} + +.theme-light .treeTable > tbody > tr > td { + border-bottom: 1px solid var(--grey-20); +} + +/* Label Types */ +.treeTable .userLabel, +.treeTable .userClassLabel, +.treeTable .userFunctionLabel { + font-weight: bold; +} + +.treeTable .userLabel { + color: #000000; +} + +.treeTable .userClassLabel { + color: #E90000; +} + +.treeTable .userFunctionLabel { + color: #025E2A; +} + +.treeTable .domLabel { + color: #000000; +} + +.treeTable .domClassLabel { + color: #E90000; +} + +.treeTable .domFunctionLabel { + color: #025E2A; +} + +.treeTable .ordinalLabel { + color: SlateBlue; + font-weight: bold; +} + +/******************************************************************************/ +/* Refresh button */ +#dom-refresh-button::before { + background-image: url("chrome://devtools/skin/images/reload.svg"); +} diff --git a/devtools/client/dom/content/dom-view.js b/devtools/client/dom/content/dom-view.js new file mode 100644 index 0000000000..6bd2252978 --- /dev/null +++ b/devtools/client/dom/content/dom-view.js @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +// React & Redux +const React = require("resource://devtools/client/shared/vendor/react.js"); +const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js"); +const { + Provider, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +// DOM Panel +const MainFrame = React.createFactory( + require("resource://devtools/client/dom/content/components/MainFrame.js") +); + +// Store +const createStore = require("resource://devtools/client/shared/redux/create-store.js"); + +const { + reducers, +} = require("resource://devtools/client/dom/content/reducers/index.js"); +const store = createStore(reducers); + +/** + * This object represents view of the DOM panel and is responsible + * for rendering the content. It renders the top level ReactJS + * component: the MainFrame. + */ +function DomView(localStore) { + addEventListener("devtools/chrome/message", this.onMessage.bind(this), true); + + // Make it local so, tests can access it. + this.store = localStore; +} + +DomView.prototype = { + initialize(rootGrip) { + const content = document.querySelector("#content"); + const mainFrame = MainFrame({ + object: rootGrip, + }); + + // Render top level component + const provider = React.createElement( + Provider, + { + store: this.store, + }, + mainFrame + ); + + this.mainFrame = ReactDOM.render(provider, content); + }, + + onMessage(event) { + const data = event.data; + const method = data.type; + + if (typeof this[method] == "function") { + this[method](data.args); + } + }, +}; + +// Construct DOM panel view object and expose it to tests. +// Tests can access it through: |panel.panelWin.view| +window.view = new DomView(store); diff --git a/devtools/client/dom/content/grip-provider.js b/devtools/client/dom/content/grip-provider.js new file mode 100644 index 0000000000..18b3308869 --- /dev/null +++ b/devtools/client/dom/content/grip-provider.js @@ -0,0 +1,102 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + fetchProperties, +} = require("resource://devtools/client/dom/content/actions/grips.js"); +const { + Property, +} = require("resource://devtools/client/dom/content/reducers/grips.js"); + +// Implementation +function GripProvider(grips, dispatch) { + this.grips = grips; + this.dispatch = dispatch; +} + +/** + * This object provides data for the tree displayed in the tooltip + * content. + */ +GripProvider.prototype = { + /** + * Fetches properties from the backend. These properties might be + * displayed as child objects in e.g. a tree UI widget. + */ + getChildren(object) { + let grip = object; + if (object instanceof Property) { + grip = this.getValue(object); + } + + if (!grip || !grip.actorID) { + return []; + } + + const props = this.grips.get(grip.actorID); + if (!props) { + // Fetch missing data from the backend. Returning a promise + // from data provider causes the tree to show a spinner. + return this.dispatch(fetchProperties(grip)); + } + + return props; + }, + + hasChildren(object) { + if (object instanceof Property) { + const value = this.getValue(object); + if (!value) { + return false; + } + const grip = value?.getGrip ? value.getGrip() : value; + + let hasChildren = grip.ownPropertyLength > 0; + + if (grip.preview) { + hasChildren = hasChildren || grip.preview.ownPropertiesLength > 0; + } + + if (grip.preview) { + const preview = grip.preview; + const k = preview.kind; + const objectsWithProps = ["DOMNode", "ObjectWithURL"]; + hasChildren = hasChildren || objectsWithProps.includes(k); + hasChildren = hasChildren || (k == "ArrayLike" && !!preview.length); + } + + return grip.type == "object" && hasChildren; + } + + return null; + }, + + getValue(object) { + if (object instanceof Property) { + const value = object.value; + return typeof value.value != "undefined" + ? value.value + : value.getterValue; + } + + return object; + }, + + getLabel(object) { + return object instanceof Property ? object.name : null; + }, + + getKey(object) { + return object instanceof Property ? object.key : null; + }, + + getType(object) { + const grip = object?.getGrip ? object.getGrip() : object; + return grip.class ? grip.class : ""; + }, +}; + +// Exports from this module +exports.GripProvider = GripProvider; diff --git a/devtools/client/dom/content/moz.build b/devtools/client/dom/content/moz.build new file mode 100644 index 0000000000..04e0ac4efc --- /dev/null +++ b/devtools/client/dom/content/moz.build @@ -0,0 +1,18 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "actions", + "components", + "reducers", +] + +DevToolsModules( + "constants.js", + "dom-decorator.js", + "dom-view.js", + "grip-provider.js", + "utils.js", +) diff --git a/devtools/client/dom/content/reducers/filter.js b/devtools/client/dom/content/reducers/filter.js new file mode 100644 index 0000000000..48c63eada7 --- /dev/null +++ b/devtools/client/dom/content/reducers/filter.js @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const constants = require("resource://devtools/client/dom/content/constants.js"); + +/** + * Initial state definition + */ +function getInitialState() { + return ""; +} + +/** + * Filter displayed object properties. + */ +function filter(state = getInitialState(), action) { + if (action.type == constants.SET_VISIBILITY_FILTER) { + return action.filter; + } + + return state; +} + +// Exports from this module +exports.filter = filter; diff --git a/devtools/client/dom/content/reducers/grips.js b/devtools/client/dom/content/reducers/grips.js new file mode 100644 index 0000000000..1413baa1ce --- /dev/null +++ b/devtools/client/dom/content/reducers/grips.js @@ -0,0 +1,123 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const constants = require("resource://devtools/client/dom/content/constants.js"); + +/** + * Initial state definition + */ +function getInitialState() { + return new Map(); +} + +/** + * Maintain a cache of received grip responses from the backend. + */ +function grips(state = getInitialState(), action) { + // This reducer supports only one action, fetching actor properties + // from the backend so, bail out if we are dealing with any other + // action. + if (action.type != constants.FETCH_PROPERTIES) { + return state; + } + + switch (action.status) { + case "start": + return onRequestProperties(state, action); + case "done": + return onReceiveProperties(state, action); + } + + return state; +} + +/** + * Handle requestProperties action + */ +function onRequestProperties(state, action) { + return state; +} + +/** + * Handle receiveProperties action + */ +function onReceiveProperties(cache, action) { + const response = action.response; + const from = response.from; + const className = action.grip?.getGrip + ? action.grip.getGrip().class + : action.grip.class; + + // Properly deal with getters. + mergeProperties(response); + + // Compute list of requested children. + const previewProps = response.preview ? response.preview.ownProperties : null; + const ownProps = response.ownProperties || previewProps || []; + + const props = Object.keys(ownProps).map(key => { + // Array indexes as a special case. We convert any keys that are string + // representations of integers to integers. + if (className === "Array" && isInteger(key)) { + key = parseInt(key, 10); + } + return new Property(key, ownProps[key], key); + }); + + props.sort(sortName); + + // Return new state/map. + const newCache = new Map(cache); + newCache.set(from, props); + + return newCache; +} + +// Helpers + +function mergeProperties(response) { + const { ownProperties } = response; + + // 'safeGetterValues' is new and isn't necessary defined on old grips. + const safeGetterValues = response.safeGetterValues || {}; + + // Merge the safe getter values into one object such that we can use it + // in variablesView. + for (const name of Object.keys(safeGetterValues)) { + if (name in ownProperties) { + const { getterValue, getterPrototypeLevel } = safeGetterValues[name]; + ownProperties[name].getterValue = getterValue; + ownProperties[name].getterPrototypeLevel = getterPrototypeLevel; + } else { + ownProperties[name] = safeGetterValues[name]; + } + } +} + +function sortName(a, b) { + // Display non-enumerable properties at the end. + if (!a.value.enumerable && b.value.enumerable) { + return 1; + } + if (a.value.enumerable && !b.value.enumerable) { + return -1; + } + return a.name > b.name ? 1 : -1; +} + +function isInteger(n) { + // We use parseInt(n, 10) == n to disregard scientific notation e.g. "3e24" + return isFinite(n) && parseInt(n, 10) == n; +} + +function Property(name, value, key) { + this.name = name; + this.value = value; + this.key = key; +} + +// Exports from this module +exports.grips = grips; +exports.Property = Property; diff --git a/devtools/client/dom/content/reducers/index.js b/devtools/client/dom/content/reducers/index.js new file mode 100644 index 0000000000..28461f84a8 --- /dev/null +++ b/devtools/client/dom/content/reducers/index.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + grips, +} = require("resource://devtools/client/dom/content/reducers/grips.js"); +const { + filter, +} = require("resource://devtools/client/dom/content/reducers/filter.js"); + +exports.reducers = { + grips, + filter, +}; diff --git a/devtools/client/dom/content/reducers/moz.build b/devtools/client/dom/content/reducers/moz.build new file mode 100644 index 0000000000..8d98444e34 --- /dev/null +++ b/devtools/client/dom/content/reducers/moz.build @@ -0,0 +1,10 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "filter.js", + "grips.js", + "index.js", +) diff --git a/devtools/client/dom/content/utils.js b/devtools/client/dom/content/utils.js new file mode 100644 index 0000000000..52c1c9b4ef --- /dev/null +++ b/devtools/client/dom/content/utils.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * The default localization just returns the last part of the key + * (all after the last dot). + */ +const DefaultL10N = { + getStr(key) { + const index = key.lastIndexOf("."); + return key.substr(index + 1); + }, +}; + +/** + * The 'l10n' object is set by main.js in case the DOM panel content + * runs within a scope with chrome privileges. + * + * Note that DOM panel content can also run within a scope with no chrome + * privileges, e.g. in an iframe with type 'content' or in a browser tab, + * which allows using our own tools for development. + */ +exports.l10n = window.l10n || DefaultL10N; diff --git a/devtools/client/dom/index.html b/devtools/client/dom/index.html new file mode 100644 index 0000000000..73e45fc71e --- /dev/null +++ b/devtools/client/dom/index.html @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<!DOCTYPE html> +<html dir=""> + <head> + <meta charset="utf-8" /> + + <link + href="chrome://devtools/content/dom/content/dom-view.css" + rel="stylesheet" + /> + <link + href="chrome://devtools-jsonview-styles/content/toolbar.css" + rel="stylesheet" + /> + <link + href="chrome://devtools/content/shared/components/tree/TreeView.css" + rel="stylesheet" + /> + + <script src="chrome://devtools/content/shared/theme-switching.js"></script> + </head> + <body class="theme-body" role="application"> + <div id="content"></div> + <script src="./main.js"></script> + </body> +</html> diff --git a/devtools/client/dom/main.js b/devtools/client/dom/main.js new file mode 100644 index 0000000000..d7fbd5dc5f --- /dev/null +++ b/devtools/client/dom/main.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { BrowserLoader } = ChromeUtils.import( + "resource://devtools/shared/loader/browser-loader.js" +); + +// Module Loader +const require = BrowserLoader({ + baseURI: "resource://devtools/client/dom/", + window, +}).require; + +XPCOMUtils.defineConstant(this, "require", require); + +// Localization +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +this.l10n = new LocalizationHelper("devtools/client/locales/dom.properties"); + +// Load DOM panel content +require("resource://devtools/client/dom/content/dom-view.js"); diff --git a/devtools/client/dom/moz.build b/devtools/client/dom/moz.build new file mode 100644 index 0000000000..6e4380260a --- /dev/null +++ b/devtools/client/dom/moz.build @@ -0,0 +1,17 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +BROWSER_CHROME_MANIFESTS += ["test/browser.ini"] + +DIRS += [ + "content", +] + +DevToolsModules( + "panel.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "DOM") diff --git a/devtools/client/dom/panel.js b/devtools/client/dom/panel.js new file mode 100644 index 0000000000..b7b7ef81ec --- /dev/null +++ b/devtools/client/dom/panel.js @@ -0,0 +1,278 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +loader.lazyRequireGetter( + this, + "openContentLink", + "resource://devtools/client/shared/link.js", + true +); + +/** + * This object represents DOM panel. It's responsibility is to + * render Document Object Model of the current debugger target. + */ +function DomPanel(iframeWindow, toolbox, commands) { + this.panelWin = iframeWindow; + this._toolbox = toolbox; + this._commands = commands; + + this.onContentMessage = this.onContentMessage.bind(this); + this.onPanelVisibilityChange = this.onPanelVisibilityChange.bind(this); + + this.pendingRequests = new Map(); + + EventEmitter.decorate(this); +} + +DomPanel.prototype = { + /** + * Open is effectively an asynchronous constructor. + * + * @return object + * A promise that is resolved when the DOM panel completes opening. + */ + async open() { + // Wait for the retrieval of root object properties before resolving open + const onGetProperties = new Promise(resolve => { + this._resolveOpen = resolve; + }); + + await this.initialize(); + + await onGetProperties; + + return this; + }, + + // Initialization + + async initialize() { + this.panelWin.addEventListener( + "devtools/content/message", + this.onContentMessage, + true + ); + + this._toolbox.on("select", this.onPanelVisibilityChange); + + // onTargetAvailable is mandatory when calling watchTargets + this._onTargetAvailable = () => {}; + this._onTargetSelected = this._onTargetSelected.bind(this); + await this._commands.targetCommand.watchTargets({ + types: [this._commands.targetCommand.TYPES.FRAME], + onAvailable: this._onTargetAvailable, + onSelected: this._onTargetSelected, + }); + + this.onResourceAvailable = this.onResourceAvailable.bind(this); + await this._commands.resourceCommand.watchResources( + [this._commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this.onResourceAvailable, + } + ); + + // Export provider object with useful API for DOM panel. + const provider = { + getToolbox: this.getToolbox.bind(this), + getPrototypeAndProperties: this.getPrototypeAndProperties.bind(this), + openLink: this.openLink.bind(this), + // Resolve DomPanel.open once the object properties are fetched + onPropertiesFetched: () => { + if (this._resolveOpen) { + this._resolveOpen(); + this._resolveOpen = null; + } + }, + }; + + exportIntoContentScope(this.panelWin, provider, "DomProvider"); + }, + + destroy() { + if (this._destroyed) { + return; + } + this._destroyed = true; + + this._commands.targetCommand.unwatchTargets({ + types: [this._commands.targetCommand.TYPES.FRAME], + onAvailable: this._onTargetAvailable, + onSelected: this._onTargetSelected, + }); + this._commands.resourceCommand.unwatchResources( + [this._commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { onAvailable: this.onResourceAvailable } + ); + this._toolbox.off("select", this.onPanelVisibilityChange); + + this.emit("destroyed"); + }, + + // Events + + refresh() { + // Do not refresh if the panel isn't visible. + if (!this.isPanelVisible()) { + return; + } + + // Do not refresh if it isn't necessary. + if (!this.shouldRefresh) { + return; + } + + // Alright reset the flag we are about to refresh the panel. + this.shouldRefresh = false; + + this.getRootGrip().then(rootGrip => { + this.postContentMessage("initialize", rootGrip); + }); + }, + + /** + * Make sure the panel is refreshed, either when navigation occurs or when a frame is + * selected in the iframe picker. + * The panel is refreshed immediately if it's currently selected or lazily when the user + * actually selects it. + */ + forceRefresh() { + this.shouldRefresh = true; + // This will end up calling scriptCommand execute method to retrieve the `window` grip + // on targetCommand.selectedTargetFront. + this.refresh(); + }, + + _onTargetSelected({ targetFront }) { + this.forceRefresh(); + }, + + onResourceAvailable(resources) { + for (const resource of resources) { + // Only consider top level document, and ignore remote iframes top document + if ( + resource.resourceType === + this._commands.resourceCommand.TYPES.DOCUMENT_EVENT && + resource.name === "dom-complete" && + resource.targetFront.isTopLevel + ) { + this.forceRefresh(); + } + } + }, + + /** + * Make sure the panel is refreshed (if needed) when it's selected. + */ + onPanelVisibilityChange() { + this.refresh(); + }, + + // Helpers + + /** + * Return true if the DOM panel is currently selected. + */ + isPanelVisible() { + return this._toolbox.currentToolId === "dom"; + }, + + async getPrototypeAndProperties(objectFront) { + if (!objectFront.actorID) { + console.error("No actor!", objectFront); + throw new Error("Failed to get object front."); + } + + // Bail out if target doesn't exist (toolbox maybe closed already). + if (!this.currentTarget) { + return null; + } + + // Check for a previously stored request for grip. + let request = this.pendingRequests.get(objectFront.actorID); + + // If no request is in progress create a new one. + if (!request) { + request = objectFront.getPrototypeAndProperties(); + this.pendingRequests.set(objectFront.actorID, request); + } + + const response = await request; + this.pendingRequests.delete(objectFront.actorID); + + // Fire an event about not having any pending requests. + if (!this.pendingRequests.size) { + this.emit("no-pending-requests"); + } + + return response; + }, + + openLink(url) { + openContentLink(url); + }, + + async getRootGrip() { + const { result } = await this._toolbox.commands.scriptCommand.execute( + "window" + ); + return result; + }, + + postContentMessage(type, args) { + const data = { + type, + args, + }; + + const event = new this.panelWin.MessageEvent("devtools/chrome/message", { + bubbles: true, + cancelable: true, + data, + }); + + this.panelWin.dispatchEvent(event); + }, + + onContentMessage(event) { + const data = event.data; + const method = data.type; + if (typeof this[method] == "function") { + this[method](data.args); + } + }, + + getToolbox() { + return this._toolbox; + }, + + get currentTarget() { + return this._toolbox.target; + }, +}; + +// Helpers + +function exportIntoContentScope(win, obj, defineAs) { + const clone = Cu.createObjectIn(win, { + defineAs, + }); + + const props = Object.getOwnPropertyNames(obj); + for (let i = 0; i < props.length; i++) { + const propName = props[i]; + const propValue = obj[propName]; + if (typeof propValue == "function") { + Cu.exportFunction(propValue, clone, { + defineAs: propName, + }); + } + } +} + +// Exports from this module +exports.DomPanel = DomPanel; diff --git a/devtools/client/dom/test/browser.ini b/devtools/client/dom/test/browser.ini new file mode 100644 index 0000000000..379ca86471 --- /dev/null +++ b/devtools/client/dom/test/browser.ini @@ -0,0 +1,18 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + head.js + page_array.html + page_basic.html + page_dom_nodes.html + !/devtools/client/shared/test/shared-head.js + !/devtools/client/shared/test/telemetry-test-helpers.js + +[browser_dom_array.js] +[browser_dom_basic.js] +[browser_dom_fission_target_switching.js] +[browser_dom_iframe_picker.js] +[browser_dom_nodes_highlight.js] +[browser_dom_nodes_select.js] +[browser_dom_refresh.js] diff --git a/devtools/client/dom/test/browser_dom_array.js b/devtools/client/dom/test/browser_dom_array.js new file mode 100644 index 0000000000..9ef35b1717 --- /dev/null +++ b/devtools/client/dom/test/browser_dom_array.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE_URL = URL_ROOT + "page_array.html"; +const TEST_ARRAY = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", +]; + +/** + * Basic test that checks content of the DOM panel. + */ +add_task(async function () { + info("Test DOM Panel Array Expansion started"); + + const { panel } = await addTestTab(TEST_PAGE_URL); + + // Expand specified row and wait till children are displayed. + await expandRow(panel, "_a"); + + // Verify that children is displayed now. + const childRows = getAllRowsForLabel(panel, "_a"); + + const item = childRows.pop(); + is(item.name, "length", "length property is correct"); + is(item.value, 26, "length property value is 26"); + + let i = 0; + for (const name in childRows) { + const row = childRows[name]; + + is( + parseInt(name, 10), + i++, + `index ${name} is correct and sorted into the correct position` + ); + ok(typeof row.name === "number", "array index is displayed as a number"); + is(TEST_ARRAY[name], row.value, `value for array[${name}] is ${row.value}`); + } +}); diff --git a/devtools/client/dom/test/browser_dom_basic.js b/devtools/client/dom/test/browser_dom_basic.js new file mode 100644 index 0000000000..c6b962015f --- /dev/null +++ b/devtools/client/dom/test/browser_dom_basic.js @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE_URL = URL_ROOT + "page_basic.html"; + +/** + * Basic test that checks content of the DOM panel. + */ +add_task(async function () { + info("Test DOM panel basic started"); + + const { panel } = await addTestTab(TEST_PAGE_URL); + + // Expand specified row and wait till children are displayed. + await expandRow(panel, "_a"); + + // Verify that child is displayed now. + const childRow = getRowByLabel(panel, "_data"); + ok(childRow, "Child row must exist"); +}); diff --git a/devtools/client/dom/test/browser_dom_fission_target_switching.js b/devtools/client/dom/test/browser_dom_fission_target_switching.js new file mode 100644 index 0000000000..4393a7d842 --- /dev/null +++ b/devtools/client/dom/test/browser_dom_fission_target_switching.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test top-level target switching in the DOM panel. + +const PARENT_PROCESS_URI = "about:robots"; +const CONTENT_PROCESS_URI = URL_ROOT_SSL + "page_basic.html"; + +add_task(async function () { + // We use about:robots as the starting page because it will run in the parent process. + // Navigating from that page to a regular content page will always trigger a target + // switch, with or without fission. + + info("Open a page that runs in the parent process"); + const { panel } = await addTestTab(PARENT_PROCESS_URI); + + const _aProperty = getRowByLabel(panel, "_a"); + let buttonProperty = getRowByLabel(panel, "button"); + + ok(!_aProperty, "There is no _a property on the about:robots page"); + ok(buttonProperty, "There is, however, a button property on this page"); + + info("Navigate to a page that runs in the content process"); + // Wait for the DOM panel to refresh. + const store = getReduxStoreFromPanel(panel); + const onPropertiesFetched = waitForDispatch(store, "FETCH_PROPERTIES"); + // Also wait for the toolbox to switch to the new target, to avoid hanging requests when + // the test ends. + await navigateTo(CONTENT_PROCESS_URI); + await onPropertiesFetched; + + await waitFor(() => getRowByLabel(panel, "_a")); + ok(true, "This time, the _a property exists on this content process page"); + + buttonProperty = getRowByLabel(panel, "button"); + ok( + !buttonProperty, + "There is, however, no more button property on this page" + ); +}); diff --git a/devtools/client/dom/test/browser_dom_iframe_picker.js b/devtools/client/dom/test/browser_dom_iframe_picker.js new file mode 100644 index 0000000000..60e7747386 --- /dev/null +++ b/devtools/client/dom/test/browser_dom_iframe_picker.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that the DOM panel works as expected when a specific frame is selected in the +// iframe picker. + +const TEST_URL = `https://example.com/document-builder.sjs?html= + <h1>top_level</h1> + <iframe src="https://example.org/document-builder.sjs?html=in_iframe"></iframe>`; + +add_task(async function () { + const { panel } = await addTestTab(TEST_URL); + const toolbox = panel._toolbox; + + info("Wait until the iframe picker button is visible"); + try { + await waitFor(() => toolbox.doc.getElementById("command-button-frames")); + } catch (e) { + if (isFissionEnabled() && !isEveryFrameTargetEnabled()) { + ok( + true, + "Remote frames are not displayed in iframe picker if Fission is enabled but EFT is not" + ); + return; + } + throw e; + } + + info("Check `document` property when no specific frame is focused"); + let documentPropertyValue = getDocumentPropertyValue(panel); + + ok( + documentPropertyValue.startsWith("HTMLDocument https://example.com"), + `Got expected "document" value (${documentPropertyValue})` + ); + + info( + "Select the frame in the iframe picker and check that the document property is updated" + ); + // Wait for the DOM panel to refresh. + const store = getReduxStoreFromPanel(panel); + let onPropertiesFetched = waitForDispatch(store, "FETCH_PROPERTIES"); + + const exampleOrgFrame = toolbox.doc.querySelector( + "#toolbox-frame-menu .menuitem:last-child .command" + ); + + exampleOrgFrame.click(); + await onPropertiesFetched; + + documentPropertyValue = getDocumentPropertyValue(panel); + ok( + documentPropertyValue.startsWith("HTMLDocument https://example.org"), + `Got expected "document" value (${documentPropertyValue})` + ); + + info( + "Select the top-level frame and check that the document property is updated" + ); + onPropertiesFetched = waitForDispatch(store, "FETCH_PROPERTIES"); + + const exampleComFrame = toolbox.doc.querySelector( + "#toolbox-frame-menu .menuitem:first-child .command" + ); + exampleComFrame.click(); + await onPropertiesFetched; + + documentPropertyValue = getDocumentPropertyValue(panel); + ok( + documentPropertyValue.startsWith("HTMLDocument https://example.com"), + `Got expected "document" value (${documentPropertyValue})` + ); +}); + +function getDocumentPropertyValue(panel) { + return getRowByLabel(panel, "document").querySelector("td.treeValueCell") + .textContent; +} diff --git a/devtools/client/dom/test/browser_dom_nodes_highlight.js b/devtools/client/dom/test/browser_dom_nodes_highlight.js new file mode 100644 index 0000000000..311a52b30c --- /dev/null +++ b/devtools/client/dom/test/browser_dom_nodes_highlight.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE_URL = URL_ROOT + "page_dom_nodes.html"; + +/** + * Checks that hovering nodes highlights them in the content page + */ +add_task(async function () { + info("Test DOM panel node highlight started"); + + const { panel, tab } = await addTestTab(TEST_PAGE_URL); + const toolbox = await gDevTools.getToolboxForTab(tab); + const highlighter = toolbox.getHighlighter(); + + const tests = [ + { + expected: "h1", + getNode: async () => { + return getRowByIndex(panel, 0).querySelector(".objectBox-node"); + }, + }, + { + expected: "h2", + getNode: async () => { + info("Expand specified row and wait till children are displayed"); + await expandRow(panel, "B"); + return getRowByIndex(panel, 1).querySelector(".objectBox-node"); + }, + }, + ]; + + for (const test of tests) { + info(`Get the NodeFront for ${test.expected}`); + const node = await test.getNode(); + + info("Highlight the node by moving the cursor on it"); + const onHighlighterShown = highlighter.waitForHighlighterShown(); + EventUtils.synthesizeMouseAtCenter( + node, + { + type: "mouseover", + }, + node.ownerDocument.defaultView + ); + const { nodeFront } = await onHighlighterShown; + is( + nodeFront.displayName, + test.expected, + "The correct node was highlighted" + ); + + info("Unhighlight the node by moving the cursor away from it"); + const onHighlighterHidden = highlighter.waitForHighlighterHidden(); + const btn = toolbox.doc.querySelector("#toolbox-meatball-menu-button"); + EventUtils.synthesizeMouseAtCenter( + btn, + { + type: "mouseover", + }, + btn.ownerDocument.defaultView + ); + + const { nodeFront: unhighlightedNode } = await onHighlighterHidden; + is( + unhighlightedNode.displayName, + test.expected, + "The node was unhighlighted" + ); + } +}); diff --git a/devtools/client/dom/test/browser_dom_nodes_select.js b/devtools/client/dom/test/browser_dom_nodes_select.js new file mode 100644 index 0000000000..7c71f2fde1 --- /dev/null +++ b/devtools/client/dom/test/browser_dom_nodes_select.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE_URL = URL_ROOT + "page_dom_nodes.html"; + +/** + * Checks whether hovering nodes highlight them in the content page + */ +add_task(async function () { + info("Test DOM panel node highlight started"); + + const { panel, tab } = await addTestTab(TEST_PAGE_URL); + const toolbox = await gDevTools.getToolboxForTab(tab); + const node = getRowByIndex(panel, 0); + + // Loading the inspector panel at first, to make it possible to listen for + // new node selections + + await toolbox.loadTool("inspector"); + const inspector = toolbox.getPanel("inspector"); + + const openInInspectorIcon = node.querySelector(".open-inspector"); + ok(node !== null, "Node was logged as expected"); + + info( + "Clicking on the inspector icon and waiting for the " + + "inspector to be selected" + ); + const onInspectorSelected = toolbox.once("inspector-selected"); + const onInspectorUpdated = inspector.once("inspector-updated"); + const onNewNode = toolbox.selection.once("new-node-front"); + + openInInspectorIcon.click(); + + await onInspectorSelected; + await onInspectorUpdated; + const nodeFront = await onNewNode; + + ok(true, "Inspector selected and new node got selected"); + is(nodeFront.displayName, "h1", "The expected node was selected"); +}); diff --git a/devtools/client/dom/test/browser_dom_refresh.js b/devtools/client/dom/test/browser_dom_refresh.js new file mode 100644 index 0000000000..a82eb0accc --- /dev/null +++ b/devtools/client/dom/test/browser_dom_refresh.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE_URL = URL_ROOT + "page_basic.html"; + +/** + * Basic test that checks the Refresh action in DOM panel. + */ +add_task(async function () { + info("Test DOM panel basic started"); + + const { panel } = await addTestTab(TEST_PAGE_URL); + + // Create a new variable in the page scope and refresh the panel. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.wrappedJSObject._b = 10; + }); + + await refreshPanel(panel); + + // Verify that the variable is displayed now. + const row = getRowByLabel(panel, "_b"); + ok(row, "New variable must be displayed"); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + delete content.wrappedJSObject._b; + }); +}); diff --git a/devtools/client/dom/test/head.js b/devtools/client/dom/test/head.js new file mode 100644 index 0000000000..93afb2fe09 --- /dev/null +++ b/devtools/client/dom/test/head.js @@ -0,0 +1,201 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint no-unused-vars: [2, {"vars": "local", "args": "none"}] */ + +"use strict"; + +// shared-head.js handles imports, constants, and utility functions +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +// DOM panel actions. +const constants = require("resource://devtools/client/dom/content/constants.js"); + +// Uncomment this pref to dump all devtools emitted events to the console. +// Services.prefs.setBoolPref("devtools.dom.enabled", true); + +// Enable the DOM panel +Services.prefs.setBoolPref("devtools.dom.enabled", true); + +registerCleanupFunction(() => { + info("finish() was called, cleaning up..."); + Services.prefs.clearUserPref("devtools.dump.emit"); + Services.prefs.clearUserPref("devtools.dom.enabled"); +}); + +/** + * Add a new test tab in the browser and load the given url. + * @param {String} url + * The url to be loaded in the new tab + * @return a promise that resolves to the tab object when + * the url is loaded + */ +async function addTestTab(url) { + info("Adding a new test tab with URL: '" + url + "'"); + + const tab = await addTab(url); + + // Select the DOM panel and wait till it's initialized. + const panel = await initDOMPanel(tab); + + // FETCH_PROPERTIES should be fired during the call to initDOMPanel + // But note that this behavior changed during a change in webconsole + // initialization. So this might be racy. + const doc = panel.panelWin.document; + const nodes = [...doc.querySelectorAll(".treeLabel")]; + ok(!!nodes.length, "The DOM panel is already populated"); + + return { + tab, + browser: tab.linkedBrowser, + panel, + }; +} + +/** + * Open the DOM panel for the given tab. + * + * @param {Element} tab + * Optional tab element for which you want open the DOM panel. + * The default tab is taken from the global variable |tab|. + * @return a promise that is resolved once the web console is open. + */ +async function initDOMPanel(tab) { + tab = tab || gBrowser.selectedTab; + const toolbox = await gDevTools.showToolboxForTab(tab, { toolId: "dom" }); + const panel = toolbox.getCurrentPanel(); + return panel; +} + +/** + * Synthesize asynchronous click event (with clean stack trace). + */ +function synthesizeMouseClickSoon(panel, element) { + return new Promise(resolve => { + executeSoon(() => { + EventUtils.synthesizeMouse(element, 2, 2, {}, panel.panelWin); + resolve(); + }); + }); +} + +/** + * Returns tree row with specified label. + */ +function getRowByLabel(panel, text) { + const doc = panel.panelWin.document; + const labels = [...doc.querySelectorAll(".treeLabel")]; + const label = labels.find(node => node.textContent == text); + return label ? label.closest(".treeRow") : null; +} + +/** + * Returns tree row with specified index. + */ +function getRowByIndex(panel, id) { + const doc = panel.panelWin.document; + const labels = [...doc.querySelectorAll(".treeLabel")]; + const label = labels.find((node, i) => i == id); + return label ? label.closest(".treeRow") : null; +} + +/** + * Returns the children (tree row text) of the specified object name as an + * array. + */ +function getAllRowsForLabel(panel, text) { + let rootObjectLevel; + let node; + const result = []; + const doc = panel.panelWin.document; + const nodes = [...doc.querySelectorAll(".treeLabel")]; + + // Find the label (object name) for which we want the children. We remove + // nodes from the start of the array until we reach the property. The children + // are then at the start of the array. + while (true) { + node = nodes.shift(); + + if (!node || node.textContent === text) { + rootObjectLevel = node.getAttribute("data-level"); + break; + } + } + + // Return an empty array if the node is not found. + if (!node) { + return result; + } + + // Now get the children. + for (node of nodes) { + const level = node.getAttribute("data-level"); + + if (level > rootObjectLevel) { + result.push({ + name: normalizeTreeValue(node.textContent), + value: normalizeTreeValue( + node.parentNode.nextElementSibling.textContent + ), + }); + } else { + break; + } + } + + return result; +} + +/** + * Strings in the tree are in the form ""a"" and numbers in the form "1". We + * normalize these values by converting ""a"" to "a" and "1" to 1. + * + * @param {String} value + * The value to normalize. + * @return {String|Number} + * The normalized value. + */ +function normalizeTreeValue(value) { + if (value === `""`) { + return ""; + } + if (value.startsWith(`"`) && value.endsWith(`"`)) { + return value.substr(1, value.length - 2); + } + if (isFinite(value) && parseInt(value, 10) == value) { + return parseInt(value, 10); + } + + return value; +} + +/** + * Expands elements with given label and waits till + * children are received from the backend. + */ +function expandRow(panel, labelText) { + const row = getRowByLabel(panel, labelText); + return synthesizeMouseClickSoon(panel, row).then(() => { + // Wait till children (properties) are fetched + // from the backend. + const store = getReduxStoreFromPanel(panel); + return waitForDispatch(store, "FETCH_PROPERTIES"); + }); +} + +function refreshPanel(panel) { + const doc = panel.panelWin.document; + const button = doc.querySelector("#dom-refresh-button"); + return synthesizeMouseClickSoon(panel, button).then(() => { + // Wait till children (properties) are fetched + // from the backend. + const store = getReduxStoreFromPanel(panel); + return waitForDispatch(store, "FETCH_PROPERTIES"); + }); +} + +function getReduxStoreFromPanel(panel) { + return panel.panelWin.view.mainFrame.store; +} diff --git a/devtools/client/dom/test/page_array.html b/devtools/client/dom/test/page_array.html new file mode 100644 index 0000000000..848d6c4ce5 --- /dev/null +++ b/devtools/client/dom/test/page_array.html @@ -0,0 +1,19 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> +<html> + <head> + <meta charset="utf-8"/> + <title>DOM Panel Array Expansion Test Page</title> + </head> + <body> + <h2>DOM Panel Array Expansion Test Page</h2> + <script type="text/javascript"> + "use strict"; + window._a = [ + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", + "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", + ]; + </script> + </body> +</html> diff --git a/devtools/client/dom/test/page_basic.html b/devtools/client/dom/test/page_basic.html new file mode 100644 index 0000000000..170b3112a6 --- /dev/null +++ b/devtools/client/dom/test/page_basic.html @@ -0,0 +1,15 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> +<html> + <head> + <meta charset="utf-8"/> + <title>DOM test page</title> + </head> + <body> + <script type="text/javascript"> + "use strict"; + window._a = {_data: "test"}; + </script> + </body> +</html> diff --git a/devtools/client/dom/test/page_dom_nodes.html b/devtools/client/dom/test/page_dom_nodes.html new file mode 100644 index 0000000000..64e78d08db --- /dev/null +++ b/devtools/client/dom/test/page_dom_nodes.html @@ -0,0 +1,18 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"/> + <title>DOM test hovering nodes page</title> + </head> + <body> + <h1 id="a">Node highlight test</h1> + <h2 id="b">Node highlight test inside object</h2> + <script> + "use strict"; + window.A = document.getElementById("a"); + window.B = {_data: document.getElementById("b")}; + </script> + </body> +</html> |