diff options
Diffstat (limited to 'devtools/client/dom')
31 files changed, 1574 insertions, 0 deletions
diff --git a/devtools/client/dom/.eslintrc.js b/devtools/client/dom/.eslintrc.js new file mode 100644 index 000000000..1ad36e780 --- /dev/null +++ b/devtools/client/dom/.eslintrc.js @@ -0,0 +1,17 @@ +"use strict"; + +module.exports = { + "globals": { + "XMLHttpRequest": true, + "window": true, + "define": true, + "addEventListener": true, + "document": true, + "dispatchEvent": true, + "MessageEvent": true + }, + "rules": { + "indent": "off", + "padded-blocks": "off", + } +}; diff --git a/devtools/client/dom/content/actions/filter.js b/devtools/client/dom/content/actions/filter.js new file mode 100644 index 000000000..3fac9d278 --- /dev/null +++ b/devtools/client/dom/content/actions/filter.js @@ -0,0 +1,21 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("../constants"); + +/** + * Used to filter DOM panel content. + */ +function setVisibilityFilter(filter) { + return { + filter: 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 000000000..23d4fc895 --- /dev/null +++ b/devtools/client/dom/content/actions/grips.js @@ -0,0 +1,54 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("../constants"); + +/** + * Used to fetch grip prototype and properties from the backend. + */ +function requestProperties(grip) { + return { + grip: 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: grip, + type: constants.FETCH_PROPERTIES, + status: "end", + response: response, + error: error + }; +} + +/** + * Used to get properties from the backend and fire an action + * when they are received. + */ +function fetchProperties(grip) { + return dispatch => { + // dispatch(requestProperties(grip)); + + // Use 'DomProvider' object exposed from the chrome scope. + return DomProvider.getPrototypeAndProperties(grip).then(response => { + dispatch(receiveProperties(grip, response)); + }); + }; +} + +// 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 000000000..6454c00cc --- /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/dom-tree.js b/devtools/client/dom/content/components/dom-tree.js new file mode 100644 index 000000000..ef529ac3f --- /dev/null +++ b/devtools/client/dom/content/components/dom-tree.js @@ -0,0 +1,91 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("devtools/client/shared/vendor/react"); +const { connect } = require("devtools/client/shared/vendor/react-redux"); + +// Reps +const { createFactories } = require("devtools/client/shared/components/reps/rep-utils"); +const TreeView = React.createFactory(require("devtools/client/shared/components/tree/tree-view")); +const { Rep } = createFactories(require("devtools/client/shared/components/reps/rep")); +const { Grip } = require("devtools/client/shared/components/reps/grip"); + +// DOM Panel +const { GripProvider } = require("../grip-provider"); +const { DomDecorator } = require("../dom-decorator"); + +// Shortcuts +const PropTypes = React.PropTypes; + +/** + * Renders DOM panel tree. + */ +var DomTree = React.createClass({ + displayName: "DomTree", + + propTypes: { + object: PropTypes.any, + filter: PropTypes.string, + dispatch: PropTypes.func.isRequired, + grips: PropTypes.object, + }, + + /** + * Filter DOM properties. Return true if the object + * should be visible in the tree. + */ + onFilter: function (object) { + if (!this.props.filter) { + return true; + } + + return (object.name && object.name.indexOf(this.props.filter) > -1); + }, + + /** + * Render DOM panel content + */ + render: function () { + let columns = [{ + "id": "value" + }]; + + // 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. + let renderValue = props => { + return Rep(Object.assign({}, props, { + defaultRep: Grip, + cropLimit: 50, + })); + }; + + return ( + TreeView({ + object: this.props.object, + provider: new GripProvider(this.props.grips, this.props.dispatch), + decorator: new DomDecorator(), + mode: "short", + columns: columns, + renderValue: renderValue, + onFilter: this.onFilter + }) + ); + } +}); + +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/main-frame.js b/devtools/client/dom/content/components/main-frame.js new file mode 100644 index 000000000..d786314e2 --- /dev/null +++ b/devtools/client/dom/content/components/main-frame.js @@ -0,0 +1,63 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("devtools/client/shared/vendor/react"); +const { connect } = require("devtools/client/shared/vendor/react-redux"); + +// DOM Panel +const DomTree = React.createFactory(require("./dom-tree")); +const MainToolbar = React.createFactory(require("./main-toolbar")); + +// Shortcuts +const { div } = React.DOM; +const PropTypes = React.PropTypes; + +/** + * Renders basic layout of the DOM panel. The DOM panel cotent consists + * from two main parts: toolbar and tree. + */ +var MainFrame = React.createClass({ + displayName: "MainFrame", + + propTypes: { + object: PropTypes.any, + filter: PropTypes.string, + dispatch: PropTypes.func.isRequired, + }, + + /** + * Render DOM panel content + */ + render: function () { + return ( + div({className: "mainFrame"}, + MainToolbar({ + dispatch: this.props.dispatch, + object: this.props.object + }), + div({className: "treeTableBox"}, + DomTree({ + object: this.props.object, + filter: this.props.filter, + }) + ) + ) + ); + } +}); + +// 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/main-toolbar.js b/devtools/client/dom/content/components/main-toolbar.js new file mode 100644 index 000000000..c44a6b4ca --- /dev/null +++ b/devtools/client/dom/content/components/main-toolbar.js @@ -0,0 +1,66 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 React = require("devtools/client/shared/vendor/react"); +const { l10n } = require("../utils"); + +// Reps +const { createFactories } = require("devtools/client/shared/components/reps/rep-utils"); +const { Toolbar, ToolbarButton } = createFactories(require("devtools/client/jsonview/components/reps/toolbar")); + +// DOM Panel +const SearchBox = React.createFactory(require("devtools/client/shared/components/search-box")); + +// Actions +const { fetchProperties } = require("../actions/grips"); +const { setVisibilityFilter } = require("../actions/filter"); + +// Shortcuts +const PropTypes = React.PropTypes; + +/** + * This template is responsible for rendering a toolbar + * within the 'Headers' panel. + */ +var MainToolbar = React.createClass({ + displayName: "MainToolbar", + + propTypes: { + object: PropTypes.any.isRequired, + dispatch: PropTypes.func.isRequired, + }, + + onRefresh: function () { + this.props.dispatch(fetchProperties(this.props.object)); + }, + + onSearch: function (value) { + this.props.dispatch(setVisibilityFilter(value)); + }, + + render: function () { + return ( + Toolbar({}, + ToolbarButton({ + className: "btn refresh", + onClick: this.onRefresh}, + l10n.getStr("dom.refresh") + ), + SearchBox({ + delay: 250, + onChange: this.onSearch, + placeholder: l10n.getStr("dom.filterDOMPanel"), + type: "filter" + }) + ) + ); + } +}); + +// 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 000000000..0fa1f8089 --- /dev/null +++ b/devtools/client/dom/content/components/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( + 'dom-tree.js', + 'main-frame.js', + 'main-toolbar.js' +) diff --git a/devtools/client/dom/content/constants.js b/devtools/client/dom/content/constants.js new file mode 100644 index 000000000..f06ca8512 --- /dev/null +++ b/devtools/client/dom/content/constants.js @@ -0,0 +1,9 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 000000000..4042df8d3 --- /dev/null +++ b/devtools/client/dom/content/dom-decorator.js @@ -0,0 +1,50 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("./reducers/grips"); + +// Implementation + +function DomDecorator() { +} + +/** + * Decorator for DOM panel tree component. It's responsible for + * appending an icon to read only properties. + */ +DomDecorator.prototype = { + getRowClass: function (object) { + if (object instanceof Property) { + let value = object.value; + let 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: function (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 000000000..631f8c536 --- /dev/null +++ b/devtools/client/dom/content/dom-view.css @@ -0,0 +1,118 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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; +} + +.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/firebug/read-only.svg") no-repeat; + background-position: 1px 5px; + background-size: 10px 10px; +} + +.treeTable tr:not(.writable) td.treeValueCell:dir(rtl) { + background-position-x: right 1px; +} + +/* Non-enumerable properties are grayed out */ +.treeTable tr:not(.enumerable) td.treeValueCell { + opacity: 0.7; +} + +.treeTable > tbody > tr > td { + border-bottom: 1px solid #EFEFEF; +} + +/* 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; +} + +/******************************************************************************/ +/* Search box */ +.devtools-searchbox { + float: right; +} + +.devtools-searchbox:dir(rtl) { + float: left; +} + +/******************************************************************************/ +/* Theme Dark */ + +.theme-dark .treeTable > tbody > tr > td { + border-bottom: none; +} + +.theme-dark body { + background-color: var(--theme-body-background); +} diff --git a/devtools/client/dom/content/dom-view.js b/devtools/client/dom/content/dom-view.js new file mode 100644 index 000000000..b0ea11dee --- /dev/null +++ b/devtools/client/dom/content/dom-view.js @@ -0,0 +1,65 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("devtools/client/shared/vendor/react"); +const ReactDOM = require("devtools/client/shared/vendor/react-dom"); +const { Provider } = require("devtools/client/shared/vendor/react-redux"); +const { combineReducers } = require("devtools/client/shared/vendor/redux"); + +// DOM Panel +const MainFrame = React.createFactory(require("./components/main-frame")); + +// Store +const createStore = require("devtools/client/shared/redux/create-store")({ + log: false +}); + +const { reducers } = require("./reducers/index"); +const store = createStore(combineReducers(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: function (rootGrip) { + let content = document.querySelector("#content"); + let mainFrame = MainFrame({ + object: rootGrip, + }); + + // Render top level component + let provider = React.createElement(Provider, { + store: this.store + }, mainFrame); + + this.mainFrame = ReactDOM.render(provider, content); + }, + + onMessage: function (event) { + let data = event.data; + let 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 throught: |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 000000000..bcda1ff18 --- /dev/null +++ b/devtools/client/dom/content/grip-provider.js @@ -0,0 +1,97 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("./actions/grips"); +const { Property } = require("./reducers/grips"); + +// 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: function (object) { + let grip = object; + if (object instanceof Property) { + grip = this.getValue(object); + } + + if (!grip || !grip.actor) { + return []; + } + + let props = this.grips.get(grip.actor); + 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: function (object) { + if (object instanceof Property) { + let value = this.getValue(object); + if (!value) { + return false; + } + + let hasChildren = value.ownPropertyLength > 0; + + if (value.preview) { + hasChildren = hasChildren || value.preview.ownPropertiesLength > 0; + } + + if (value.preview) { + let preview = value.preview; + let k = preview.kind; + let objectsWithProps = ["DOMNode", "ObjectWithURL"]; + hasChildren = hasChildren || (objectsWithProps.indexOf(k) != -1); + hasChildren = hasChildren || (k == "ArrayLike" && preview.length > 0); + } + + return (value.type == "object" && hasChildren); + } + + return null; + }, + + getValue: function (object) { + if (object instanceof Property) { + let value = object.value; + return (typeof value.value != "undefined") ? value.value : + value.getterValue; + } + + return object; + }, + + getLabel: function (object) { + return (object instanceof Property) ? object.name : null; + }, + + getKey: function (object) { + return (object instanceof Property) ? object.key : null; + }, + + getType: function (object) { + return object.class ? object.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 000000000..b4a9c76bf --- /dev/null +++ b/devtools/client/dom/content/moz.build @@ -0,0 +1,19 @@ +# 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.css', + '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 000000000..3eb5bd3fc --- /dev/null +++ b/devtools/client/dom/content/reducers/filter.js @@ -0,0 +1,29 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("../constants"); + +/** + * 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 000000000..c7d589434 --- /dev/null +++ b/devtools/client/dom/content/reducers/grips.js @@ -0,0 +1,123 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("../constants"); + +/** + * 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 "end": + return onReceiveProperties(state, action); + } + + return state; +} + +/** + * Handle requestProperties action + */ +function onRequestProperties(state, action) { + return state; +} + +/** + * Handle receiveProperties action + */ +function onReceiveProperties(cache, action) { + let response = action.response; + let from = response.from; + let className = action.grip.class; + + // Properly deal with getters. + mergeProperties(response); + + // Compute list of requested children. + let previewProps = response.preview ? response.preview.ownProperties : null; + let ownProps = response.ownProperties || previewProps || []; + + let 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. + let newCache = new Map(cache); + newCache.set(from, props); + + return newCache; +} + +// Helpers + +function mergeProperties(response) { + let { ownProperties } = response; + + // 'safeGetterValues' is new and isn't necessary defined on old grips. + let safeGetterValues = response.safeGetterValues || {}; + + // Merge the safe getter values into one object such that we can use it + // in variablesView. + for (let name of Object.keys(safeGetterValues)) { + if (name in ownProperties) { + let { 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 000000000..1900487e1 --- /dev/null +++ b/devtools/client/dom/content/reducers/index.js @@ -0,0 +1,14 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("./grips"); +const { filter } = require("./filter"); + +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 000000000..0a00b3feb --- /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 000000000..645ba7921 --- /dev/null +++ b/devtools/client/dom/content/utils.js @@ -0,0 +1,27 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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: function (key) { + let 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/dom-panel.js b/devtools/client/dom/dom-panel.js new file mode 100644 index 000000000..5cb6d0061 --- /dev/null +++ b/devtools/client/dom/dom-panel.js @@ -0,0 +1,241 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 { Cu } = require("chrome"); +const defer = require("devtools/shared/defer"); +const { ObjectClient } = require("devtools/shared/client/main"); + +const promise = require("promise"); +const EventEmitter = require("devtools/shared/event-emitter"); +const { Task } = require("devtools/shared/task"); + +/** + * This object represents DOM panel. It's responsibility is to + * render Document Object Model of the current debugger target. + */ +function DomPanel(iframeWindow, toolbox) { + this.panelWin = iframeWindow; + this._toolbox = toolbox; + + this.onTabNavigated = this.onTabNavigated.bind(this); + 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. + */ + open: Task.async(function* () { + if (this._opening) { + return this._opening; + } + + let deferred = promise.defer(); + this._opening = deferred.promise; + + // Local monitoring needs to make the target remote. + if (!this.target.isRemote) { + yield this.target.makeRemote(); + } + + this.initialize(); + + this.isReady = true; + this.emit("ready"); + deferred.resolve(this); + + return this._opening; + }), + + // Initialization + + initialize: function () { + this.panelWin.addEventListener("devtools/content/message", + this.onContentMessage, true); + + this.target.on("navigate", this.onTabNavigated); + this._toolbox.on("select", this.onPanelVisibilityChange); + + let provider = { + getPrototypeAndProperties: this.getPrototypeAndProperties.bind(this) + }; + + exportIntoContentScope(this.panelWin, provider, "DomProvider"); + + this.shouldRefresh = true; + }, + + destroy: Task.async(function* () { + if (this._destroying) { + return this._destroying; + } + + let deferred = promise.defer(); + this._destroying = deferred.promise; + + this.target.off("navigate", this.onTabNavigated); + this._toolbox.off("select", this.onPanelVisibilityChange); + + this.emit("destroyed"); + + deferred.resolve(); + return this._destroying; + }), + + // Events + + refresh: function () { + // 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 when the page is reloaded. + * The panel is refreshed immediatelly if it's currently selected + * or lazily when the user actually selects it. + */ + onTabNavigated: function () { + this.shouldRefresh = true; + this.refresh(); + }, + + /** + * Make sure the panel is refreshed (if needed) when it's selected. + */ + onPanelVisibilityChange: function () { + this.refresh(); + }, + + // Helpers + + /** + * Return true if the DOM panel is currently selected. + */ + isPanelVisible: function () { + return this._toolbox.currentToolId === "dom"; + }, + + getPrototypeAndProperties: function (grip) { + let deferred = defer(); + + if (!grip.actor) { + console.error("No actor!", grip); + deferred.reject(new Error("Failed to get actor from grip.")); + return deferred.promise; + } + + // Bail out if target doesn't exist (toolbox maybe closed already). + if (!this.target) { + return deferred.promise; + } + + // If a request for the grips is already in progress + // use the same promise. + let request = this.pendingRequests.get(grip.actor); + if (request) { + return request; + } + + let client = new ObjectClient(this.target.client, grip); + client.getPrototypeAndProperties(response => { + this.pendingRequests.delete(grip.actor, deferred.promise); + deferred.resolve(response); + + // Fire an event about not having any pending requests. + if (!this.pendingRequests.size) { + this.emit("no-pending-requests"); + } + }); + + this.pendingRequests.set(grip.actor, deferred.promise); + + return deferred.promise; + }, + + getRootGrip: function () { + let deferred = defer(); + + // Attach Console. It might involve RDP communication, so wait + // asynchronously for the result + this.target.activeConsole.evaluateJSAsync("window", res => { + deferred.resolve(res.result); + }); + + return deferred.promise; + }, + + postContentMessage: function (type, args) { + let data = { + type: type, + args: args, + }; + + let event = new this.panelWin.MessageEvent("devtools/chrome/message", { + bubbles: true, + cancelable: true, + data: data, + }); + + this.panelWin.dispatchEvent(event); + }, + + onContentMessage: function (event) { + let data = event.data; + let method = data.type; + if (typeof this[method] == "function") { + this[method](data.args); + } + }, + + get target() { + return this._toolbox.target; + }, +}; + +// Helpers + +function exportIntoContentScope(win, obj, defineAs) { + let clone = Cu.createObjectIn(win, { + defineAs: defineAs + }); + + let props = Object.getOwnPropertyNames(obj); + for (let i = 0; i < props.length; i++) { + let propName = props[i]; + let 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/dom.html b/devtools/client/dom/dom.html new file mode 100644 index 000000000..5fe473d09 --- /dev/null +++ b/devtools/client/dom/dom.html @@ -0,0 +1,21 @@ +<?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="resource://devtools/client/dom/content/dom-view.css" rel="stylesheet" /> + <link href="resource://devtools/client/jsonview/css/toolbar.css" rel="stylesheet" /> + <link href="resource://devtools/client/shared/components/tree/tree-view.css" rel="stylesheet" /> + + <script type="text/javascript;version=1.8" + src="chrome://devtools/content/shared/theme-switching.js"></script> +</head> +<body class="theme-body devtools-monospace" role="application"> + <div id="content"></div> + <script type="text/javascript" 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 000000000..085393428 --- /dev/null +++ b/devtools/client/dom/main.js @@ -0,0 +1,26 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 { utils: Cu } = Components; + +const { XPCOMUtils } = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {}); +const { BrowserLoader } = Cu.import("resource://devtools/client/shared/browser-loader.js", {}); + +// Module Loader +const require = BrowserLoader({ + baseURI: "resource://devtools/client/dom/", + window +}).require; + +XPCOMUtils.defineConstant(this, "require", require); + +// Localization +const { LocalizationHelper } = require("devtools/shared/l10n"); +this.l10n = new LocalizationHelper("devtools/client/locales/dom.properties"); + +// Load DOM panel content +require("./content/dom-view.js"); diff --git a/devtools/client/dom/moz.build b/devtools/client/dom/moz.build new file mode 100644 index 000000000..1e04a09dc --- /dev/null +++ b/devtools/client/dom/moz.build @@ -0,0 +1,14 @@ +# 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( + 'dom-panel.js', +) diff --git a/devtools/client/dom/test/.eslintrc.js b/devtools/client/dom/test/.eslintrc.js new file mode 100644 index 000000000..140985533 --- /dev/null +++ b/devtools/client/dom/test/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for mochitests. + "extends": "../../../.eslintrc.mochitests.js", +}; diff --git a/devtools/client/dom/test/browser.ini b/devtools/client/dom/test/browser.ini new file mode 100644 index 000000000..e8e35b32b --- /dev/null +++ b/devtools/client/dom/test/browser.ini @@ -0,0 +1,12 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + head.js + page_array.html + page_basic.html + !/devtools/client/framework/test/shared-head.js + +[browser_dom_array.js] +[browser_dom_basic.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 000000000..2813af320 --- /dev/null +++ b/devtools/client/dom/test/browser_dom_array.js @@ -0,0 +1,40 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* 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(function* () { + info("Test DOM Panel Array Expansion started"); + + let { panel } = yield addTestTab(TEST_PAGE_URL); + + // Expand specified row and wait till children are displayed. + yield expandRow(panel, "_a"); + + // Verify that children is displayed now. + let childRows = getAllRowsForLabel(panel, "_a"); + + let item = childRows.pop(); + is(item.name, "length", "length property is correct"); + is(item.value, 26, "length property value is 26"); + + let i = 0; + for (let name in childRows) { + let row = childRows[name]; + + is(name, 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 000000000..2b76fe0fe --- /dev/null +++ b/devtools/client/dom/test/browser_dom_basic.js @@ -0,0 +1,24 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* 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(function* () { + info("Test DOM panel basic started"); + + let { panel } = yield addTestTab(TEST_PAGE_URL); + + // Expand specified row and wait till children are displayed. + yield expandRow(panel, "_a"); + + // Verify that child is displayed now. + let childRow = getRowByLabel(panel, "_data"); + ok(childRow, "Child row must exist"); +}); 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 000000000..9fdc6aa7f --- /dev/null +++ b/devtools/client/dom/test/browser_dom_refresh.js @@ -0,0 +1,25 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* 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(function* () { + info("Test DOM panel basic started"); + + let { panel } = yield addTestTab(TEST_PAGE_URL); + + // Create a new variable in the page scope and refresh the panel. + yield evaluateJSAsync(panel, "var _b = 10"); + yield refreshPanel(panel); + + // Verify that the variable is displayed now. + let row = getRowByLabel(panel, "_b"); + ok(row, "New variable must be displayed"); +}); diff --git a/devtools/client/dom/test/head.js b/devtools/client/dom/test/head.js new file mode 100644 index 000000000..f9382786b --- /dev/null +++ b/devtools/client/dom/test/head.js @@ -0,0 +1,239 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint no-unused-vars: [2, {"vars": "local", "args": "none"}] */ +/* import-globals-from ../../framework/test/shared-head.js */ + +"use strict"; + +const FRAME_SCRIPT_UTILS_URL = + "chrome://devtools/content/shared/frame-script-utils.js"; + +// shared-head.js handles imports, constants, and utility functions +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/framework/test/shared-head.js", this); + +// DOM panel actions. +const constants = require("devtools/client/dom/content/constants"); + +// 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 + */ +function addTestTab(url) { + info("Adding a new test tab with URL: '" + url + "'"); + + return new Promise(resolve => { + addTab(url).then(tab => { + // Load devtools/shared/frame-script-utils.js + getFrameScript(); + + // Select the DOM panel and wait till it's initialized. + initDOMPanel(tab).then(panel => { + waitForDispatch(panel, "FETCH_PROPERTIES").then(() => { + resolve({ + tab: tab, + browser: tab.linkedBrowser, + panel: panel + }); + }); + }); + }); + }); +} + +/** + * Open the DOM panel for the given tab. + * + * @param {nsIDOMElement} 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. + */ +function initDOMPanel(tab) { + return new Promise(resolve => { + let target = TargetFactory.forTab(tab || gBrowser.selectedTab); + gDevTools.showToolbox(target, "dom").then(toolbox => { + let panel = toolbox.getCurrentPanel(); + resolve(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) { + let doc = panel.panelWin.document; + let labels = [...doc.querySelectorAll(".treeLabel")]; + let label = labels.find(node => node.textContent == text); + 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; + let result = []; + let doc = panel.panelWin.document; + let 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) { + let 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) { + let row = getRowByLabel(panel, labelText); + return synthesizeMouseClickSoon(panel, row).then(() => { + // Wait till children (properties) are fetched + // from the backend. + return waitForDispatch(panel, "FETCH_PROPERTIES"); + }); +} + +function evaluateJSAsync(panel, expression) { + return new Promise(resolve => { + panel.target.activeConsole.evaluateJSAsync(expression, res => { + resolve(res); + }); + }); +} + +function refreshPanel(panel) { + let doc = panel.panelWin.document; + let button = doc.querySelector(".btn.refresh"); + return synthesizeMouseClickSoon(panel, button).then(() => { + // Wait till children (properties) are fetched + // from the backend. + return waitForDispatch(panel, "FETCH_PROPERTIES"); + }); +} + +// Redux related API, use from shared location +// as soon as bug 1261076 is fixed. + +// Wait until an action of `type` is dispatched. If it's part of an +// async operation, wait until the `status` field is "done" or "error" +function _afterDispatchDone(store, type) { + return new Promise(resolve => { + store.dispatch({ + // Normally we would use `services.WAIT_UNTIL`, but use the + // internal name here so tests aren't forced to always pass it + // in + type: "@@service/waitUntil", + predicate: action => { + if (action.type === type) { + return action.status ? + (action.status === "end" || action.status === "error") : + true; + } + return false; + }, + run: (dispatch, getState, action) => { + resolve(action); + } + }); + }); +} + +function waitForDispatch(panel, type, eventRepeat = 1) { + const store = panel.panelWin.view.mainFrame.store; + const actionType = constants[type]; + let count = 0; + + return Task.spawn(function* () { + info("Waiting for " + type + " to dispatch " + eventRepeat + " time(s)"); + while (count < eventRepeat) { + yield _afterDispatchDone(store, actionType); + count++; + info(type + " dispatched " + count + " time(s)"); + } + }); +} diff --git a/devtools/client/dom/test/page_array.html b/devtools/client/dom/test/page_array.html new file mode 100644 index 000000000..703b93a85 --- /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 000000000..170b3112a --- /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> |