diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /devtools/client/inspector/extensions | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/inspector/extensions')
17 files changed, 1450 insertions, 0 deletions
diff --git a/devtools/client/inspector/extensions/actions/index.js b/devtools/client/inspector/extensions/actions/index.js new file mode 100644 index 0000000000..384dfb0da7 --- /dev/null +++ b/devtools/client/inspector/extensions/actions/index.js @@ -0,0 +1,24 @@ +/* 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 { createEnum } = require("resource://devtools/client/shared/enum.js"); + +createEnum( + [ + // Update the extension sidebar with an object TreeView. + "EXTENSION_SIDEBAR_OBJECT_TREEVIEW_UPDATE", + + // Update the extension sidebar with an expression result. + "EXTENSION_SIDEBAR_EXPRESSION_RESULT_VIEW_UPDATE", + + // Switch the extension sidebar into an extension page container. + "EXTENSION_SIDEBAR_PAGE_UPDATE", + + // Remove an extension sidebar from the inspector store. + "EXTENSION_SIDEBAR_REMOVE", + ], + module.exports +); diff --git a/devtools/client/inspector/extensions/actions/moz.build b/devtools/client/inspector/extensions/actions/moz.build new file mode 100644 index 0000000000..d101bc2fea --- /dev/null +++ b/devtools/client/inspector/extensions/actions/moz.build @@ -0,0 +1,10 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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( + "index.js", + "sidebar.js", +) diff --git a/devtools/client/inspector/extensions/actions/sidebar.js b/devtools/client/inspector/extensions/actions/sidebar.js new file mode 100644 index 0000000000..34c7276c27 --- /dev/null +++ b/devtools/client/inspector/extensions/actions/sidebar.js @@ -0,0 +1,58 @@ +/* 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 { + EXTENSION_SIDEBAR_OBJECT_TREEVIEW_UPDATE, + EXTENSION_SIDEBAR_EXPRESSION_RESULT_VIEW_UPDATE, + EXTENSION_SIDEBAR_PAGE_UPDATE, + EXTENSION_SIDEBAR_REMOVE, +} = require("resource://devtools/client/inspector/extensions/actions/index.js"); + +module.exports = { + /** + * Update the sidebar with an object treeview. + */ + updateObjectTreeView(sidebarId, object) { + return { + type: EXTENSION_SIDEBAR_OBJECT_TREEVIEW_UPDATE, + sidebarId, + object, + }; + }, + + /** + * Update the sidebar with an expression result. + */ + updateExpressionResultView(sidebarId, expressionResult, rootTitle) { + return { + type: EXTENSION_SIDEBAR_EXPRESSION_RESULT_VIEW_UPDATE, + sidebarId, + expressionResult, + rootTitle, + }; + }, + + /** + * Switch the sidebar into the extension page mode. + */ + updateExtensionPage(sidebarId, iframeURL) { + return { + type: EXTENSION_SIDEBAR_PAGE_UPDATE, + sidebarId, + iframeURL, + }; + }, + + /** + * Remove the extension sidebar from the inspector store. + */ + removeExtensionSidebar(sidebarId) { + return { + type: EXTENSION_SIDEBAR_REMOVE, + sidebarId, + }; + }, +}; diff --git a/devtools/client/inspector/extensions/components/ExpressionResultView.js b/devtools/client/inspector/extensions/components/ExpressionResultView.js new file mode 100644 index 0000000000..887c5610c4 --- /dev/null +++ b/devtools/client/inspector/extensions/components/ExpressionResultView.js @@ -0,0 +1,110 @@ +/* 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 { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const Accordion = createFactory( + require("resource://devtools/client/shared/components/Accordion.js") +); + +const Types = require("resource://devtools/client/inspector/extensions/types.js"); + +const { + REPS: { Grip }, + MODE, + objectInspector: { ObjectInspector: ObjectInspectorClass }, +} = require("resource://devtools/client/shared/components/reps/index.js"); + +loader.lazyRequireGetter( + this, + "LongStringFront", + "resource://devtools/client/fronts/string.js", + true +); + +loader.lazyRequireGetter( + this, + "ObjectFront", + "resource://devtools/client/fronts/object.js", + true +); + +const ObjectInspector = createFactory(ObjectInspectorClass); + +class ObjectValueGripView extends PureComponent { + static get propTypes() { + return { + rootTitle: PropTypes.string, + expressionResult: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.object, + ]).isRequired, + // Helpers injected as props by extension-sidebar.js. + serviceContainer: PropTypes.shape(Types.serviceContainer).isRequired, + }; + } + + render() { + const { expressionResult, serviceContainer, rootTitle } = this.props; + + const isFront = + expressionResult instanceof ObjectFront || + expressionResult instanceof LongStringFront; + const grip = isFront ? expressionResult.getGrip() : expressionResult; + + const objectInspectorProps = { + autoExpandDepth: 1, + mode: MODE.SHORT, + // TODO: we disable focus since it's not currently working well in ObjectInspector. + // Let's remove the property below when problem are fixed in OI. + disabledFocus: true, + roots: [ + { + path: expressionResult?.actorID || JSON.stringify(expressionResult), + contents: { value: grip, front: isFront ? expressionResult : null }, + }, + ], + // TODO: evaluate if there should also be a serviceContainer.openLink. + }; + + if (expressionResult?.actorID) { + Object.assign(objectInspectorProps, { + onDOMNodeMouseOver: serviceContainer.highlightDomElement, + onDOMNodeMouseOut: serviceContainer.unHighlightDomElement, + onInspectIconClick(object, e) { + // Stop the event propagation so we don't trigger ObjectInspector + // expand/collapse. + e.stopPropagation(); + serviceContainer.openNodeInInspector(object); + }, + defaultRep: Grip, + }); + } + + if (rootTitle) { + return Accordion({ + items: [ + { + component: ObjectInspector, + componentProps: objectInspectorProps, + header: rootTitle, + id: rootTitle.replace(/\s/g, "-"), + opened: true, + }, + ], + }); + } + + return ObjectInspector(objectInspectorProps); + } +} + +module.exports = ObjectValueGripView; diff --git a/devtools/client/inspector/extensions/components/ExtensionPage.js b/devtools/client/inspector/extensions/components/ExtensionPage.js new file mode 100644 index 0000000000..3f92b8f41f --- /dev/null +++ b/devtools/client/inspector/extensions/components/ExtensionPage.js @@ -0,0 +1,56 @@ +/* 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 { + createRef, + PureComponent, +} = 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"); + +/** + * The ExtensionPage React Component is used in the ExtensionSidebar component to provide + * a UI viewMode which shows an extension page rendered inside the sidebar panel. + */ +class ExtensionPage extends PureComponent { + static get propTypes() { + return { + iframeURL: PropTypes.string.isRequired, + onExtensionPageMount: PropTypes.func.isRequired, + onExtensionPageUnmount: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + this.iframeRef = createRef(); + } + + componentDidMount() { + this.props.onExtensionPageMount(this.iframeRef.current); + } + + componentWillUnmount() { + this.props.onExtensionPageUnmount(this.iframeRef.current); + } + + render() { + return dom.iframe({ + className: "inspector-extension-sidebar-page", + src: this.props.iframeURL, + style: { + width: "100%", + height: "100%", + margin: 0, + padding: 0, + }, + ref: this.iframeRef, + }); + } +} + +module.exports = ExtensionPage; diff --git a/devtools/client/inspector/extensions/components/ExtensionSidebar.js b/devtools/client/inspector/extensions/components/ExtensionSidebar.js new file mode 100644 index 0000000000..a351b6add9 --- /dev/null +++ b/devtools/client/inspector/extensions/components/ExtensionSidebar.js @@ -0,0 +1,106 @@ +/* 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 { + createFactory, + PureComponent, +} = 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"); + +const ExtensionPage = createFactory( + require("resource://devtools/client/inspector/extensions/components/ExtensionPage.js") +); +const ObjectTreeView = createFactory( + require("resource://devtools/client/inspector/extensions/components/ObjectTreeView.js") +); +const ExpressionResultView = createFactory( + require("resource://devtools/client/inspector/extensions/components/ExpressionResultView.js") +); +const Types = require("resource://devtools/client/inspector/extensions/types.js"); + +/** + * The ExtensionSidebar is a React component with 2 supported viewMode: + * - an ObjectTreeView UI, used to show the JS objects + * (used by the sidebar.setObject WebExtensions APIs) + * - an ExpressionResultView UI, used to show the result for an expression + * (used by sidebar.setExpression WebExtensions APIs) + * - an ExtensionPage UI used to show an extension page + * (used by the sidebar.setPage WebExtensions APIs). + * + * TODO: implement the ExtensionPage viewMode. + */ +class ExtensionSidebar extends PureComponent { + static get propTypes() { + return { + id: PropTypes.string.isRequired, + extensionsSidebar: PropTypes.object.isRequired, + onExtensionPageMount: PropTypes.func.isRequired, + onExtensionPageUnmount: PropTypes.func.isRequired, + // Helpers injected as props by extension-sidebar.js. + serviceContainer: PropTypes.shape(Types.serviceContainer).isRequired, + }; + } + + render() { + const { + id, + extensionsSidebar, + onExtensionPageMount, + onExtensionPageUnmount, + serviceContainer, + } = this.props; + + const { + iframeURL, + object, + expressionResult, + rootTitle, + viewMode = "empty-sidebar", + } = extensionsSidebar[id] || {}; + + let sidebarContentEl; + + switch (viewMode) { + case "object-treeview": + sidebarContentEl = ObjectTreeView({ object }); + break; + case "object-value-grip-view": + sidebarContentEl = ExpressionResultView({ + expressionResult, + rootTitle, + serviceContainer, + }); + break; + case "extension-page": + sidebarContentEl = ExtensionPage({ + iframeURL, + onExtensionPageMount, + onExtensionPageUnmount, + }); + break; + case "empty-sidebar": + break; + default: + throw new Error(`Unknown ExtensionSidebar viewMode: "${viewMode}"`); + } + + const className = "devtools-monospace extension-sidebar inspector-tabpanel"; + + return dom.div( + { + id, + className, + }, + sidebarContentEl + ); + } +} + +module.exports = connect(state => state)(ExtensionSidebar); diff --git a/devtools/client/inspector/extensions/components/ObjectTreeView.js b/devtools/client/inspector/extensions/components/ObjectTreeView.js new file mode 100644 index 0000000000..1788f8d020 --- /dev/null +++ b/devtools/client/inspector/extensions/components/ObjectTreeView.js @@ -0,0 +1,67 @@ +/* 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 { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { + REPS, + MODE, +} = require("resource://devtools/client/shared/components/reps/index.js"); +const { Rep } = REPS; +const TreeViewClass = require("resource://devtools/client/shared/components/tree/TreeView.js"); +const TreeView = createFactory(TreeViewClass); + +/** + * The ExpressionResultView React Component is used in the ExtensionSidebar component to + * provide a UI viewMode which shows a tree view of the passed JavaScript object. + */ +class ExpressionResultView extends PureComponent { + static get propTypes() { + return { + object: PropTypes.object.isRequired, + }; + } + + render() { + const { object } = this.props; + + const columns = [ + { + id: "value", + }, + ]; + + // Render the node value (omitted on the root element if it has children). + const renderValue = props => { + if (props.member.level === 0 && props.member.hasChildren) { + return undefined; + } + + return Rep( + Object.assign({}, props, { + cropLimit: 50, + }) + ); + }; + + return TreeView({ + object, + mode: MODE.SHORT, + columns, + renderValue, + expandedNodes: TreeViewClass.getExpandedNodes(object, { + maxLevel: 1, + maxNodes: 1, + }), + }); + } +} + +module.exports = ExpressionResultView; diff --git a/devtools/client/inspector/extensions/components/moz.build b/devtools/client/inspector/extensions/components/moz.build new file mode 100644 index 0000000000..62f3d991c1 --- /dev/null +++ b/devtools/client/inspector/extensions/components/moz.build @@ -0,0 +1,12 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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( + "ExpressionResultView.js", + "ExtensionPage.js", + "ExtensionSidebar.js", + "ObjectTreeView.js", +) diff --git a/devtools/client/inspector/extensions/extension-sidebar.js b/devtools/client/inspector/extensions/extension-sidebar.js new file mode 100644 index 0000000000..c359d8b4fa --- /dev/null +++ b/devtools/client/inspector/extensions/extension-sidebar.js @@ -0,0 +1,189 @@ +/* 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 { + createElement, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const { + Provider, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const extensionsSidebarReducer = require("resource://devtools/client/inspector/extensions/reducers/sidebar.js"); +const { + default: objectInspectorReducer, +} = require("resource://devtools/client/shared/components/object-inspector/reducer.js"); + +const ExtensionSidebarComponent = createFactory( + require("resource://devtools/client/inspector/extensions/components/ExtensionSidebar.js") +); + +const { + updateExtensionPage, + updateObjectTreeView, + updateExpressionResultView, + removeExtensionSidebar, +} = require("resource://devtools/client/inspector/extensions/actions/sidebar.js"); + +/** + * ExtensionSidebar instances represents Inspector sidebars installed by add-ons + * using the devtools.panels.elements.createSidebarPane WebExtensions API. + * + * The WebExtensions API registers the extensions' sidebars on the toolbox instance + * (using the registerInspectorExtensionSidebar method) and, once the Inspector has been + * created, the toolbox uses the Inpector createExtensionSidebar method to create the + * ExtensionSidebar instances and then it registers them to the Inspector. + * + * @param {Inspector} inspector + * The inspector where the sidebar should be hooked to. + * @param {Object} options + * @param {String} options.id + * The unique id of the sidebar. + * @param {String} options.title + * The title of the sidebar. + */ +class ExtensionSidebar { + constructor(inspector, { id, title }) { + EventEmitter.decorate(this); + this.inspector = inspector; + this.store = inspector.store; + this.id = id; + this.title = title; + this.destroyed = false; + + this.store.injectReducer("extensionsSidebar", extensionsSidebarReducer); + this.store.injectReducer("objectInspector", objectInspectorReducer); + } + + /** + * Lazily create a React ExtensionSidebarComponent wrapped into a Redux Provider. + */ + get provider() { + if (!this._provider) { + this._provider = createElement( + Provider, + { + store: this.store, + key: this.id, + title: this.title, + }, + ExtensionSidebarComponent({ + id: this.id, + onExtensionPageMount: containerEl => { + this.emit("extension-page-mount", containerEl); + }, + onExtensionPageUnmount: containerEl => { + this.emit("extension-page-unmount", containerEl); + }, + serviceContainer: { + highlightDomElement: async (grip, options = {}) => { + const nodeFront = + await this.inspector.inspectorFront.getNodeFrontFromNodeGrip( + grip + ); + return this.inspector.highlighters.showHighlighterTypeForNode( + this.inspector.highlighters.TYPES.BOXMODEL, + nodeFront, + options + ); + }, + unHighlightDomElement: async () => { + return this.inspector.highlighters.hideHighlighterType( + this.inspector.highlighters.TYPES.BOXMODEL + ); + }, + openNodeInInspector: async grip => { + const nodeFront = + await this.inspector.inspectorFront.getNodeFrontFromNodeGrip( + grip + ); + const onInspectorUpdated = + this.inspector.once("inspector-updated"); + const onNodeFrontSet = + this.inspector.toolbox.selection.setNodeFront(nodeFront, { + reason: "inspector-extension-sidebar", + }); + + return Promise.all([onNodeFrontSet, onInspectorUpdated]); + }, + }, + }) + ); + } + + return this._provider; + } + + /** + * Destroy the ExtensionSidebar instance, dispatch a removeExtensionSidebar Redux action + * (which removes the related state from the Inspector store) and clear any reference + * to the inspector, the Redux store and the lazily created Redux Provider component. + * + * This method is called by the inspector when the ExtensionSidebar is being removed + * (or when the inspector is being destroyed). + */ + destroy() { + if (this.destroyed) { + throw new Error( + `ExtensionSidebar instances cannot be destroyed more than once` + ); + } + + // Remove the data related to this extension from the inspector store. + this.store.dispatch(removeExtensionSidebar(this.id)); + + this.inspector = null; + this.store = null; + this._provider = null; + + this.destroyed = true; + } + + /** + * Dispatch an objectTreeView action to change the SidebarComponent into an + * ObjectTreeView React Component, which shows the passed javascript object + * in the sidebar. + */ + setObject(object) { + if (this.removed) { + throw new Error( + "Unable to set an object preview on a removed ExtensionSidebar" + ); + } + + this.store.dispatch(updateObjectTreeView(this.id, object)); + } + + /** + * Dispatch an objectPreview action to change the SidebarComponent into an + * ObjectPreview React Component, which shows the passed value grip + * in the sidebar. + */ + setExpressionResult(expressionResult, rootTitle) { + if (this.removed) { + throw new Error( + "Unable to set an object preview on a removed ExtensionSidebar" + ); + } + + this.store.dispatch( + updateExpressionResultView(this.id, expressionResult, rootTitle) + ); + } + + setExtensionPage(iframeURL) { + if (this.removed) { + throw new Error( + "Unable to set an object preview on a removed ExtensionSidebar" + ); + } + + this.store.dispatch(updateExtensionPage(this.id, iframeURL)); + } +} + +module.exports = ExtensionSidebar; diff --git a/devtools/client/inspector/extensions/moz.build b/devtools/client/inspector/extensions/moz.build new file mode 100644 index 0000000000..73fe369a7f --- /dev/null +++ b/devtools/client/inspector/extensions/moz.build @@ -0,0 +1,18 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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( + "extension-sidebar.js", + "types.js", +) + +BROWSER_CHROME_MANIFESTS += ["test/browser.ini"] diff --git a/devtools/client/inspector/extensions/reducers/moz.build b/devtools/client/inspector/extensions/reducers/moz.build new file mode 100644 index 0000000000..0f8a5757c8 --- /dev/null +++ b/devtools/client/inspector/extensions/reducers/moz.build @@ -0,0 +1,9 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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( + "sidebar.js", +) diff --git a/devtools/client/inspector/extensions/reducers/sidebar.js b/devtools/client/inspector/extensions/reducers/sidebar.js new file mode 100644 index 0000000000..c7566f3b67 --- /dev/null +++ b/devtools/client/inspector/extensions/reducers/sidebar.js @@ -0,0 +1,67 @@ +/* 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 { + EXTENSION_SIDEBAR_OBJECT_TREEVIEW_UPDATE, + EXTENSION_SIDEBAR_EXPRESSION_RESULT_VIEW_UPDATE, + EXTENSION_SIDEBAR_PAGE_UPDATE, + EXTENSION_SIDEBAR_REMOVE, +} = require("resource://devtools/client/inspector/extensions/actions/index.js"); + +const INITIAL_SIDEBAR = {}; + +const reducers = { + [EXTENSION_SIDEBAR_OBJECT_TREEVIEW_UPDATE](sidebar, { sidebarId, object }) { + // Update the sidebar to a "object-treeview" which shows + // the passed object. + return Object.assign({}, sidebar, { + [sidebarId]: { + viewMode: "object-treeview", + object, + }, + }); + }, + + [EXTENSION_SIDEBAR_EXPRESSION_RESULT_VIEW_UPDATE]( + sidebar, + { sidebarId, expressionResult, rootTitle } + ) { + // Update the sidebar to a "object-treeview" which shows + // the passed object. + return Object.assign({}, sidebar, { + [sidebarId]: { + viewMode: "object-value-grip-view", + expressionResult, + rootTitle, + }, + }); + }, + + [EXTENSION_SIDEBAR_PAGE_UPDATE](sidebar, { sidebarId, iframeURL }) { + // Update the sidebar to a "object-treeview" which shows + // the passed object. + return Object.assign({}, sidebar, { + [sidebarId]: { + viewMode: "extension-page", + iframeURL, + }, + }); + }, + + [EXTENSION_SIDEBAR_REMOVE](sidebar, { sidebarId }) { + // Remove the sidebar from the Redux store. + delete sidebar[sidebarId]; + return Object.assign({}, sidebar); + }, +}; + +module.exports = function (sidebar = INITIAL_SIDEBAR, action) { + const reducer = reducers[action.type]; + if (!reducer) { + return sidebar; + } + return reducer(sidebar, action); +}; diff --git a/devtools/client/inspector/extensions/test/browser.ini b/devtools/client/inspector/extensions/test/browser.ini new file mode 100644 index 0000000000..a141e7afc6 --- /dev/null +++ b/devtools/client/inspector/extensions/test/browser.ini @@ -0,0 +1,13 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + head.js + head_devtools_inspector_sidebar.js + !/devtools/client/inspector/test/head.js + !/devtools/client/inspector/test/shared-head.js + !/devtools/client/shared/test/shared-head.js + !/devtools/client/shared/test/telemetry-test-helpers.js + !/devtools/client/shared/test/highlighter-test-actor.js + +[browser_inspector_extension_sidebar.js] diff --git a/devtools/client/inspector/extensions/test/browser_inspector_extension_sidebar.js b/devtools/client/inspector/extensions/test/browser_inspector_extension_sidebar.js new file mode 100644 index 0000000000..740b1fda13 --- /dev/null +++ b/devtools/client/inspector/extensions/test/browser_inspector_extension_sidebar.js @@ -0,0 +1,451 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const SIDEBAR_ID = "an-extension-sidebar"; +const SIDEBAR_TITLE = "Sidebar Title"; + +let extension; +let fakeExtCallerInfo; + +let toolbox; +let inspector; + +add_task(async function setupExtensionSidebar() { + extension = ExtensionTestUtils.loadExtension({ + background() { + // This is just an empty extension used to ensure that the caller extension uuid + // actually exists. + }, + }); + + await extension.startup(); + + fakeExtCallerInfo = { + url: WebExtensionPolicy.getByID(extension.id).getURL( + "fake-caller-script.js" + ), + lineNumber: 1, + addonId: extension.id, + }; + + const res = await openInspectorForURL("about:blank"); + inspector = res.inspector; + toolbox = res.toolbox; + + const onceSidebarCreated = toolbox.once( + `extension-sidebar-created-${SIDEBAR_ID}` + ); + toolbox.registerInspectorExtensionSidebar(SIDEBAR_ID, { + title: SIDEBAR_TITLE, + }); + + const sidebar = await onceSidebarCreated; + + // Test sidebar properties. + is( + sidebar, + inspector.getPanel(SIDEBAR_ID), + "Got an extension sidebar instance equal to the one saved in the inspector" + ); + is( + sidebar.title, + SIDEBAR_TITLE, + "Got the expected title in the extension sidebar instance" + ); + is( + sidebar.provider.props.title, + SIDEBAR_TITLE, + "Got the expeted title in the provider props" + ); + + // Test sidebar Redux state. + const inspectorStoreState = inspector.store.getState(); + ok( + "extensionsSidebar" in inspectorStoreState, + "Got the extensionsSidebar sub-state in the inspector Redux store" + ); + Assert.deepEqual( + inspectorStoreState.extensionsSidebar, + {}, + "The extensionsSidebar should be initially empty" + ); +}); + +add_task(async function testSidebarSetObject() { + const object = { + propertyName: { + nestedProperty: "propertyValue", + anotherProperty: "anotherValue", + }, + }; + + const sidebar = inspector.getPanel(SIDEBAR_ID); + sidebar.setObject(object); + + // Test updated sidebar Redux state. + const inspectorStoreState = inspector.store.getState(); + is( + Object.keys(inspectorStoreState.extensionsSidebar).length, + 1, + "The extensionsSidebar state contains the newly registered extension sidebar state" + ); + Assert.deepEqual( + inspectorStoreState.extensionsSidebar, + { + [SIDEBAR_ID]: { + viewMode: "object-treeview", + object, + }, + }, + "Got the expected state for the registered extension sidebar" + ); + + // Select the extension sidebar. + const waitSidebarSelected = toolbox.once(`inspector-sidebar-select`); + inspector.sidebar.show(SIDEBAR_ID); + await waitSidebarSelected; + + const sidebarPanelContent = inspector.sidebar.getTabPanel(SIDEBAR_ID); + + // Test extension sidebar content. + ok( + sidebarPanelContent, + "Got a sidebar panel for the registered extension sidebar" + ); + + assertTreeView(sidebarPanelContent, { + expectedTreeTables: 1, + expectedStringCells: 2, + expectedNumberCells: 0, + }); + + // Test sidebar refreshed on further sidebar.setObject calls. + info("Change the inspected object in the extension sidebar object treeview"); + sidebar.setObject({ aNewProperty: 123 }); + + assertTreeView(sidebarPanelContent, { + expectedTreeTables: 1, + expectedStringCells: 0, + expectedNumberCells: 1, + }); +}); + +add_task(async function testSidebarSetExpressionResult() { + const { commands } = toolbox; + const sidebar = inspector.getPanel(SIDEBAR_ID); + const sidebarPanelContent = inspector.sidebar.getTabPanel(SIDEBAR_ID); + + info("Testing sidebar.setExpressionResult with rootTitle"); + + const expression = ` + var obj = Object.create(null); + obj.prop1 = 123; + obj[Symbol('sym1')] = 456; + obj.cyclic = obj; + obj; + `; + + const consoleFront = await toolbox.target.getFront("console"); + let evalResult = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + expression, + { + consoleFront, + } + ); + + sidebar.setExpressionResult(evalResult, "Expected Root Title"); + + // Wait the ObjectInspector component to be rendered and test its content. + await testSetExpressionSidebarPanel(sidebarPanelContent, { + nodesLength: 4, + propertiesNames: ["cyclic", "prop1", "Symbol(sym1)"], + rootTitle: "Expected Root Title", + }); + + info("Testing sidebar.setExpressionResult without rootTitle"); + + sidebar.setExpressionResult(evalResult); + + // Wait the ObjectInspector component to be rendered and test its content. + await testSetExpressionSidebarPanel(sidebarPanelContent, { + nodesLength: 4, + propertiesNames: ["cyclic", "prop1", "Symbol(sym1)"], + }); + + info("Test expanding the object"); + const oi = sidebarPanelContent.querySelector(".tree"); + const cyclicNode = oi.querySelectorAll(".node")[1]; + ok(cyclicNode.innerText.includes("cyclic"), "Found the expected node"); + cyclicNode.click(); + + await TestUtils.waitForCondition( + () => oi.querySelectorAll(".node").length === 7, + "Wait for the 'cyclic' node to be expanded" + ); + + await TestUtils.waitForCondition( + () => oi.querySelector(".tree-node.focused"), + "Wait for the 'cyclic' node to be focused" + ); + ok( + oi.querySelector(".tree-node.focused").innerText.includes("cyclic"), + "'cyclic' node is focused" + ); + + info("Test keyboard navigation"); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, oi.ownerDocument.defaultView); + await TestUtils.waitForCondition( + () => oi.querySelectorAll(".node").length === 4, + "Wait for the 'cyclic' node to be collapsed" + ); + ok( + oi.querySelector(".tree-node.focused").innerText.includes("cyclic"), + "'cyclic' node is still focused" + ); + + EventUtils.synthesizeKey("KEY_ArrowDown", {}, oi.ownerDocument.defaultView); + await TestUtils.waitForCondition( + () => oi.querySelectorAll(".tree-node")[2].classList.contains("focused"), + "Wait for the 'prop1' node to be focused" + ); + + ok( + oi.querySelector(".tree-node.focused").innerText.includes("prop1"), + "'prop1' node is focused" + ); + + info( + "Testing sidebar.setExpressionResult for an expression returning a longstring" + ); + evalResult = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + `"ab ".repeat(10000)`, + { + consoleFront, + } + ); + sidebar.setExpressionResult(evalResult); + + await TestUtils.waitForCondition(() => { + const longStringEl = sidebarPanelContent.querySelector( + ".tree .objectBox-string" + ); + return ( + longStringEl && longStringEl.textContent.includes("ab ".repeat(10000)) + ); + }, "Wait for the longString to be render with its full text"); + ok(true, "The longString is expanded and its full text is displayed"); + + info( + "Testing sidebar.setExpressionResult for an expression returning a primitive" + ); + evalResult = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + `1 + 2`, + { + consoleFront, + } + ); + sidebar.setExpressionResult(evalResult); + const numberEl = await TestUtils.waitForCondition( + () => sidebarPanelContent.querySelector(".objectBox-number"), + "Wait for the result number element to be rendered" + ); + is(numberEl.textContent, "3", `The "1 + 2" expression was evaluated as "3"`); +}); + +add_task(async function testSidebarDOMNodeHighlighting() { + const { commands } = toolbox; + const sidebar = inspector.getPanel(SIDEBAR_ID); + const sidebarPanelContent = inspector.sidebar.getTabPanel(SIDEBAR_ID); + + const { waitForHighlighterTypeShown, waitForHighlighterTypeHidden } = + getHighlighterTestHelpers(inspector); + + const expression = "({ body: document.body })"; + + const consoleFront = await toolbox.target.getFront("console"); + const evalResult = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + expression, + { + consoleFront, + } + ); + + sidebar.setExpressionResult(evalResult); + + // Wait the DOM node to be rendered inside the component. + await waitForObjectInspector(sidebarPanelContent, "node"); + + // Wait for the object to be expanded so we only target the "body" property node, and + // not the root object element. + await TestUtils.waitForCondition( + () => + sidebarPanelContent.querySelectorAll(".object-inspector .tree-node") + .length > 1 + ); + + // Get and verify the DOMNode and the "open inspector"" icon + // rendered inside the ObjectInspector. + + assertObjectInspector(sidebarPanelContent, { + expectedDOMNodes: 2, + expectedOpenInspectors: 2, + }); + + // Test highlight DOMNode on mouseover. + info("Highlight the node by moving the cursor on it"); + + const onNodeHighlight = waitForHighlighterTypeShown( + inspector.highlighters.TYPES.BOXMODEL + ); + + moveMouseOnObjectInspectorDOMNode(sidebarPanelContent); + + const { nodeFront } = await onNodeHighlight; + is(nodeFront.displayName, "body", "The correct node was highlighted"); + + // Test unhighlight DOMNode on mousemove. + info("Unhighlight the node by moving away from the node"); + const onNodeUnhighlight = waitForHighlighterTypeHidden( + inspector.highlighters.TYPES.BOXMODEL + ); + + moveMouseOnPanelCenter(sidebarPanelContent); + + await onNodeUnhighlight; + info("The node is no longer highlighted"); +}); + +add_task(async function testSidebarDOMNodeOpenInspector() { + const sidebarPanelContent = inspector.sidebar.getTabPanel(SIDEBAR_ID); + + // Test DOMNode selected in the inspector when "open inspector"" icon clicked. + info("Unselect node in the inspector"); + let onceNewNodeFront = inspector.selection.once("new-node-front"); + inspector.selection.setNodeFront(null); + let nodeFront = await onceNewNodeFront; + is(nodeFront, null, "The inspector selection should have been unselected"); + + info( + "Select the ObjectInspector DOMNode in the inspector panel by clicking on it" + ); + + // In test mode, shown highlighters are not automatically hidden after a delay to + // prevent intermittent test failures from race conditions. + // Restore this behavior just for this test because it is explicitly checked. + const HIGHLIGHTER_AUTOHIDE_TIMER = inspector.HIGHLIGHTER_AUTOHIDE_TIMER; + inspector.HIGHLIGHTER_AUTOHIDE_TIMER = 1000; + registerCleanupFunction(() => { + // Restore the value to disable autohiding to not impact other tests. + inspector.HIGHLIGHTER_AUTOHIDE_TIMER = HIGHLIGHTER_AUTOHIDE_TIMER; + }); + + const { waitForHighlighterTypeShown, waitForHighlighterTypeHidden } = + getHighlighterTestHelpers(inspector); + + // Once we click the open-inspector icon we expect a new node front to be selected + // and the node to have been highlighted and unhighlighted. + const onNodeHighlight = waitForHighlighterTypeShown( + inspector.highlighters.TYPES.BOXMODEL + ); + const onNodeUnhighlight = waitForHighlighterTypeHidden( + inspector.highlighters.TYPES.BOXMODEL + ); + onceNewNodeFront = inspector.selection.once("new-node-front"); + + clickOpenInspectorIcon(sidebarPanelContent); + + nodeFront = await onceNewNodeFront; + is(nodeFront.displayName, "body", "The correct node has been selected"); + const { nodeFront: highlightedNodeFront } = await onNodeHighlight; + is( + highlightedNodeFront.displayName, + "body", + "The correct node was highlighted" + ); + + await onNodeUnhighlight; +}); + +add_task(async function testSidebarSetExtensionPage() { + const sidebar = inspector.getPanel(SIDEBAR_ID); + const sidebarPanelContent = inspector.sidebar.getTabPanel(SIDEBAR_ID); + + info("Testing sidebar.setExtensionPage"); + + await SpecialPowers.pushPrefEnv({ + set: [["security.allow_unsafe_parent_loads", true]], + }); + + const expectedURL = + "data:text/html,<!DOCTYPE html><html><body><h1>Extension Page"; + + sidebar.setExtensionPage(expectedURL); + + await testSetExtensionPageSidebarPanel(sidebarPanelContent, expectedURL); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function teardownExtensionSidebar() { + info("Remove the sidebar instance"); + + toolbox.unregisterInspectorExtensionSidebar(SIDEBAR_ID); + + ok( + !inspector.sidebar.getTabPanel(SIDEBAR_ID), + "The rendered extension sidebar has been removed" + ); + + const inspectorStoreState = inspector.store.getState(); + + Assert.deepEqual( + inspectorStoreState.extensionsSidebar, + {}, + "The extensions sidebar Redux store data has been cleared" + ); + + await extension.unload(); + + toolbox = null; + inspector = null; + extension = null; +}); + +add_task(async function testActiveTabOnNonExistingSidebar() { + // Set a fake non existing sidebar id in the activeSidebar pref, + // to simulate the scenario where an extension has installed a sidebar + // which has been saved in the preference but it doesn't exist anymore. + await SpecialPowers.pushPrefEnv({ + set: [["devtools.inspector.activeSidebar", "unexisting-sidebar-id"]], + }); + + const res = await openInspectorForURL("about:blank"); + inspector = res.inspector; + toolbox = res.toolbox; + + const onceSidebarCreated = toolbox.once( + `extension-sidebar-created-${SIDEBAR_ID}` + ); + toolbox.registerInspectorExtensionSidebar(SIDEBAR_ID, { + title: SIDEBAR_TITLE, + }); + + // Wait the extension sidebar to be created and then unregister it to force the tabbar + // to select a new one. + await onceSidebarCreated; + toolbox.unregisterInspectorExtensionSidebar(SIDEBAR_ID); + + is( + inspector.sidebar.getCurrentTabID(), + "layoutview", + "Got the expected inspector sidebar tab selected" + ); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/devtools/client/inspector/extensions/test/head.js b/devtools/client/inspector/extensions/test/head.js new file mode 100644 index 0000000000..17d7538904 --- /dev/null +++ b/devtools/client/inspector/extensions/test/head.js @@ -0,0 +1,21 @@ +/* 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/. */ + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +"use strict"; + +// Import the inspector's head.js first (which itself imports shared-head.js). +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/head.js", + this +); + +// Import the inspector extensions test helpers (shared between the tests that live +// in the current devtools test directory and the devtools sidebar tests that live +// in browser/components/extensions/test/browser). +Services.scriptloader.loadSubScript( + new URL("head_devtools_inspector_sidebar.js", gTestPath).href, + this +); diff --git a/devtools/client/inspector/extensions/test/head_devtools_inspector_sidebar.js b/devtools/client/inspector/extensions/test/head_devtools_inspector_sidebar.js new file mode 100644 index 0000000000..73467c3e31 --- /dev/null +++ b/devtools/client/inspector/extensions/test/head_devtools_inspector_sidebar.js @@ -0,0 +1,224 @@ +/* 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/. */ + +/* exported getExtensionSidebarActors, expectNoSuchActorIDs, + waitForObjectInspector, testSetExpressionSidebarPanel, assertTreeView, + assertObjectInspector, moveMouseOnObjectInspectorDOMNode, + moveMouseOnPanelCenter, clickOpenInspectorIcon */ + +"use strict"; + +const ACCORDION_LABEL_SELECTOR = ".accordion-header-label"; +const ACCORDION_CONTENT_SELECTOR = ".accordion-content"; + +// Retrieve the array of all the objectValueGrip actors from the +// inspector extension sidebars state +// (used in browser_ext_devtools_panels_elements_sidebar.js). +function getExtensionSidebarActors(inspector) { + const state = inspector.store.getState(); + + const actors = []; + + for (const sidebarId of Object.keys(state.extensionsSidebar)) { + const sidebarState = state.extensionsSidebar[sidebarId]; + + if ( + sidebarState.viewMode === "object-value-grip-view" && + sidebarState.objectValueGrip && + sidebarState.objectValueGrip.actor + ) { + actors.push(sidebarState.objectValueGrip.actor); + } + } + + return actors; +} + +// Test that the specified objectValueGrip actors have been released +// on the remote debugging server +// (used in browser_ext_devtools_panels_elements_sidebar.js). +async function expectNoSuchActorIDs(client, actors) { + info(`Test that all the objectValueGrip actors have been released`); + for (const actor of actors) { + await Assert.rejects( + client.request({ to: actor, type: "requestTypes" }), + err => err.message == `No such actor for ID: ${actor}` + ); + } +} + +function waitForObjectInspector(panelDoc, waitForNodeWithType = "object") { + const selector = `.object-inspector .objectBox-${waitForNodeWithType}`; + return TestUtils.waitForCondition(() => { + return !!panelDoc.querySelectorAll(selector).length; + }, `Wait for objectInspector's node type "${waitForNodeWithType}" to be loaded`); +} + +// Helper function used inside the sidebar.setExtensionPage test case. +async function testSetExtensionPageSidebarPanel(panelDoc, expectedURL) { + const selector = "iframe.inspector-extension-sidebar-page"; + const iframesCount = await TestUtils.waitForCondition(() => { + return panelDoc.querySelectorAll(selector).length; + }, "Wait for the extension page iframe"); + + is( + iframesCount, + 1, + "Got the expected number of iframes in the extension panel" + ); + + const iframeWindow = panelDoc.querySelector(selector).contentWindow; + await TestUtils.waitForCondition(() => { + return iframeWindow.document.readyState === "complete"; + }, "Wait for the extension page iframe to complete to load"); + + is( + iframeWindow.location.href, + expectedURL, + "Got the expected url in the extension panel iframe" + ); +} + +// Helper function used inside the sidebar.setObjectValueGrip test case. +async function testSetExpressionSidebarPanel(panel, expected) { + const { nodesLength, propertiesNames, rootTitle } = expected; + + await waitForObjectInspector(panel); + + const objectInspectors = [...panel.querySelectorAll(".tree")]; + is( + objectInspectors.length, + 1, + "There is the expected number of object inspectors" + ); + const [objectInspector] = objectInspectors; + + await TestUtils.waitForCondition(() => { + return objectInspector.querySelectorAll(".node").length >= nodesLength; + }, "Wait the objectInspector to have been fully rendered"); + + const oiNodes = objectInspector.querySelectorAll(".node"); + + is( + oiNodes.length, + nodesLength, + "Got the expected number of nodes in the tree" + ); + const propertiesNodes = [ + ...objectInspector.querySelectorAll(".object-label"), + ].map(el => el.textContent); + is( + JSON.stringify(propertiesNodes), + JSON.stringify(propertiesNames), + "Got the expected property names" + ); + + if (rootTitle) { + // Also check that the ObjectInspector is rendered inside + // an Accordion component with the expected title. + const accordion = panel.querySelector(".accordion"); + + ok(accordion, "Got an Accordion component as expected"); + + is( + accordion.querySelector(ACCORDION_CONTENT_SELECTOR).firstChild, + objectInspector, + "The ObjectInspector should be inside the Accordion content" + ); + + is( + accordion.querySelector(ACCORDION_LABEL_SELECTOR).textContent, + rootTitle, + "The Accordion has the expected label" + ); + } else { + // Also check that there is no Accordion component rendered + // inside the sidebar panel. + ok( + !panel.querySelector(".accordion"), + "Got no Accordion component as expected" + ); + } +} + +function assertTreeView(panelDoc, expectedContent) { + const { expectedTreeTables, expectedStringCells, expectedNumberCells } = + expectedContent; + + if (expectedTreeTables) { + is( + panelDoc.querySelectorAll("table.treeTable").length, + expectedTreeTables, + "The panel document contains the expected number of TreeView components" + ); + } + + if (expectedStringCells) { + is( + panelDoc.querySelectorAll("table.treeTable .stringCell").length, + expectedStringCells, + "The panel document contains the expected number of string cells." + ); + } + + if (expectedNumberCells) { + is( + panelDoc.querySelectorAll("table.treeTable .numberCell").length, + expectedNumberCells, + "The panel document contains the expected number of number cells." + ); + } +} + +async function assertObjectInspector(panelDoc, expectedContent) { + const { expectedDOMNodes, expectedOpenInspectors } = expectedContent; + + // Get and verify the DOMNode and the "open inspector"" icon + // rendered inside the ObjectInspector. + const nodes = panelDoc.querySelectorAll(".objectBox-node"); + const nodeOpenInspectors = panelDoc.querySelectorAll( + ".objectBox-node .open-inspector" + ); + + is( + nodes.length, + expectedDOMNodes, + "Found the expected number of ObjectInspector DOMNodes" + ); + is( + nodeOpenInspectors.length, + expectedOpenInspectors, + "Found the expected nuber of open-inspector icons inside the ObjectInspector" + ); +} + +function moveMouseOnObjectInspectorDOMNode(panelDoc, nodeIndex = 0) { + const nodes = panelDoc.querySelectorAll(".objectBox-node"); + const node = nodes[nodeIndex]; + + ok(node, "Found the ObjectInspector DOMNode"); + + EventUtils.synthesizeMouseAtCenter( + node, + { type: "mousemove" }, + node.ownerDocument.defaultView + ); +} + +function moveMouseOnPanelCenter(panelDoc) { + EventUtils.synthesizeMouseAtCenter( + panelDoc, + { type: "mousemove" }, + panelDoc.window + ); +} + +function clickOpenInspectorIcon(panelDoc, nodeIndex = 0) { + const nodes = panelDoc.querySelectorAll(".objectBox-node .open-inspector"); + const node = nodes[nodeIndex]; + + ok(node, "Found the ObjectInspector open-inspector icon"); + + node.click(); +} diff --git a/devtools/client/inspector/extensions/types.js b/devtools/client/inspector/extensions/types.js new file mode 100644 index 0000000000..187c281031 --- /dev/null +++ b/devtools/client/inspector/extensions/types.js @@ -0,0 +1,15 @@ +/* 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 PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +// Helpers injected as props by extension-sidebar.js and used by the +// ObjectInspector component (which is part of the ExpressionResultView). +exports.serviceContainer = { + highlightDomElement: PropTypes.func.isRequired, + unHighlightDomElement: PropTypes.func.isRequired, + openNodeInInspector: PropTypes.func.isRequired, +}; |