diff options
Diffstat (limited to 'devtools/client/shared/components')
296 files changed, 56928 insertions, 0 deletions
diff --git a/devtools/client/shared/components/.eslintrc.js b/devtools/client/shared/components/.eslintrc.js new file mode 100644 index 0000000000..b67123ad2c --- /dev/null +++ b/devtools/client/shared/components/.eslintrc.js @@ -0,0 +1,11 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + globals: { + define: true, + }, +}; diff --git a/devtools/client/shared/components/Accordion.css b/devtools/client/shared/components/Accordion.css new file mode 100644 index 0000000000..4941d09537 --- /dev/null +++ b/devtools/client/shared/components/Accordion.css @@ -0,0 +1,89 @@ +/* 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/. */ + +/* Accordion */ + +.accordion { + width: 100%; + padding: 0; + margin: 0; + list-style-type: none; + /* Accordion root has tabindex="-1" to get focus programatically. + * This can give it a focus outline when clicked, which we don't want. + * The container itself is not in the focus order at all. */ + outline: none; + background-color: var(--theme-sidebar-background); +} + +.accordion-header { + box-sizing: border-box; + display: flex; + align-items: center; + /* Reserve 1px for the border */ + min-height: calc(var(--theme-toolbar-height) + 1px); + margin: 0; + border-bottom: 1px solid var(--theme-splitter-color); + padding: 2px 4px; + font-size: inherit; + font-weight: normal; + user-select: none; + cursor: default; + background-color: var(--theme-accordion-header-background); + /* Adjust outline to make it visible */ + outline-offset: -2px; +} + +.accordion-header:hover { + background-color: var(--theme-accordion-header-hover); +} + +/* + Arrow should be a bit closer to the text than to the start edge: + - total distance between text and start edge = 20px + - arrow width = 10px + - distance between arrow and start edge = 6px + - distance between arrow and text = 4px +*/ +.accordion-header .theme-twisty { + display: inline-block; + flex: none; + width: 10px; + height: 10px; + margin-inline-start: 2px; + margin-inline-end: 4px; + pointer-events: none; +} + +.accordion-header-label { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + line-height: 16px; + color: var(--theme-toolbar-color); +} + +.accordion-header-buttons { + flex: none; + display: flex; + align-items: center; + justify-content: flex-end; + max-width: 50%; + margin-inline-start: auto; + padding-inline-start: 4px; +} + +.accordion-content { + overflow: auto; + border-bottom: 1px solid var(--theme-splitter-color); +} + +.accordion-content[hidden] { + display: none; +} + +.accordion-item:last-child > .accordion-content { + border-bottom: none; +} diff --git a/devtools/client/shared/components/Accordion.js b/devtools/client/shared/components/Accordion.js new file mode 100644 index 0000000000..c3f1afa418 --- /dev/null +++ b/devtools/client/shared/components/Accordion.js @@ -0,0 +1,257 @@ +/* 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 { + Component, + createElement, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + ul, + li, + h2, + div, + span, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +class Accordion extends Component { + static get propTypes() { + return { + className: PropTypes.string, + // A list of all items to be rendered using an Accordion component. + items: PropTypes.arrayOf( + PropTypes.shape({ + buttons: PropTypes.arrayOf(PropTypes.object), + className: PropTypes.string, + component: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), + componentProps: PropTypes.object, + contentClassName: PropTypes.string, + header: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + onToggle: PropTypes.func, + // Determines the initial open state of the accordion item + opened: PropTypes.bool.isRequired, + // Enables dynamically changing the open state of the accordion + // on update. + shouldOpen: PropTypes.func, + }) + ).isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { + opened: {}, + }; + + this.onHeaderClick = this.onHeaderClick.bind(this); + this.onHeaderKeyDown = this.onHeaderKeyDown.bind(this); + this.setInitialState = this.setInitialState.bind(this); + this.updateCurrentState = this.updateCurrentState.bind(this); + } + + componentDidMount() { + this.setInitialState(); + } + + componentDidUpdate(prevProps) { + if (prevProps.items !== this.props.items) { + this.updateCurrentState(); + } + } + + setInitialState() { + /** + * Add initial data to the `state.opened` map. + * This happens only on initial mount of the accordion. + */ + const newItems = this.props.items.filter( + ({ id }) => typeof this.state.opened[id] !== "boolean" + ); + + if (newItems.length) { + const everOpened = { ...this.state.everOpened }; + const opened = { ...this.state.opened }; + for (const item of newItems) { + everOpened[item.id] = item.opened; + opened[item.id] = item.opened; + } + + this.setState({ everOpened, opened }); + } + } + + updateCurrentState() { + /** + * Updates the `state.opened` map based on the + * new items that have been added and those that + * `item.shouldOpen()` has changed. This happens + * on each update. + */ + const updatedItems = this.props.items.filter(item => { + const notExist = typeof this.state.opened[item.id] !== "boolean"; + if (typeof item.shouldOpen == "function") { + const currentState = this.state.opened[item.id]; + return notExist || currentState !== item.shouldOpen(item, currentState); + } + return notExist; + }); + + if (updatedItems.length) { + const everOpened = { ...this.state.everOpened }; + const opened = { ...this.state.opened }; + for (const item of updatedItems) { + let itemOpen = item.opened; + if (typeof item.shouldOpen == "function") { + itemOpen = item.shouldOpen(item, itemOpen); + } + everOpened[item.id] = itemOpen; + opened[item.id] = itemOpen; + } + this.setState({ everOpened, opened }); + } + } + + /** + * @param {Event} event Click event. + * @param {Object} item The item to be collapsed/expanded. + */ + onHeaderClick(event, item) { + event.preventDefault(); + // In the Browser Toolbox's Inspector/Layout view, handleHeaderClick is + // called twice unless we call stopPropagation, making the accordion item + // open-and-close or close-and-open + event.stopPropagation(); + this.toggleItem(item); + } + + /** + * @param {Event} event Keyboard event. + * @param {Object} item The item to be collapsed/expanded. + */ + onHeaderKeyDown(event, item) { + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + this.toggleItem(item); + } + } + + /** + * Expand or collapse an accordion list item. + * @param {Object} item The item to be collapsed or expanded. + */ + toggleItem(item) { + const opened = !this.state.opened[item.id]; + + this.setState({ + everOpened: { + ...this.state.everOpened, + [item.id]: true, + }, + opened: { + ...this.state.opened, + [item.id]: opened, + }, + }); + + if (typeof item.onToggle === "function") { + item.onToggle(opened, item); + } + } + + renderItem(item) { + const { + buttons, + className = "", + component, + componentProps = {}, + contentClassName = "", + header, + id, + } = item; + + const headerId = `${id}-header`; + const opened = this.state.opened[id]; + let itemContent; + + // Only render content if the accordion item is open or has been opened once before. + // This saves us rendering complex components when users are keeping + // them closed (e.g. in Inspector/Layout) or may not open them at all. + if (this.state.everOpened && this.state.everOpened[id]) { + if (typeof component === "function") { + itemContent = createElement(component, componentProps); + } else if (typeof component === "object") { + itemContent = component; + } + } + + return li( + { + key: id, + id, + className: `accordion-item ${ + opened ? "accordion-open" : "" + } ${className} `.trim(), + "aria-labelledby": headerId, + }, + h2( + { + id: headerId, + className: "accordion-header", + tabIndex: 0, + "aria-expanded": opened, + // If the header contains buttons, make sure the heading name only + // contains the "header" text and not the button text + "aria-label": header, + onKeyDown: event => this.onHeaderKeyDown(event, item), + onClick: event => this.onHeaderClick(event, item), + }, + span({ + className: `theme-twisty${opened ? " open" : ""}`, + role: "presentation", + }), + span( + { + className: "accordion-header-label", + }, + header + ), + buttons && + span( + { + className: "accordion-header-buttons", + role: "presentation", + }, + buttons + ) + ), + div( + { + className: `accordion-content ${contentClassName}`.trim(), + hidden: !opened, + role: "presentation", + }, + itemContent + ) + ); + } + + render() { + return ul( + { + className: + "accordion" + + (this.props.className ? ` ${this.props.className}` : ""), + tabIndex: -1, + }, + this.props.items.map(item => this.renderItem(item)) + ); + } +} + +module.exports = Accordion; diff --git a/devtools/client/shared/components/AppErrorBoundary.css b/devtools/client/shared/components/AppErrorBoundary.css new file mode 100644 index 0000000000..29b1732e20 --- /dev/null +++ b/devtools/client/shared/components/AppErrorBoundary.css @@ -0,0 +1,86 @@ +/* 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/. */ + +/* Base styles (common to most error boundaries) */ + + +/* Container */ +.app-error-panel { + color: var(--theme-text-color-strong); + display: flex; + flex-direction: column; + font-family: inherit; + font-size: 16px; + margin: 0 0 2rem; + overflow-y: scroll; + padding: 1rem 3rem; + width: 100%; + height: 100%; +} + +/* "Has crashed" header */ +.app-error-panel .error-panel-header { + align-self: center; + font-size: 1.5em; + font-weight: 300; +} + +/* "File a Bug" button */ +.app-error-panel .error-panel-file-button { + align-self: center; + background-color: var(--blue-60); + outline: none; + color: white; + font-size: 1em; + font-weight: 400; + margin-bottom: 14.74px; + padding: 0.75rem; + text-align: center; + inline-size: 200px; + text-decoration: none; +} + +.app-error-panel .error-panel-file-button:hover { + background-color: var(--blue-70); +} + +.app-error-panel .error-panel-file-button:hover:active { + background-color: var(--blue-80); +} + +/* Text of the error itself, not the stack trace */ +.app-error-panel .error-panel-error { + background-color: var(--theme-body-emphasized-background); + border: 1px solid var(--theme-toolbar-separator); + border-block-end: 0; + font-size: 1.2em; + font-weight: 500; + margin: 0; + padding: 2rem; +} + +/* Stack trace; composed of <p> elements */ +.app-error-panel .stack-trace-section { + background-color: var(--theme-body-emphasized-background); + border: 1px solid var(--theme-toolbar-separator); + padding: 2rem; + margin-bottom: 1rem; +} + +.app-error-panel .stack-trace-section p { + color: var(--theme-stack-trace-text); + margin: 0; + margin-inline-start: 1rem; +} + +.app-error-panel .stack-trace-section p:first-child { + margin: 0; +} + +/* Instructions to reopen the toolbox */ +.app-error-panel .error-panel-reload-info { + font-size: 1em; + font-weight: 400; + margin: 2rem 0 1rem; +} diff --git a/devtools/client/shared/components/AppErrorBoundary.js b/devtools/client/shared/components/AppErrorBoundary.js new file mode 100644 index 0000000000..de29b3fc6f --- /dev/null +++ b/devtools/client/shared/components/AppErrorBoundary.js @@ -0,0 +1,163 @@ +/* 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 deps +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { div, h1, h2, h3, p, a } = dom; + +// Localized strings for (devtools/client/locales/en-US/components.properties) +loader.lazyGetter(this, "L10N", function () { + const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); + return new LocalizationHelper( + "devtools/client/locales/components.properties" + ); +}); + +loader.lazyGetter(this, "FILE_BUG_BUTTON", function () { + return L10N.getStr("appErrorBoundary.fileBugButton"); +}); + +loader.lazyGetter(this, "RELOAD_PAGE_INFO", function () { + return L10N.getStr("appErrorBoundary.reloadPanelInfo"); +}); + +// File a bug for the selected component specifically +// Add format=__default__ to make sure users without EDITBUGS permission still +// use the regular UI to create bugs, including the prefilled description. +const bugLink = + "https://bugzilla.mozilla.org/enter_bug.cgi?format=__default__&product=DevTools&component="; + +/** + * Error boundary that wraps around the a given component. + */ +class AppErrorBoundary extends Component { + static get propTypes() { + return { + children: PropTypes.any.isRequired, + panel: PropTypes.any.isRequired, + componentName: PropTypes.string.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { + errorMsg: "No error", + errorStack: null, + errorInfo: null, + }; + } + + /** + * Map the `info` object to a render. + * Currently, `info` usually just contains something similar to the + * following object (which is provided to componentDidCatch): + * componentStack: {"\n in (component) \n in (other component)..."} + */ + renderErrorInfo(info = {}) { + if (Object.keys(info).length) { + return Object.keys(info).map((obj, outerIdx) => { + const traceParts = info[obj] + .split("\n") + .map((part, idx) => p({ key: `strace${idx}` }, part)); + return div( + { key: `st-div-${outerIdx}`, className: "stack-trace-section" }, + h3({}, "React Component Stack"), + p({ key: `st-p-${outerIdx}` }, obj.toString()), + traceParts + ); + }); + } + + return p({}, "undefined errorInfo"); + } + + renderStackTrace(stacktrace = "") { + const re = /:\d+:\d+/g; + const traces = stacktrace + .replace(re, "$&,") + .split(",") + .map((trace, index) => { + return p({ key: `rst-${index}` }, trace); + }); + + return div( + { className: "stack-trace-section" }, + h3({}, "Stacktrace"), + traces + ); + } + + // Return a valid object, even if we don't receive one + getValidInfo(infoObj) { + if (!infoObj.componentStack) { + try { + return { componentStack: JSON.stringify(infoObj) }; + } catch (err) { + return { componentStack: `Unknown Error: ${err}` }; + } + } + return infoObj; + } + + // Called when a child component throws an error. + componentDidCatch(error, info) { + const validInfo = this.getValidInfo(info); + this.setState({ + errorMsg: error.toString(), + errorStack: error.stack, + errorInfo: validInfo, + }); + } + + getBugLink() { + const compStack = this.getValidInfo(this.state.errorInfo).componentStack; + const errorMsg = this.state.errorMsg; + const errorStack = this.state.errorStack; + const msg = `Error: \n${errorMsg}\n\nReact Component Stack: ${compStack}\n\nStacktrace: \n${errorStack}`; + return `${bugLink}${this.props.componentName}&comment=${encodeURIComponent( + msg + )}`; + } + + render() { + if (this.state.errorInfo !== null) { + // "The (componentDesc) has crashed" + const errorDescription = L10N.getFormatStr( + "appErrorBoundary.description", + this.props.panel + ); + return div( + { + className: `app-error-panel`, + }, + h1({ className: "error-panel-header" }, errorDescription), + a( + { + className: "error-panel-file-button", + href: this.getBugLink(), + onClick: () => { + window.open(this.getBugLink(), "_blank"); + }, + }, + FILE_BUG_BUTTON + ), + h2({ className: "error-panel-error" }, this.state.errorMsg), + div({}, this.renderErrorInfo(this.state.errorInfo)), + div({}, this.renderStackTrace(this.state.errorStack)), + p({ className: "error-panel-reload-info" }, RELOAD_PAGE_INFO) + ); + } + return this.props.children; + } +} + +module.exports = AppErrorBoundary; diff --git a/devtools/client/shared/components/Frame.js b/devtools/client/shared/components/Frame.js new file mode 100644 index 0000000000..4efc7d3bd6 --- /dev/null +++ b/devtools/client/shared/components/Frame.js @@ -0,0 +1,401 @@ +/* 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 { + Component, +} = 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 { + getUnicodeUrl, + getUnicodeUrlPath, + getUnicodeHostname, +} = require("resource://devtools/client/shared/unicode-url.js"); +const { + getSourceNames, + parseURL, + getSourceMappedFile, +} = require("resource://devtools/client/shared/source-utils.js"); +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const { + MESSAGE_SOURCE, +} = require("resource://devtools/client/webconsole/constants.js"); + +const l10n = new LocalizationHelper( + "devtools/client/locales/components.properties" +); +const webl10n = new LocalizationHelper( + "devtools/client/locales/webconsole.properties" +); + +function savedFrameToLocation(frame) { + const { source: url, line, column, sourceId } = frame; + return { + url, + line, + column, + // The sourceId will be a string if it's a source actor ID, otherwise + // it is either a Spidermonkey-internal ID from a SavedFrame or missing, + // and in either case we can't use the ID for anything useful. + id: typeof sourceId === "string" ? sourceId : null, + }; +} + +/** + * Get the tooltip message. + * @param {string|undefined} messageSource + * @param {string} url + * @returns {string} + */ +function getTooltipMessage(messageSource, url) { + if (messageSource && messageSource === MESSAGE_SOURCE.CSS) { + return l10n.getFormatStr("frame.viewsourceinstyleeditor", url); + } + return l10n.getFormatStr("frame.viewsourceindebugger", url); +} + +class Frame extends Component { + static get propTypes() { + return { + // Optional className that will be put into the element. + className: PropTypes.string, + // SavedFrame, or an object containing all the required properties. + frame: PropTypes.shape({ + functionDisplayName: PropTypes.string, + // This could be a SavedFrame with a numeric sourceId, or it could + // be a SavedFrame-like client-side object, in which case the + // "sourceId" will be a source actor ID. + sourceId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + source: PropTypes.string.isRequired, + line: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + column: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + }).isRequired, + // Clicking on the frame link -- probably should link to the debugger. + onClick: PropTypes.func, + // Option to display a function name before the source link. + showFunctionName: PropTypes.bool, + // Option to display a function name even if it's anonymous. + showAnonymousFunctionName: PropTypes.bool, + // Option to display a host name after the source link. + showHost: PropTypes.bool, + // Option to display a host name if the filename is empty or just '/' + showEmptyPathAsHost: PropTypes.bool, + // Option to display a full source instead of just the filename. + showFullSourceUrl: PropTypes.bool, + // Service to enable the source map feature for console. + sourceMapURLService: PropTypes.object, + // The source of the message + messageSource: PropTypes.string, + }; + } + + static get defaultProps() { + return { + showFunctionName: false, + showAnonymousFunctionName: false, + showHost: false, + showEmptyPathAsHost: false, + showFullSourceUrl: false, + }; + } + + constructor(props) { + super(props); + this.state = { + originalLocation: null, + }; + this._locationChanged = this._locationChanged.bind(this); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillMount() { + if (this.props.sourceMapURLService) { + const location = savedFrameToLocation(this.props.frame); + // Many things that make use of this component either: + // a) Pass in no sourceId because they have no way to know. + // b) Pass in no sourceId because the actor wasn't created when the + // server sent its response. + // + // and due to that, we need to use subscribeByLocation in order to + // handle both cases with an without an ID. + this.unsubscribeSourceMapURLService = + this.props.sourceMapURLService.subscribeByLocation( + location, + this._locationChanged + ); + } + } + + componentWillUnmount() { + if (this.unsubscribeSourceMapURLService) { + this.unsubscribeSourceMapURLService(); + } + } + + _locationChanged(originalLocation) { + this.setState({ originalLocation }); + } + + /** + * Get current location's source, line, and column. + * @returns {{source: string, line: number|null, column: number|null}} + */ + #getCurrentLocationInfo = () => { + const { frame } = this.props; + const { originalLocation } = this.state; + + const generatedLocation = savedFrameToLocation(frame); + const currentLocation = originalLocation || generatedLocation; + + const source = currentLocation.url || ""; + const line = + currentLocation.line != void 0 ? Number(currentLocation.line) : null; + const column = + currentLocation.column != void 0 ? Number(currentLocation.column) : null; + return { + source, + line, + column, + }; + }; + + /** + * Get unicode hostname of the source link. + * @returns {string} + */ + #getCurrentLocationUnicodeHostName = () => { + const { source } = this.#getCurrentLocationInfo(); + + const { host } = getSourceNames(source); + return host ? getUnicodeHostname(host) : ""; + }; + + /** + * Check if the current location is linkable. + * @returns {boolean} + */ + #isCurrentLocationLinkable = () => { + const { frame } = this.props; + const { originalLocation } = this.state; + + const generatedLocation = savedFrameToLocation(frame); + + // Reparse the URL to determine if we should link this; `getSourceNames` + // has already cached this indirectly. We don't want to attempt to + // link to "self-hosted" and "(unknown)". + // Source mapped sources might not necessary linkable, but they + // are still valid in the debugger. + // If we have a source ID then we can show the source in the debugger. + return !!( + originalLocation || + generatedLocation.id || + !!parseURL(generatedLocation.url) + ); + }; + + /** + * Get the props of the top element. + */ + #getTopElementProps = () => { + const { className } = this.props; + + const { source, line, column } = this.#getCurrentLocationInfo(); + const { long } = getSourceNames(source); + const props = { + "data-url": long, + className: "frame-link" + (className ? ` ${className}` : ""), + }; + + // If we have a line number > 0. + if (line) { + // Add `data-line` attribute for testing + props["data-line"] = line; + + // Intentionally exclude 0 + if (column) { + // Add `data-column` attribute for testing + props["data-column"] = column; + } + } + return props; + }; + + /** + * Get the props of the source element. + */ + #getSourceElementsProps = () => { + const { frame, onClick, messageSource } = this.props; + + const generatedLocation = savedFrameToLocation(frame); + const { source, line, column } = this.#getCurrentLocationInfo(); + const { long } = getSourceNames(source); + let url = getUnicodeUrl(long); + + // Exclude all falsy values, including `0`, as line numbers start with 1. + if (line) { + url += `:${line}`; + // Intentionally exclude 0 + if (column) { + url += `:${column}`; + } + } + + const isLinkable = this.#isCurrentLocationLinkable(); + + // Inner el is useful for achieving ellipsis on the left and correct LTR/RTL + // ordering. See CSS styles for frame-link-source-[inner] and bug 1290056. + const tooltipMessage = getTooltipMessage(messageSource, url); + + const sourceElConfig = { + key: "source", + className: "frame-link-source", + title: isLinkable ? tooltipMessage : url, + }; + + if (isLinkable) { + return { + ...sourceElConfig, + onClick: e => { + e.preventDefault(); + e.stopPropagation(); + + onClick(generatedLocation); + }, + href: source, + draggable: false, + }; + } + + return sourceElConfig; + }; + + /** + * Render the source elements. + * @returns {React.ReactNode} + */ + #renderSourceElements = () => { + const { line, column } = this.#getCurrentLocationInfo(); + + const sourceElements = [this.#renderDisplaySource()]; + + if (line) { + let lineInfo = `:${line}`; + + // Intentionally exclude 0 + if (column) { + lineInfo += `:${column}`; + } + + sourceElements.push( + dom.span( + { + key: "line", + className: "frame-link-line", + }, + lineInfo + ) + ); + } + + if (this.#isCurrentLocationLinkable()) { + return dom.a(this.#getSourceElementsProps(), sourceElements); + } + // If source is not a URL (self-hosted, eval, etc.), don't make + // it an anchor link, as we can't link to it. + return dom.span(this.#getSourceElementsProps(), sourceElements); + }; + + /** + * Render the display source. + * @returns {React.ReactNode} + */ + #renderDisplaySource = () => { + const { showEmptyPathAsHost, showFullSourceUrl } = this.props; + const { originalLocation } = this.state; + + const { source } = this.#getCurrentLocationInfo(); + const { short, long, host } = getSourceNames(source); + const unicodeShort = getUnicodeUrlPath(short); + const unicodeLong = getUnicodeUrl(long); + let displaySource = showFullSourceUrl ? unicodeLong : unicodeShort; + if (originalLocation) { + displaySource = getSourceMappedFile(displaySource); + + // In case of pretty-printed HTML file, we would only get the formatted suffix; replace + // it with the full URL instead + if (showEmptyPathAsHost && displaySource == ":formatted") { + displaySource = host + displaySource; + } + } else if ( + showEmptyPathAsHost && + (displaySource === "" || displaySource === "/") + ) { + displaySource = host; + } + + return dom.span( + { + key: "filename", + className: "frame-link-filename", + }, + displaySource + ); + }; + + /** + * Render the function display name. + * @returns {React.ReactNode} + */ + #renderFunctionDisplayName = () => { + const { frame, showFunctionName, showAnonymousFunctionName } = this.props; + if (!showFunctionName) { + return null; + } + const functionDisplayName = frame.functionDisplayName; + if (functionDisplayName || showAnonymousFunctionName) { + return [ + dom.span( + { + key: "function-display-name", + className: "frame-link-function-display-name", + }, + functionDisplayName || webl10n.getStr("stacktrace.anonymousFunction") + ), + " ", + ]; + } + return null; + }; + + render() { + const { showHost } = this.props; + + const elements = [ + this.#renderFunctionDisplayName(), + this.#renderSourceElements(), + ]; + + const unicodeHost = showHost + ? this.#getCurrentLocationUnicodeHostName() + : null; + if (unicodeHost) { + elements.push(" "); + elements.push( + dom.span( + { + key: "host", + className: "frame-link-host", + }, + unicodeHost + ) + ); + } + + return dom.span(this.#getTopElementProps(), ...elements); + } +} + +module.exports = Frame; diff --git a/devtools/client/shared/components/HSplitBox.js b/devtools/client/shared/components/HSplitBox.js new file mode 100644 index 0000000000..65dfc0aaf6 --- /dev/null +++ b/devtools/client/shared/components/HSplitBox.js @@ -0,0 +1,165 @@ +/* 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-env browser */ +"use strict"; + +// A box with a start and a end pane, separated by a dragable splitter that +// allows the user to resize the relative widths of the panes. +// +// +-----------------------+---------------------+ +// | | | +// | | | +// | S | +// | Start Pane p End Pane | +// | l | +// | i | +// | t | +// | t | +// | e | +// | r | +// | | | +// | | | +// +-----------------------+---------------------+ + +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { assert } = require("resource://devtools/shared/DevToolsUtils.js"); + +class HSplitBox extends Component { + static get propTypes() { + return { + // The contents of the start pane. + start: PropTypes.any.isRequired, + + // The contents of the end pane. + end: PropTypes.any.isRequired, + + // The relative width of the start pane, expressed as a number between 0 and + // 1. The relative width of the end pane is 1 - startWidth. For example, + // with startWidth = .5, both panes are of equal width; with startWidth = + // .25, the start panel will take up 1/4 width and the end panel will take + // up 3/4 width. + startWidth: PropTypes.number, + + // A minimum css width value for the start and end panes. + minStartWidth: PropTypes.any, + minEndWidth: PropTypes.any, + + // A callback fired when the user drags the splitter to resize the relative + // pane widths. The function is passed the startWidth value that would put + // the splitter underneath the users mouse. + onResize: PropTypes.func.isRequired, + }; + } + + static get defaultProps() { + return { + startWidth: 0.5, + minStartWidth: "20px", + minEndWidth: "20px", + }; + } + + constructor(props) { + super(props); + + this.state = { + mouseDown: false, + }; + + this._onMouseDown = this._onMouseDown.bind(this); + this._onMouseUp = this._onMouseUp.bind(this); + this._onMouseMove = this._onMouseMove.bind(this); + } + + componentDidMount() { + document.defaultView.top.addEventListener("mouseup", this._onMouseUp); + document.defaultView.top.addEventListener("mousemove", this._onMouseMove); + } + + componentWillUnmount() { + document.defaultView.top.removeEventListener("mouseup", this._onMouseUp); + document.defaultView.top.removeEventListener( + "mousemove", + this._onMouseMove + ); + } + + _onMouseDown(event) { + if (event.button !== 0) { + return; + } + + this.setState({ mouseDown: true }); + event.preventDefault(); + } + + _onMouseUp(event) { + if (event.button !== 0 || !this.state.mouseDown) { + return; + } + + this.setState({ mouseDown: false }); + event.preventDefault(); + } + + _onMouseMove(event) { + if (!this.state.mouseDown) { + return; + } + + const rect = this.refs.box.getBoundingClientRect(); + const { left, right } = rect; + const width = right - left; + const direction = this.refs.box.ownerDocument.dir; + const relative = + direction == "rtl" ? right - event.clientX : event.clientX - left; + this.props.onResize(relative / width); + + event.preventDefault(); + } + + render() { + /* eslint-disable no-shadow */ + const { start, end, startWidth, minStartWidth, minEndWidth } = this.props; + assert( + startWidth >= 0 && startWidth <= 1, + "0 <= this.props.startWidth <= 1" + ); + /* eslint-enable */ + return dom.div( + { + className: "h-split-box", + ref: "box", + }, + + dom.div( + { + className: "h-split-box-pane", + style: { flex: startWidth, minWidth: minStartWidth }, + }, + start + ), + + dom.div({ + className: "devtools-side-splitter", + onMouseDown: this._onMouseDown, + }), + + dom.div( + { + className: "h-split-box-pane", + style: { flex: 1 - startWidth, minWidth: minEndWidth }, + }, + end + ) + ); + } +} + +module.exports = HSplitBox; diff --git a/devtools/client/shared/components/List.css b/devtools/client/shared/components/List.css new file mode 100644 index 0000000000..1d0f668180 --- /dev/null +++ b/devtools/client/shared/components/List.css @@ -0,0 +1,41 @@ +/* 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/. */ + +/* List */ + +.list { + background-color: var(--theme-sidebar-background); + list-style-type: none; + padding: 0; + margin: 0; + width: 100%; + white-space: nowrap; + overflow: auto; +} + +.list:focus, .list .list-item-content:focus { + outline: 0; +} + +.list::-moz-focus-inner, .list .list-item-content::-moz-focus-inner { + border: 0; +} + +.list li.current { + background-color: var(--theme-toolbar-hover); +} + +.list:focus li.current, .list li.active.current { + background-color: var(--theme-emphasized-splitter-color); +} + +.list:focus li:not(.current):hover, +.list:not(:focus) li:not(.active):hover { + background-color: var(--theme-selection-background-hover); +} + +.list .list-item-content:not(:empty) { + font-size: 12px; + overflow: auto; +} diff --git a/devtools/client/shared/components/List.js b/devtools/client/shared/components/List.js new file mode 100644 index 0000000000..95c3ffe4dd --- /dev/null +++ b/devtools/client/shared/components/List.js @@ -0,0 +1,352 @@ +/* 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, + createRef, + Component, + cloneElement, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + ul, + li, + div, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const { + scrollIntoView, +} = require("resource://devtools/client/shared/scroll.js"); +const { + preventDefaultAndStopPropagation, +} = require("resource://devtools/client/shared/events.js"); + +loader.lazyRequireGetter( + this, + ["getFocusableElements", "wrapMoveFocus"], + "resource://devtools/client/shared/focus.js", + true +); + +class ListItemClass extends Component { + static get propTypes() { + return { + active: PropTypes.bool, + current: PropTypes.bool, + onClick: PropTypes.func, + item: PropTypes.shape({ + key: PropTypes.string, + component: PropTypes.object, + componentProps: PropTypes.object, + className: PropTypes.string, + }).isRequired, + }; + } + + constructor(props) { + super(props); + + this.contentRef = createRef(); + + this._setTabbableState = this._setTabbableState.bind(this); + this._onKeyDown = this._onKeyDown.bind(this); + } + + componentDidMount() { + this._setTabbableState(); + } + + componentDidUpdate() { + this._setTabbableState(); + } + + _onKeyDown(event) { + const { target, key, shiftKey } = event; + + if (key !== "Tab") { + return; + } + + const focusMoved = !!wrapMoveFocus( + getFocusableElements(this.contentRef.current), + target, + shiftKey + ); + if (focusMoved) { + // Focus was moved to the begining/end of the list, so we need to prevent the + // default focus change that would happen here. + event.preventDefault(); + } + + event.stopPropagation(); + } + + /** + * Makes sure that none of the focusable elements inside the list item container are + * tabbable if the list item is not active. If the list item is active and focus is + * outside its container, focus on the first focusable element inside. + */ + _setTabbableState() { + const elms = getFocusableElements(this.contentRef.current); + if (elms.length === 0) { + return; + } + + if (!this.props.active) { + elms.forEach(elm => elm.setAttribute("tabindex", "-1")); + return; + } + + if (!elms.includes(document.activeElement)) { + elms[0].focus(); + } + } + + render() { + const { active, item, current, onClick } = this.props; + const { className, component, componentProps } = item; + + return li( + { + className: `${className}${current ? " current" : ""}${ + active ? " active" : "" + }`, + id: item.key, + onClick, + onKeyDownCapture: active ? this._onKeyDown : null, + }, + div( + { + className: "list-item-content", + role: "presentation", + ref: this.contentRef, + }, + cloneElement(component, componentProps || {}) + ) + ); + } +} + +const ListItem = createFactory(ListItemClass); + +class List extends Component { + static get propTypes() { + return { + // A list of all items to be rendered using a List component. + items: PropTypes.arrayOf( + PropTypes.shape({ + component: PropTypes.object, + componentProps: PropTypes.object, + className: PropTypes.string, + key: PropTypes.string.isRequired, + }) + ).isRequired, + + // Note: the two properties below are mutually exclusive. Only one of the + // label properties is necessary. + // ID of an element whose textual content serves as an accessible label for + // a list. + labelledBy: PropTypes.string, + + // Accessibility label for a list widget. + label: PropTypes.string, + }; + } + + constructor(props) { + super(props); + + this.listRef = createRef(); + + this.state = { + active: null, + current: null, + mouseDown: false, + }; + + this._setCurrentItem = this._setCurrentItem.bind(this); + this._preventArrowKeyScrolling = this._preventArrowKeyScrolling.bind(this); + this._onKeyDown = this._onKeyDown.bind(this); + } + + shouldComponentUpdate(nextProps, nextState) { + const { active, current, mouseDown } = this.state; + + return ( + current !== nextState.current || + active !== nextState.active || + mouseDown === nextState.mouseDown + ); + } + + _preventArrowKeyScrolling(e) { + switch (e.key) { + case "ArrowUp": + case "ArrowDown": + case "ArrowLeft": + case "ArrowRight": + preventDefaultAndStopPropagation(e); + break; + } + } + + /** + * Sets the passed in item to be the current item. + * + * @param {null|Number} index + * The index of the item in to be set as current, or undefined to unset the + * current item. + */ + _setCurrentItem(index = -1, options = {}) { + const item = this.props.items[index]; + if (item !== undefined && !options.preventAutoScroll) { + const element = document.getElementById(item.key); + scrollIntoView(element, { + ...options, + container: this.listRef.current, + }); + } + + const state = {}; + if (this.state.active != undefined) { + state.active = null; + if (this.listRef.current !== document.activeElement) { + this.listRef.current.focus(); + } + } + + if (this.state.current !== index) { + this.setState({ + ...state, + current: index, + }); + } + } + + /** + * Handles key down events in the list's container. + * + * @param {Event} e + */ + _onKeyDown(e) { + const { active, current } = this.state; + if (current == null) { + return; + } + + if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) { + return; + } + + this._preventArrowKeyScrolling(e); + + const { length } = this.props.items; + switch (e.key) { + case "ArrowUp": + current > 0 && this._setCurrentItem(current - 1, { alignTo: "top" }); + break; + + case "ArrowDown": + current < length - 1 && + this._setCurrentItem(current + 1, { alignTo: "bottom" }); + break; + + case "Home": + this._setCurrentItem(0, { alignTo: "top" }); + break; + + case "End": + this._setCurrentItem(length - 1, { alignTo: "bottom" }); + break; + + case "Enter": + case " ": + // On space or enter make current list item active. This means keyboard focus + // handling is passed on to the component within the list item. + if (document.activeElement === this.listRef.current) { + preventDefaultAndStopPropagation(e); + if (active !== current) { + this.setState({ active: current }); + } + } + break; + + case "Escape": + // If current list item is active, make it inactive and let keyboard focusing be + // handled normally. + preventDefaultAndStopPropagation(e); + if (active != null) { + this.setState({ active: null }); + } + + this.listRef.current.focus(); + break; + } + } + + render() { + const { active, current } = this.state; + const { items } = this.props; + + return ul( + { + ref: this.listRef, + className: "list", + tabIndex: 0, + onKeyDown: this._onKeyDown, + onKeyPress: this._preventArrowKeyScrolling, + onKeyUp: this._preventArrowKeyScrolling, + onMouseDown: () => this.setState({ mouseDown: true }), + onMouseUp: () => this.setState({ mouseDown: false }), + onFocus: () => { + if (current != null || this.state.mouseDown) { + return; + } + + // Only set default current to the first list item if current item is + // not yet set and the focus event is not the result of a mouse + // interarction. + this._setCurrentItem(0); + }, + onClick: () => { + // Focus should always remain on the list container itself. + this.listRef.current.focus(); + }, + onBlur: e => { + if (active != null) { + const { relatedTarget } = e; + if (!this.listRef.current.contains(relatedTarget)) { + this.setState({ active: null }); + } + } + }, + "aria-label": this.props.label, + "aria-labelledby": this.props.labelledBy, + "aria-activedescendant": current != null ? items[current].key : null, + }, + items.map((item, index) => { + return ListItem({ + item, + current: index === current, + active: index === active, + // We make a key unique depending on whether the list item is in active or + // inactive state to make sure that it is actually replaced and the tabbable + // state is reset. + key: `${item.key}-${index === active ? "active" : "inactive"}`, + // Since the user just clicked the item, there's no need to check if it should + // be scrolled into view. + onClick: () => + this._setCurrentItem(index, { preventAutoScroll: true }), + }); + }) + ); + } +} + +module.exports = { + ListItem: ListItemClass, + List, +}; diff --git a/devtools/client/shared/components/MdnLink.css b/devtools/client/shared/components/MdnLink.css new file mode 100644 index 0000000000..0fef9c0bba --- /dev/null +++ b/devtools/client/shared/components/MdnLink.css @@ -0,0 +1,33 @@ +/* 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/. */ + +/* Learn more links */ + +.network-monitor .learn-more-link { + display: inline-block; + line-height: 16px; +} + +.network-monitor .learn-more-link::before { + background-image: url(chrome://devtools/skin/images/help.svg); + background-size: contain; +} + +.network-monitor .tree-container .learn-more-link { + position: absolute; + top: 0; + inset-inline-start: 2px; + /* Override devtools-button styles to make this button 20x20, + * so that the icon is vertically centered in the table row */ + padding: 1px 0; +} + +.network-monitor .tree-container tr:not(:hover) .learn-more-link { + opacity: 0.4; +} + +.network-monitor .tabpanel-summary-value.status { + display: flex; + align-items: center; +} diff --git a/devtools/client/shared/components/MdnLink.js b/devtools/client/shared/components/MdnLink.js new file mode 100644 index 0000000000..344143f54c --- /dev/null +++ b/devtools/client/shared/components/MdnLink.js @@ -0,0 +1,38 @@ +/* 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"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { a } = dom; + +loader.lazyRequireGetter( + this, + "openDocLink", + "resource://devtools/client/shared/link.js", + true +); + +function MDNLink({ url, title }) { + return a({ + className: "devtools-button learn-more-link", + title, + onClick: e => onLearnMoreClick(e, url), + }); +} + +MDNLink.displayName = "MDNLink"; + +MDNLink.propTypes = { + url: PropTypes.string.isRequired, +}; + +function onLearnMoreClick(e, url) { + e.stopPropagation(); + e.preventDefault(); + openDocLink(url); +} + +module.exports = MDNLink; diff --git a/devtools/client/shared/components/NotificationBox.css b/devtools/client/shared/components/NotificationBox.css new file mode 100644 index 0000000000..f2ff550f46 --- /dev/null +++ b/devtools/client/shared/components/NotificationBox.css @@ -0,0 +1,130 @@ +/* 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/. */ + +/* Layout */ + +.notificationbox .notificationInner { + display: flex; + flex-direction: row; + align-items: center; +} + +.notificationInner .messageText { + flex: 1; + width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.notificationInner .messageImage, +.notificationbox .notificationButton, +.notificationbox .messageCloseButton { + flex: none; +} + +.notificationbox .notificationInner:dir(rtl) { + flex-direction: row-reverse; +} + +/* Style */ + +.notificationbox .notification { + color: var(--theme-toolbar-color); + background-color: var(--theme-body-background); + text-shadow: none; + border-color: var(--theme-splitter-color); + border-style: solid; + border-width: 0; +} + +.notificationbox.border-top .notification { + border-top-width: 1px; +} + +.notificationbox.border-bottom .notification { + border-bottom-width: 1px; +} + +.notificationbox .notification[data-type="info"] { + color: -moz-DialogText; + background-color: -moz-Dialog; +} + +.notificationbox .notification[data-type="new"] { + color: var(--theme-contrast-color); + background-color: var(--theme-body-alternate-emphasized-background); +} + +/** + * Remove button borders for notifications highlighting New features. + */ +.notification[data-type="new"] .notificationButton { + border-radius: 2px; + border-width: 0; + padding: 4px; +} + +.notificationbox .notification[data-type="critical"] { + color: white; + background-image: linear-gradient(rgb(212,0,0), rgb(152,0,0)); +} + +.notificationbox .messageImage { + -moz-context-properties: fill; + fill: currentColor; + background-size: 16px; + width: 16px; + height: 16px; + margin: 6px; +} + +/* Default icons for notifications */ + +.notificationbox .messageImage[data-type="info"] { + background-image: url("chrome://devtools/skin/images/info.svg"); +} + +.notificationbox .messageImage[data-type="new"] { + background-image: url("chrome://global/skin/icons/whatsnew.svg"); + fill: var(--theme-highlight-blue); +} + +.notificationbox .messageImage[data-type="warning"] { + background-image: url("chrome://devtools/skin/images/alert.svg"); + /* Keep the icon colored to make it more eye-catching */ + fill: #ffbf00; +} + +.notificationbox .messageImage[data-type="critical"] { + background-image: url("chrome://devtools/skin/images/error.svg"); +} + +/* Close button */ + +.notificationbox .messageCloseButton { + width: 24px; + height: 24px; + margin: 2px 4px; + background-image: url("chrome://devtools/skin/images/close.svg"); + background-position: center; + background-color: transparent; + background-repeat: no-repeat; + border-radius: 2px; + border-width: 0; + -moz-context-properties: fill; + fill: var(--theme-icon-color); +} + +.notificationbox .messageCloseButton:hover { + background-color: var(--theme-button-active-background); +} + +.notificationbox .messageCloseButton:active { + background-color: rgba(170, 170, 170, .4); /* --toolbar-tab-hover-active */ +} + +.notificationbox.wrapping .notificationInner .messageText { + white-space: normal; +} diff --git a/devtools/client/shared/components/NotificationBox.js b/devtools/client/shared/components/NotificationBox.js new file mode 100644 index 0000000000..53e1073d7a --- /dev/null +++ b/devtools/client/shared/components/NotificationBox.js @@ -0,0 +1,403 @@ +/* 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 { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); + +const l10n = new LocalizationHelper( + "devtools/client/locales/components.properties" +); +const { div, span, button } = dom; +loader.lazyGetter(this, "MDNLink", function () { + return createFactory( + require("resource://devtools/client/shared/components/MdnLink.js") + ); +}); + +// Priority Levels +const PriorityLevels = { + PRIORITY_INFO_LOW: 1, + PRIORITY_INFO_MEDIUM: 2, + PRIORITY_INFO_HIGH: 3, + // Type NEW should be used to highlight new features, and should be more + // eye-catchy than INFO level notifications. + PRIORITY_NEW: 4, + PRIORITY_WARNING_LOW: 5, + PRIORITY_WARNING_MEDIUM: 6, + PRIORITY_WARNING_HIGH: 7, + PRIORITY_CRITICAL_LOW: 8, + PRIORITY_CRITICAL_MEDIUM: 9, + PRIORITY_CRITICAL_HIGH: 10, + PRIORITY_CRITICAL_BLOCK: 11, +}; + +/** + * This component represents Notification Box - HTML alternative for + * <xul:notificationbox> binding. + * + * See also MDN for more info about <xul:notificationbox>: + * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/notificationbox + * + * This component can maintain its own state (list of notifications) + * as well as consume list of notifications provided as a prop + * (coming e.g. from Redux store). + */ +class NotificationBox extends Component { + static get propTypes() { + return { + // Optional box ID (used for mounted node ID attribute) + id: PropTypes.string, + /** + * List of notifications appended into the box. Each item of the map is an object + * of the following shape: + * - {String} label: Label to appear on the notification. + * - {String} value: Value used to identify the notification. Should be the same + * as the map key used for this notification. + * - {String} image: URL of image to appear on the notification. If "" then an + * appropriate icon for the priority level is used. + * - {Number} priority: Notification priority; see Priority Levels. + * - {Function} eventCallback: A function to call to notify you of interesting + things that happen with the notification box. + - {String} type: One of "info", "warning", or "critical" used to determine + what styling and icon are used for the notification. + * - {Array<Object>} buttons: Array of button descriptions to appear on the + * notification. Should be of the following shape: + * - {Function} callback: This function is passed 3 arguments: + 1) the NotificationBox component + the button is associated with. + 2) the button description as passed + to appendNotification. + 3) the element which was the target + of the button press event. + If the return value from this function + is not true, then the notification is + closed. The notification is also not + closed if an error is thrown. + - {String} label: The label to appear on the button. + - {String} accesskey: The accesskey attribute set on the + <button> element. + - {String} mdnUrl: URL to MDN docs. Optional but if set + turns button into a MDNLink and supersedes + all other properties. Uses Label as the title + for the link. + */ + notifications: PropTypes.instanceOf(Map), + // Message that should be shown when hovering over the close button + closeButtonTooltip: PropTypes.string, + // Wraps text when passed from console window as wrapping: true + wrapping: PropTypes.bool, + // Display a top border (default to false) + displayBorderTop: PropTypes.bool, + // Display a bottom border (default to true) + displayBorderBottom: PropTypes.bool, + // Display a close button (default to true) + displayCloseButton: PropTypes.bool, + }; + } + + static get defaultProps() { + return { + closeButtonTooltip: l10n.getStr("notificationBox.closeTooltip"), + displayBorderTop: false, + displayBorderBottom: true, + displayCloseButton: true, + }; + } + + constructor(props) { + super(props); + + this.state = { + notifications: new Map(), + }; + + this.appendNotification = this.appendNotification.bind(this); + this.removeNotification = this.removeNotification.bind(this); + this.getNotificationWithValue = this.getNotificationWithValue.bind(this); + this.getCurrentNotification = this.getCurrentNotification.bind(this); + this.close = this.close.bind(this); + this.renderButton = this.renderButton.bind(this); + this.renderNotification = this.renderNotification.bind(this); + } + + /** + * Create a new notification and display it. If another notification is + * already present with a higher priority, the new notification will be + * added behind it. See `propTypes` for arguments description. + */ + appendNotification( + label, + value, + image, + priority, + buttons = [], + eventCallback + ) { + const newState = appendNotification(this.state, { + label, + value, + image, + priority, + buttons, + eventCallback, + }); + + this.setState(newState); + } + + /** + * Remove specific notification from the list. + */ + removeNotification(notification) { + if (notification) { + this.close(this.state.notifications.get(notification.value)); + } + } + + /** + * Returns an object that represents a notification. It can be + * used to close it. + */ + getNotificationWithValue(value) { + const notification = this.state.notifications.get(value); + if (!notification) { + return null; + } + + // Return an object that can be used to remove the notification + // later (using `removeNotification` method) or directly close it. + return Object.assign({}, notification, { + close: () => { + this.close(notification); + }, + }); + } + + getCurrentNotification() { + return getHighestPriorityNotification(this.state.notifications); + } + + /** + * Close specified notification. + */ + close(notification) { + if (!notification) { + return; + } + + if (notification.eventCallback) { + notification.eventCallback("removed"); + } + + if (!this.state.notifications.get(notification.value)) { + return; + } + + const newNotifications = new Map(this.state.notifications); + newNotifications.delete(notification.value); + this.setState({ + notifications: newNotifications, + }); + } + + /** + * Render a button. A notification can have a set of custom buttons. + * These are used to execute custom callback. Will render a MDNLink + * if mdnUrl property is set. + */ + renderButton(props, notification) { + if (props.mdnUrl != null) { + return MDNLink({ + url: props.mdnUrl, + title: props.label, + }); + } + const onClick = event => { + if (props.callback) { + const result = props.callback(this, props, event.target); + if (!result) { + this.close(notification); + } + event.stopPropagation(); + } + }; + + return button( + { + key: props.label, + className: "notificationButton", + accesskey: props.accesskey, + onClick, + }, + props.label + ); + } + + /** + * Render a notification. + */ + renderNotification(notification) { + return div( + { + key: notification.value, + className: "notification", + "data-key": notification.value, + "data-type": notification.type, + }, + div( + { className: "notificationInner" }, + div({ + className: "messageImage", + "data-type": notification.type, + }), + span( + { + className: "messageText", + title: notification.label, + }, + notification.label + ), + notification.buttons.map(props => + this.renderButton(props, notification) + ), + this.props.displayCloseButton + ? button({ + className: "messageCloseButton", + title: this.props.closeButtonTooltip, + onClick: this.close.bind(this, notification), + }) + : null + ) + ); + } + + /** + * Render the top (highest priority) notification. Only one + * notification is rendered at a time. + */ + render() { + const notifications = this.props.notifications || this.state.notifications; + const notification = getHighestPriorityNotification(notifications); + const content = notification ? this.renderNotification(notification) : null; + + const classNames = ["notificationbox"]; + if (this.props.wrapping) { + classNames.push("wrapping"); + } + + if (this.props.displayBorderBottom) { + classNames.push("border-bottom"); + } + + if (this.props.displayBorderTop) { + classNames.push("border-top"); + } + + return div( + { + className: classNames.join(" "), + id: this.props.id, + }, + content + ); + } +} + +// Helpers + +/** + * Create a new notification. If another notification is already present with + * a higher priority, the new notification will be added behind it. + * See `propTypes` for arguments description. + */ +function appendNotification(state, props) { + const { label, value, image, priority, buttons, eventCallback } = props; + + // Priority level must be within expected interval + // (see priority levels at the top of this file). + if ( + priority < PriorityLevels.PRIORITY_INFO_LOW || + priority > PriorityLevels.PRIORITY_CRITICAL_BLOCK + ) { + throw new Error("Invalid notification priority " + priority); + } + + // Custom image URL is not supported yet. + if (image) { + throw new Error("Custom image URL is not supported yet"); + } + + let type = "warning"; + if (priority == PriorityLevels.PRIORITY_NEW) { + type = "new"; + } else if (priority >= PriorityLevels.PRIORITY_CRITICAL_LOW) { + type = "critical"; + } else if (priority <= PriorityLevels.PRIORITY_INFO_HIGH) { + type = "info"; + } + + if (!state.notifications) { + state.notifications = new Map(); + } + + const notifications = new Map(state.notifications); + notifications.set(value, { + label, + value, + image, + priority, + type, + buttons: Array.isArray(buttons) ? buttons : [], + eventCallback, + }); + + return { + notifications, + }; +} + +function getNotificationWithValue(notifications, value) { + return notifications ? notifications.get(value) : null; +} + +function removeNotificationWithValue(notifications, value) { + const newNotifications = new Map(notifications); + newNotifications.delete(value); + + return { + notifications: newNotifications, + }; +} + +function getHighestPriorityNotification(notifications) { + if (!notifications) { + return null; + } + + let currentNotification = null; + // High priorities must be on top. + for (const [, notification] of notifications) { + if ( + !currentNotification || + notification.priority > currentNotification.priority + ) { + currentNotification = notification; + } + } + + return currentNotification; +} + +module.exports.NotificationBox = NotificationBox; +module.exports.PriorityLevels = PriorityLevels; +module.exports.appendNotification = appendNotification; +module.exports.getNotificationWithValue = getNotificationWithValue; +module.exports.removeNotificationWithValue = removeNotificationWithValue; diff --git a/devtools/client/shared/components/SearchBox.js b/devtools/client/shared/components/SearchBox.js new file mode 100644 index 0000000000..a1c771bfb0 --- /dev/null +++ b/devtools/client/shared/components/SearchBox.js @@ -0,0 +1,275 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global window */ + +"use strict"; + +const { + createFactory, + createRef, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const l10n = new LocalizationHelper( + "devtools/client/locales/components.properties" +); + +loader.lazyGetter(this, "SearchBoxAutocompletePopup", function () { + return createFactory( + require("resource://devtools/client/shared/components/SearchBoxAutocompletePopup.js") + ); +}); +loader.lazyGetter(this, "MDNLink", function () { + return createFactory( + require("resource://devtools/client/shared/components/MdnLink.js") + ); +}); + +loader.lazyRequireGetter( + this, + "KeyShortcuts", + "resource://devtools/client/shared/key-shortcuts.js" +); + +class SearchBox extends PureComponent { + static get propTypes() { + return { + autocompleteProvider: PropTypes.func, + delay: PropTypes.number, + keyShortcut: PropTypes.string, + learnMoreTitle: PropTypes.string, + learnMoreUrl: PropTypes.string, + onBlur: PropTypes.func, + onChange: PropTypes.func.isRequired, + onClearButtonClick: PropTypes.func, + onFocus: PropTypes.func, + // Optional function that will be called on the focus keyboard shortcut, before + // setting the focus to the input. If the function returns false, the input won't + // get focused. + onFocusKeyboardShortcut: PropTypes.func, + onKeyDown: PropTypes.func, + placeholder: PropTypes.string.isRequired, + summary: PropTypes.string, + summaryTooltip: PropTypes.string, + type: PropTypes.string, + value: PropTypes.string, + }; + } + + constructor(props) { + super(props); + + this.state = { + value: props.value || "", + focused: false, + }; + + this.autocompleteRef = createRef(); + this.inputRef = createRef(); + + this.onBlur = this.onBlur.bind(this); + this.onChange = this.onChange.bind(this); + this.onClearButtonClick = this.onClearButtonClick.bind(this); + this.onFocus = this.onFocus.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + } + + componentDidMount() { + if (!this.props.keyShortcut) { + return; + } + + this.shortcuts = new KeyShortcuts({ + window, + }); + this.shortcuts.on(this.props.keyShortcut, event => { + if (this.props.onFocusKeyboardShortcut?.(event)) { + return; + } + + event.preventDefault(); + this.focus(); + }); + } + + componentWillUnmount() { + if (this.shortcuts) { + this.shortcuts.destroy(); + } + + // Clean up an existing timeout. + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + } + + focus() { + if (this.inputRef) { + this.inputRef.current.focus(); + } + } + + onChange(inputValue = "") { + if (this.state.value !== inputValue) { + this.setState({ + focused: true, + value: inputValue, + }); + } + + if (!this.props.delay) { + this.props.onChange(inputValue); + return; + } + + // Clean up an existing timeout before creating a new one. + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + + // Execute the search after a timeout. It makes the UX + // smoother if the user is typing quickly. + this.searchTimeout = setTimeout(() => { + this.searchTimeout = null; + this.props.onChange(this.state.value); + }, this.props.delay); + } + + onClearButtonClick() { + this.onChange(""); + + if (this.props.onClearButtonClick) { + this.props.onClearButtonClick(); + } + } + + onFocus() { + if (this.props.onFocus) { + this.props.onFocus(); + } + + this.setState({ focused: true }); + } + + onBlur() { + if (this.props.onBlur) { + this.props.onBlur(); + } + + this.setState({ focused: false }); + } + + onKeyDown(e) { + if (this.props.onKeyDown) { + this.props.onKeyDown(e); + } + + const autocomplete = this.autocompleteRef.current; + if (!autocomplete || autocomplete.state.list.length <= 0) { + return; + } + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + autocomplete.jumpBy(1); + break; + case "ArrowUp": + e.preventDefault(); + autocomplete.jumpBy(-1); + break; + case "PageDown": + e.preventDefault(); + autocomplete.jumpBy(5); + break; + case "PageUp": + e.preventDefault(); + autocomplete.jumpBy(-5); + break; + case "Enter": + case "Tab": + e.preventDefault(); + autocomplete.select(); + break; + case "Escape": + e.preventDefault(); + this.onBlur(); + break; + case "Home": + e.preventDefault(); + autocomplete.jumpToTop(); + break; + case "End": + e.preventDefault(); + autocomplete.jumpToBottom(); + break; + } + } + + render() { + const { + autocompleteProvider, + summary, + summaryTooltip, + learnMoreTitle, + learnMoreUrl, + placeholder, + type = "search", + } = this.props; + const { value } = this.state; + const showAutocomplete = + autocompleteProvider && this.state.focused && value !== ""; + const showLearnMoreLink = learnMoreUrl && value === ""; + + const inputClassList = [`devtools-${type}input`]; + + return dom.div( + { className: "devtools-searchbox" }, + dom.input({ + className: inputClassList.join(" "), + onBlur: this.onBlur, + onChange: e => this.onChange(e.target.value), + onFocus: this.onFocus, + onKeyDown: this.onKeyDown, + placeholder, + ref: this.inputRef, + value, + type: "search", + }), + showLearnMoreLink && + MDNLink({ + title: learnMoreTitle, + url: learnMoreUrl, + }), + summary + ? dom.span( + { + className: "devtools-searchinput-summary", + title: summaryTooltip || "", + }, + summary + ) + : null, + dom.button({ + className: "devtools-searchinput-clear", + hidden: value === "", + onClick: this.onClearButtonClick, + title: l10n.getStr("searchBox.clearButtonTitle"), + }), + showAutocomplete && + SearchBoxAutocompletePopup({ + autocompleteProvider, + filter: value, + onItemSelected: itemValue => this.onChange(itemValue), + ref: this.autocompleteRef, + }) + ); + } +} + +module.exports = SearchBox; diff --git a/devtools/client/shared/components/SearchBoxAutocompletePopup.js b/devtools/client/shared/components/SearchBoxAutocompletePopup.js new file mode 100644 index 0000000000..08aad18872 --- /dev/null +++ b/devtools/client/shared/components/SearchBoxAutocompletePopup.js @@ -0,0 +1,150 @@ +/* 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 { + Component, +} = 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"); + +class SearchBoxAutocompletePopup extends Component { + static get propTypes() { + return { + /** + * autocompleteProvider takes search-box's entire input text as `filter` argument + * ie. "is:cached pr" + * returned value is array of objects like below + * [{value: "is:cached protocol", displayValue: "protocol"}[, ...]] + * `value` is used to update the search-box input box for given item + * `displayValue` is used to render the autocomplete list + */ + autocompleteProvider: PropTypes.func.isRequired, + filter: PropTypes.string.isRequired, + onItemSelected: PropTypes.func.isRequired, + }; + } + + constructor(props, context) { + super(props, context); + this.state = this.computeState(props); + this.computeState = this.computeState.bind(this); + this.jumpToTop = this.jumpToTop.bind(this); + this.jumpToBottom = this.jumpToBottom.bind(this); + this.jumpBy = this.jumpBy.bind(this); + this.select = this.select.bind(this); + this.onMouseDown = this.onMouseDown.bind(this); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + if (this.props.filter === nextProps.filter) { + return; + } + this.setState(this.computeState(nextProps)); + } + + componentDidUpdate() { + if (this.refs.selected) { + this.refs.selected.scrollIntoView(false); + } + } + + computeState({ autocompleteProvider, filter }) { + const list = autocompleteProvider(filter); + const selectedIndex = list.length ? 0 : -1; + + return { list, selectedIndex }; + } + + /** + * Use this method to select the top-most item + * This method is public, called outside of the autocomplete-popup component. + */ + jumpToTop() { + this.setState({ selectedIndex: 0 }); + } + + /** + * Use this method to select the bottom-most item + * This method is public. + */ + jumpToBottom() { + this.setState({ selectedIndex: this.state.list.length - 1 }); + } + + /** + * Increment the selected index with the provided increment value. Will cycle to the + * beginning/end of the list if the index exceeds the list boundaries. + * This method is public. + * + * @param {number} increment - No. of hops in the direction + */ + jumpBy(increment = 1) { + const { list, selectedIndex } = this.state; + let nextIndex = selectedIndex + increment; + if (increment > 0) { + // Positive cycling + nextIndex = nextIndex > list.length - 1 ? 0 : nextIndex; + } else if (increment < 0) { + // Inverse cycling + nextIndex = nextIndex < 0 ? list.length - 1 : nextIndex; + } + this.setState({ selectedIndex: nextIndex }); + } + + /** + * Submit the currently selected item to the onItemSelected callback + * This method is public. + */ + select() { + if (this.refs.selected) { + this.props.onItemSelected(this.refs.selected.dataset.value); + } + } + + onMouseDown(e) { + e.preventDefault(); + this.setState( + { selectedIndex: Number(e.target.dataset.index) }, + this.select + ); + } + + render() { + const { list } = this.state; + + return ( + !!list.length && + dom.div( + { className: "devtools-autocomplete-popup devtools-monospace" }, + dom.ul( + { className: "devtools-autocomplete-listbox" }, + list.map((item, i) => { + const isSelected = this.state.selectedIndex == i; + const itemClassList = ["autocomplete-item"]; + + if (isSelected) { + itemClassList.push("autocomplete-selected"); + } + return dom.li( + { + key: i, + "data-index": i, + "data-value": item.value, + className: itemClassList.join(" "), + ref: isSelected ? "selected" : null, + onMouseDown: this.onMouseDown, + }, + item.displayValue + ); + }) + ) + ) + ); + } +} + +module.exports = SearchBoxAutocompletePopup; diff --git a/devtools/client/shared/components/SearchModifiers.css b/devtools/client/shared/components/SearchModifiers.css new file mode 100644 index 0000000000..b92f12b1f9 --- /dev/null +++ b/devtools/client/shared/components/SearchModifiers.css @@ -0,0 +1,64 @@ +/* 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/. */ + +/* Search Modifiers */ + +.search-modifiers { + display: flex; + align-items: center; +} + +.search-modifiers button { + padding: 2px; + margin: 0 3px; + border: none; + background: none; + width: 20px; + height: 20px; + border-radius: 2px; +} + +.search-modifiers .pipe-divider { + flex: none; + align-self: stretch; + width: 1px; + vertical-align: middle; + margin: 4px; + background-color: var(--theme-splitter-color); +} + +.search-modifiers button > span { + display: inline-block; + width: 16px; + height: 16px; + vertical-align: middle; + background-color: transparent; + background-position: center; + background-repeat: no-repeat; + background-size: contain; + /* do not let images shrink when used as flex children */ + flex-shrink: 0; + fill: var(--theme-icon-color); + -moz-context-properties: fill; +} + +.search-modifiers button > span.case-match { + background-image: url(chrome://devtools/content/debugger/images/case-match.svg); +} + +.search-modifiers button > span.regex-match { + background-image: url(chrome://devtools/content/debugger/images/regex-match.svg); +} + +.search-modifiers button > span.whole-word-match { + background-image: url(chrome://devtools/content/debugger/images/whole-word-match.svg); +} + +.search-modifiers button:hover { + fill: var(--theme-toolbar-background-hover); +} + +.search-modifiers button.active > span { + fill: var(--theme-icon-checked-color); +} diff --git a/devtools/client/shared/components/SearchModifiers.js b/devtools/client/shared/components/SearchModifiers.js new file mode 100644 index 0000000000..fb66076b83 --- /dev/null +++ b/devtools/client/shared/components/SearchModifiers.js @@ -0,0 +1,84 @@ +/* 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 { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + div, + span, + button, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +loader.lazyGetter(this, "l10n", function () { + const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); + return new LocalizationHelper( + "devtools/client/locales/components.properties" + ); +}); + +const modifierOptions = [ + { + value: "regexMatch", + className: "regex-match-btn", + svgName: "regex-match", + tooltip: l10n.getStr("searchModifier.regExpModifier"), + }, + { + value: "caseSensitive", + className: "case-sensitive-btn", + svgName: "case-match", + tooltip: l10n.getStr("searchModifier.caseSensitiveModifier"), + }, + { + value: "wholeWord", + className: "whole-word-btn", + svgName: "whole-word-match", + tooltip: l10n.getStr("searchModifier.wholeWordModifier"), + }, +]; + +class SearchModifiers extends Component { + static get propTypes() { + return { + modifiers: PropTypes.object.isRequired, + onToggleSearchModifier: PropTypes.func.isRequired, + }; + } + + #renderSearchModifier({ value, className, svgName, tooltip }) { + const { modifiers, onToggleSearchModifier } = this.props; + + return button( + { + className: `${className} ${modifiers?.[value] ? "active" : ""}`, + onMouseDown: () => { + modifiers[value] = !modifiers[value]; + onToggleSearchModifier(modifiers); + }, + onKeyDown: e => { + if (e.key === "Enter") { + modifiers[value] = !modifiers[value]; + onToggleSearchModifier(modifiers); + } + }, + title: tooltip, + }, + span({ className: svgName }) + ); + } + + render() { + return div( + { className: "search-modifiers" }, + span({ className: "pipe-divider" }), + modifierOptions.map(options => this.#renderSearchModifier(options)) + ); + } +} + +module.exports = SearchModifiers; diff --git a/devtools/client/shared/components/Sidebar.js b/devtools/client/shared/components/Sidebar.js new file mode 100644 index 0000000000..bf9ef9938d --- /dev/null +++ b/devtools/client/shared/components/Sidebar.js @@ -0,0 +1,98 @@ +/* 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 SidebarToggle = createFactory( + require("resource://devtools/client/shared/components/SidebarToggle.js") +); +const Tabs = createFactory( + require("resource://devtools/client/shared/components/tabs/Tabs.js").Tabs +); + +class Sidebar extends PureComponent { + static get propTypes() { + return { + children: PropTypes.oneOfType([PropTypes.array, PropTypes.element]) + .isRequired, + onAfterChange: PropTypes.func, + onAllTabsMenuClick: PropTypes.func, + renderOnlySelected: PropTypes.bool, + showAllTabsMenu: PropTypes.bool, + allTabsMenuButtonTooltip: PropTypes.string, + sidebarToggleButton: PropTypes.shape({ + collapsed: PropTypes.bool.isRequired, + collapsePaneTitle: PropTypes.string.isRequired, + expandPaneTitle: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + alignRight: PropTypes.bool, + canVerticalSplit: PropTypes.bool, + }), + activeTab: PropTypes.number, + }; + } + + constructor(props) { + super(props); + this.renderSidebarToggle = this.renderSidebarToggle.bind(this); + } + + renderSidebarToggle() { + if (!this.props.sidebarToggleButton) { + return null; + } + + const { + collapsed, + collapsePaneTitle, + expandPaneTitle, + onClick, + alignRight, + canVerticalSplit, + } = this.props.sidebarToggleButton; + + return SidebarToggle({ + collapsed, + collapsePaneTitle, + expandPaneTitle, + onClick, + alignRight, + canVerticalSplit, + }); + } + + render() { + const { renderSidebarToggle } = this; + const { + children, + onAfterChange, + onAllTabsMenuClick, + renderOnlySelected, + showAllTabsMenu, + allTabsMenuButtonTooltip, + activeTab, + } = this.props; + + return Tabs( + { + onAfterChange, + onAllTabsMenuClick, + renderOnlySelected, + renderSidebarToggle, + showAllTabsMenu, + allTabsMenuButtonTooltip, + activeTab, + }, + children + ); + } +} + +module.exports = Sidebar; diff --git a/devtools/client/shared/components/SidebarToggle.css b/devtools/client/shared/components/SidebarToggle.css new file mode 100644 index 0000000000..f715816d8c --- /dev/null +++ b/devtools/client/shared/components/SidebarToggle.css @@ -0,0 +1,39 @@ +/* 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/. */ + +.sidebar-toggle { + display: block; +} + +.sidebar-toggle::before, +.sidebar-toggle.pane-collapsed:dir(rtl)::before { + background-image: url(chrome://devtools/skin/images/pane-collapse.svg); +} + +.sidebar-toggle.pane-collapsed::before, +.sidebar-toggle:dir(rtl)::before { + background-image: url(chrome://devtools/skin/images/pane-expand.svg); +} + +.sidebar-toggle.alignRight::before { + transform: scaleX(-1); +} + +.sidebar-toggle.alignRight { + order: 10 +} + +/* Rotate button icon 90deg if the toolbox container is + in vertical mode (sidebar displayed under the main panel) */ +@media (max-width: 700px) { + .sidebar-toggle:not(.disableVerticalBehaviour)::before { + transform: rotate(90deg); + } + + /* Since RTL swaps the used images, we need to flip them + the other way round */ + .sidebar-toggle:not(.disableVerticalBehaviour):dir(rtl)::before { + transform: rotate(-90deg); + } +} diff --git a/devtools/client/shared/components/SidebarToggle.js b/devtools/client/shared/components/SidebarToggle.js new file mode 100644 index 0000000000..3cb2a28438 --- /dev/null +++ b/devtools/client/shared/components/SidebarToggle.js @@ -0,0 +1,89 @@ +/* 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 { + Component, +} = 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"); + +// Shortcuts +const { button } = dom; + +/** + * Sidebar toggle button. This button is used to exapand + * and collapse Sidebar. + */ +class SidebarToggle extends Component { + static get propTypes() { + return { + // Set to true if collapsed. + collapsed: PropTypes.bool.isRequired, + // Tooltip text used when the button indicates expanded state. + collapsePaneTitle: PropTypes.string.isRequired, + // Tooltip text used when the button indicates collapsed state. + expandPaneTitle: PropTypes.string.isRequired, + // Click callback + onClick: PropTypes.func.isRequired, + // align toggle button to right + alignRight: PropTypes.bool, + // if set to true toggle-button rotate 90 + canVerticalSplit: PropTypes.bool, + }; + } + + static get defaultProps() { + return { + alignRight: false, + canVerticalSplit: true, + }; + } + + constructor(props) { + super(props); + + this.state = { + collapsed: props.collapsed, + }; + + this.onClick = this.onClick.bind(this); + } + + // Events + + onClick(event) { + event.stopPropagation(); + this.setState({ collapsed: !this.state.collapsed }); + this.props.onClick(event); + } + + // Rendering + + render() { + const title = this.state.collapsed + ? this.props.expandPaneTitle + : this.props.collapsePaneTitle; + + const classNames = ["devtools-button", "sidebar-toggle"]; + if (this.state.collapsed) { + classNames.push("pane-collapsed"); + } + if (this.props.alignRight) { + classNames.push("alignRight"); + } + if (!this.props.canVerticalSplit) { + classNames.push("disableVerticalBehaviour"); + } + + return button({ + className: classNames.join(" "), + title, + onClick: this.onClick, + }); + } +} + +module.exports = SidebarToggle; diff --git a/devtools/client/shared/components/SmartTrace.css b/devtools/client/shared/components/SmartTrace.css new file mode 100644 index 0000000000..838711dd9e --- /dev/null +++ b/devtools/client/shared/components/SmartTrace.css @@ -0,0 +1,170 @@ +/* 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/. */ + +/** + * SmartTrace Component + * Styles for React component at `devtools/client/shared/components/SmartTrace.js` + */ + + +.frames-group .frame{ + display: block; + padding-inline-start: 16px; +} + +.img.annotation-logo{ + background-color: var(--theme-body-color); +} + + +.frames [role="list"]{ + display: inline-grid; + grid-template-columns: auto 1fr; + grid-column-gap: 8px; +} + +.frames .frame { + /* Parent is a grid container whose grid template we want to use, so span the whole line */ + grid-column: 1 / -1; + display: grid; + /* Grid is defined in `.frames [role="list"]` rule */ + grid-template-columns: subgrid; + cursor: pointer; + white-space: normal; +} + +.frames .title { + text-overflow: ellipsis; + white-space: nowrap; + grid-column: 1 / 2; + color: var(--console-output-color); +} + +.frames .location { + color: var(--frame-link-source); + grid-column: -1 / -2; + /* Force the location to be on one line and crop at start */ + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + text-align: end; +} + +.frames .location .line { + color: var(--frame-link-line-color); +} + +.frames .frames-list .frame:hover .location { + text-decoration: underline; +} + +.frames .location-async-cause { + grid-column: 1 / -1; + color: var(--theme-comment); +} + +/******* Group styles *******/ +.frames-group { + grid-column:1 / -1; +} + +.frames .frames-group .group { + display: flex; +} + +.group-description { + display: flex; + align-items: center; + color: var(--console-output-color); +} + +.frames .frames-group .frames-list { + margin-block-start: 2px; + /* + * We want to display each frame name on its own row, without having new lines in the + * clipboard when copying it. This does the trick. + */ + display: grid; + grid-template-columns: 1fr; +} + +.frames .frames-group .frames-list .frame { + padding-inline-start: 0; + text-overflow: ellipsis; +} + +.frames-group .frames-list .title { + grid-column: -1 / 1; + padding-inline-start: 16px; +} + +.frames .frames-group .frames-list .frame:first-of-type { + border-top: 1px solid var(--theme-splitter-color); + padding-block-start: 4px; +} + +.frames .frames-group .frames-list .frame:last-of-type { + margin-block-end: 4px; + border-bottom: 1px solid var(--theme-splitter-color); +} + +.badge { + background: var(--theme-toolbar-background-hover); + color: var(--theme-body-color); + border-radius: 8px; + padding: 1px 4px; + font-size: 0.9em; + display: inline-block; + text-align: center; + cursor: default; + margin-inline-start: 4px; +} + +.frames .frames-group.expanded .group-description, +.frames .frames-group.expanded .badge { + color: var(--theme-highlight-blue); +} + +/** Images **/ + +.frames .img.annotation-logo { + /* FIXME: In order to display the Framework icons, we need to find a way to share CSS + * from the debugger, where the background images are defined. + * See https://github.com/firefox-devtools/debugger.html/issues/7782. + */ + display: none; + /* + background-color:var(--theme-body-color); + display: inline-block; + width: 12px; + height:12px; + vertical-align: middle; + margin-inline-end:4px; + */ +} + +.expanded .img.annotation-logo { + background-color: currentColor; +} + +.group .img.arrow { + mask: url("chrome://devtools/content/debugger/images/arrow.svg"); + margin-inline-end: 4px; + background-color: var(--theme-icon-dimmed-color); + width: 10px; + height: 10px; + mask-size: 100%; + display: inline-block; + transform: rotate(-90deg); + transition: transform 0.18s ease; +} + +.group .img.arrow.expanded { + transform: rotate(0); +} + +/* Frameworks */ +:root.theme-dark .annotation-logo:not(.angular) { + background-color: var(--theme-highlight-blue); +} diff --git a/devtools/client/shared/components/SmartTrace.js b/devtools/client/shared/components/SmartTrace.js new file mode 100644 index 0000000000..d9613be7b8 --- /dev/null +++ b/devtools/client/shared/components/SmartTrace.js @@ -0,0 +1,319 @@ +/* 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 { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); + +const l10n = new LocalizationHelper( + "devtools/client/locales/components.properties" +); +const dbgL10n = new LocalizationHelper( + "devtools/client/locales/debugger.properties" +); +const Frames = createFactory( + require("resource://devtools/client/debugger/src/components/SecondaryPanes/Frames/index.js") + .Frames +); +const { + annotateFramesWithLibrary, +} = require("resource://devtools/client/debugger/src/utils/pause/frames/annotateFrames.js"); +const { + getDisplayURL, +} = require("resource://devtools/client/debugger/src/utils/sources-tree/getURL.js"); + +class SmartTrace extends Component { + static get propTypes() { + return { + stacktrace: PropTypes.array.isRequired, + onViewSource: PropTypes.func.isRequired, + onViewSourceInDebugger: PropTypes.func.isRequired, + // Service to enable the source map feature. + sourceMapURLService: PropTypes.object, + // A number in ms (defaults to 100) which we'll wait before doing the first actual + // render of this component, in order to avoid shifting layout rapidly in case the + // page is using sourcemap. + // Setting it to 0 or anything else than a number will force the first render to + // happen immediatly, without any delay. + initialRenderDelay: PropTypes.number, + onSourceMapResultDebounceDelay: PropTypes.number, + // Function that will be called when the SmartTrace is ready, i.e. once it was + // rendered. + onReady: PropTypes.func, + }; + } + + static get defaultProps() { + return { + initialRenderDelay: 100, + onSourceMapResultDebounceDelay: 200, + }; + } + + constructor(props) { + super(props); + this.state = { + hasError: false, + // If a sourcemap service is passed, we want to introduce a small delay in rendering + // so we can have the results from the sourcemap service, or render if they're not + // available yet. + ready: !props.sourceMapURLService || !this.hasInitialRenderDelay(), + updateCount: 0, + // Original positions for each indexed position + originalLocations: null, + }; + } + + getChildContext() { + return { l10n: dbgL10n }; + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillMount() { + if (this.props.sourceMapURLService) { + this.sourceMapURLServiceUnsubscriptions = []; + const sourceMapInit = Promise.all( + this.props.stacktrace.map( + ({ filename, sourceId, lineNumber, columnNumber }, index) => + new Promise(resolve => { + const callback = originalLocation => { + this.onSourceMapServiceChange(originalLocation, index); + resolve(); + }; + + this.sourceMapURLServiceUnsubscriptions.push( + this.props.sourceMapURLService.subscribeByLocation( + { + id: sourceId, + url: filename.split(" -> ").pop(), + line: lineNumber, + column: columnNumber, + }, + callback + ) + ); + }) + ) + ); + + // Without initial render delay, we don't have to do anything; if the frames are + // sourcemapped, we will get new renders from onSourceMapServiceChange. + if (!this.hasInitialRenderDelay()) { + return; + } + + const delay = new Promise(res => { + this.initialRenderDelayTimeoutId = setTimeout( + res, + this.props.initialRenderDelay + ); + }); + + // We wait either for the delay to be over (if it exists), or the sourcemapService + // results to be available, before setting the state as initialized. + Promise.race([delay, sourceMapInit]).then(() => { + if (this.initialRenderDelayTimeoutId) { + clearTimeout(this.initialRenderDelayTimeoutId); + } + this.setState(state => ({ + // Force-update so that the ready state is detected. + updateCount: state.updateCount + 1, + ready: true, + })); + }); + } + } + + componentDidMount() { + if (this.props.onReady && this.state.ready) { + this.props.onReady(); + } + } + + shouldComponentUpdate(_, nextState) { + if (this.state.updateCount !== nextState.updateCount) { + return true; + } + + return false; + } + + componentDidUpdate(_, previousState) { + if (this.props.onReady && !previousState.ready && this.state.ready) { + this.props.onReady(); + } + } + + componentWillUnmount() { + if (this.initialRenderDelayTimeoutId) { + clearTimeout(this.initialRenderDelayTimeoutId); + } + + if (this.onFrameLocationChangedTimeoutId) { + clearTimeout(this.initialRenderDelayTimeoutId); + } + + if (this.sourceMapURLServiceUnsubscriptions) { + this.sourceMapURLServiceUnsubscriptions.forEach(unsubscribe => { + unsubscribe(); + }); + } + } + + componentDidCatch(error, info) { + console.error( + "Error while rendering stacktrace:", + error, + info, + "props:", + this.props + ); + this.setState(state => ({ + // Force-update so the error is detected. + updateCount: state.updateCount + 1, + hasError: true, + })); + } + + onSourceMapServiceChange(originalLocation, index) { + this.setState(({ originalLocations }) => { + if (!originalLocations) { + originalLocations = Array.from({ + length: this.props.stacktrace.length, + }); + } + return { + originalLocations: [ + ...originalLocations.slice(0, index), + originalLocation, + ...originalLocations.slice(index + 1), + ], + }; + }); + + if (this.onFrameLocationChangedTimeoutId) { + clearTimeout(this.onFrameLocationChangedTimeoutId); + } + + // Since a trace may have many original positions, we don't want to + // constantly re-render every time one becomes available. To avoid this, + // we only update the component after an initial timeout, and on a + // debounce edge as more positions load after that. + if (this.state.ready === true) { + this.onFrameLocationChangedTimeoutId = setTimeout(() => { + this.setState(state => ({ + updateCount: state.updateCount + 1, + })); + }, this.props.onSourceMapResultDebounceDelay); + } + } + + hasInitialRenderDelay() { + return ( + Number.isFinite(this.props.initialRenderDelay) && + this.props.initialRenderDelay > 0 + ); + } + + render() { + if ( + this.state.hasError || + (this.hasInitialRenderDelay() && !this.state.ready) + ) { + return null; + } + + const { onViewSourceInDebugger, onViewSource, stacktrace } = this.props; + const { originalLocations } = this.state; + + const frames = stacktrace.map( + ( + { + filename, + sourceId, + lineNumber, + columnNumber, + functionName, + asyncCause, + }, + i + ) => { + // Create partial debugger frontend "location" objects compliant with <Frames> react component requirements + const sourceUrl = filename.split(" -> ").pop(); + const generatedLocation = { + line: lineNumber, + column: columnNumber, + source: { + // 'id' isn't used by Frames, but by selectFrame callback below + id: sourceId, + url: sourceUrl, + // 'displayURL' might be used by FrameComponent via getFilename + displayURL: getDisplayURL(sourceUrl), + }, + }; + let location = generatedLocation; + const originalLocation = originalLocations?.[i]; + if (originalLocation) { + location = { + line: originalLocation.line, + column: originalLocation.column, + source: { + url: originalLocation.url, + // 'displayURL' might be used by FrameComponent via getFilename + displayURL: getDisplayURL(originalLocation.url), + }, + }; + } + + // Create partial debugger frontend "frame" objects compliant with <Frames> react component requirements + return { + id: "fake-frame-id-" + i, + displayName: functionName, + asyncCause, + location, + // Note that for now, Frames component only uses 'location' attribute + // and never the 'generatedLocation'. + // But the code below does, the selectFrame callback. + generatedLocation, + }; + } + ); + annotateFramesWithLibrary(frames); + + return Frames({ + frames, + selectFrame: ({ generatedLocation }) => { + const viewSource = onViewSourceInDebugger || onViewSource; + + viewSource({ + id: generatedLocation.source.id, + url: generatedLocation.source.url, + line: generatedLocation.line, + column: generatedLocation.column, + }); + }, + getFrameTitle: url => { + return l10n.getFormatStr("frame.viewsourceindebugger", url); + }, + disableFrameTruncate: true, + disableContextMenu: true, + frameworkGroupingOn: true, + // Force displaying the original location (we might try to use current Debugger state?) + shouldDisplayOriginalLocation: true, + displayFullUrl: !this.state || !this.state.originalLocations, + panel: "webconsole", + }); + } +} + +SmartTrace.childContextTypes = { + l10n: PropTypes.object, +}; + +module.exports = SmartTrace; diff --git a/devtools/client/shared/components/StackTrace.js b/devtools/client/shared/components/StackTrace.js new file mode 100644 index 0000000000..e89e79b344 --- /dev/null +++ b/devtools/client/shared/components/StackTrace.js @@ -0,0 +1,96 @@ +/* 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 { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const Frame = createFactory( + require("resource://devtools/client/shared/components/Frame.js") +); + +const l10n = new LocalizationHelper( + "devtools/client/locales/webconsole.properties" +); + +class AsyncFrameClass extends Component { + static get propTypes() { + return { + asyncCause: PropTypes.string.isRequired, + }; + } + + render() { + const { asyncCause } = this.props; + + return dom.span( + { className: "frame-link-async-cause" }, + l10n.getFormatStr("stacktrace.asyncStack", asyncCause) + ); + } +} + +class StackTrace extends Component { + static get propTypes() { + return { + stacktrace: PropTypes.array.isRequired, + onViewSourceInDebugger: PropTypes.func.isRequired, + // Service to enable the source map feature. + sourceMapURLService: PropTypes.object, + }; + } + + render() { + const { stacktrace, onViewSourceInDebugger, sourceMapURLService } = + this.props; + + if (!stacktrace || !stacktrace.length) { + return null; + } + + const frames = []; + stacktrace.forEach((s, i) => { + if (s.asyncCause) { + frames.push( + "\t", + AsyncFrame({ + key: `${i}-asyncframe`, + asyncCause: s.asyncCause, + }), + "\n" + ); + } + + frames.push( + "\t", + Frame({ + key: `${i}-frame`, + frame: { + functionDisplayName: s.functionName, + source: s.filename, + line: s.lineNumber, + column: s.columnNumber, + }, + showFunctionName: true, + showAnonymousFunctionName: true, + showFullSourceUrl: true, + onClick: onViewSourceInDebugger, + sourceMapURLService, + }), + "\n" + ); + }); + + return dom.div({ className: "stack-trace" }, frames); + } +} + +const AsyncFrame = createFactory(AsyncFrameClass); + +module.exports = StackTrace; diff --git a/devtools/client/shared/components/Tree.css b/devtools/client/shared/components/Tree.css new file mode 100644 index 0000000000..3a0667cd5c --- /dev/null +++ b/devtools/client/shared/components/Tree.css @@ -0,0 +1,86 @@ +/* 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/. */ + +/* We can remove the outline since we do add our own focus style on nodes */ +.tree:focus { + outline: none; +} + +.tree.inline { + display: inline-block; +} + +.tree.nowrap { + white-space: nowrap; +} + +.tree.noselect { + user-select: none; +} + +.tree .tree-node { + display: flex; +} + +.tree .tree-node:not(.focused):hover { + background-color: var(--theme-selection-background-hover); +} + +.tree-indent { + display: inline-block; + width: 12px; + margin-inline-start: 3px; + border-inline-start: 1px solid #a2d1ff; + flex-shrink: 0; + height: 0; +} + +.tree-node[data-expandable="false"] .tree-last-indent { + /* The 13px value is taken from the total width and margins of the arrow + element of expandables nodes (10px width + 3px margins). That way the + node's text are indented the same for both expandable and non-expandable + nodes */ + margin-inline-end: 13px; +} + +.tree .tree-node[data-expandable="true"] { + cursor: default; +} + +.tree-node button.arrow { + mask: url("chrome://devtools/content/debugger/images/arrow.svg") no-repeat center; + mask-size: 10px; + vertical-align: -1px; + width: 10px; + height: 10px; + border: 0; + padding: 0; + margin-inline-end: 4px; + transform-origin: center center; + transition: transform 125ms var(--animation-curve); + background-color: var(--theme-icon-dimmed-color); +} + +.tree-node button.arrow:not(.expanded) { + transform: rotate(-90deg); +} + +html[dir="rtl"] .tree-node button.arrow:not(.expanded) { + transform: rotate(90deg); +} + +.tree .tree-node.focused { + color: var(--theme-selection-color); + background-color: var(--theme-selection-background); +} + +/* Invert text selection color in selected rows */ +.tree .tree-node.focused ::selection { + color: var(--theme-selection-background); + background-color: var(--theme-selection-color); +} + +.tree-node.focused button.arrow { + background-color: currentColor; +} diff --git a/devtools/client/shared/components/Tree.js b/devtools/client/shared/components/Tree.js new file mode 100644 index 0000000000..b1e9e18780 --- /dev/null +++ b/devtools/client/shared/components/Tree.js @@ -0,0 +1,1072 @@ +/* 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 React = require("resource://devtools/client/shared/vendor/react.js"); +const { Component, createFactory } = React; +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +// Localized strings for (devtools/client/locales/en-US/components.properties) +loader.lazyGetter(this, "L10N_COMPONENTS", function () { + const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); + return new LocalizationHelper( + "devtools/client/locales/components.properties" + ); +}); + +loader.lazyGetter(this, "EXPAND_LABEL", function () { + return L10N_COMPONENTS.getStr("treeNode.expandButtonTitle"); +}); + +loader.lazyGetter(this, "COLLAPSE_LABEL", function () { + return L10N_COMPONENTS.getStr("treeNode.collapseButtonTitle"); +}); + +// depth +const AUTO_EXPAND_DEPTH = 0; + +// Simplied selector targetting elements that can receive the focus, +// full version at https://stackoverflow.com/questions/1599660. +const FOCUSABLE_SELECTOR = [ + "a[href]:not([tabindex='-1'])", + "button:not([disabled], [tabindex='-1'])", + "iframe:not([tabindex='-1'])", + "input:not([disabled], [tabindex='-1'])", + "select:not([disabled], [tabindex='-1'])", + "textarea:not([disabled], [tabindex='-1'])", + "[tabindex]:not([tabindex='-1'])", +].join(", "); + +/** + * An arrow that displays whether its node is expanded (▼) or collapsed + * (▶). When its node has no children, it is hidden. + */ +class ArrowExpander extends Component { + static get propTypes() { + return { + expanded: PropTypes.bool, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + return this.props.expanded !== nextProps.expanded; + } + + render() { + const { expanded } = this.props; + + const classNames = ["arrow"]; + const title = expanded ? COLLAPSE_LABEL : EXPAND_LABEL; + + if (expanded) { + classNames.push("expanded"); + } + return dom.button({ + className: classNames.join(" "), + title, + }); + } +} + +const treeIndent = dom.span({ className: "tree-indent" }, "\u200B"); +const treeLastIndent = dom.span( + { className: "tree-indent tree-last-indent" }, + "\u200B" +); + +class TreeNode extends Component { + static get propTypes() { + return { + id: PropTypes.any.isRequired, + index: PropTypes.number.isRequired, + depth: PropTypes.number.isRequired, + focused: PropTypes.bool.isRequired, + active: PropTypes.bool.isRequired, + expanded: PropTypes.bool.isRequired, + item: PropTypes.any.isRequired, + isExpandable: PropTypes.bool.isRequired, + onClick: PropTypes.func, + shouldItemUpdate: PropTypes.func, + renderItem: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + this.treeNodeRef = React.createRef(); + + this._onKeyDown = this._onKeyDown.bind(this); + } + + componentDidMount() { + // Make sure that none of the focusable elements inside the tree node + // container are tabbable if the tree node is not active. If the tree node + // is active and focus is outside its container, focus on the first + // focusable element inside. + const elms = this.getFocusableElements(); + if (this.props.active) { + const doc = this.treeNodeRef.current.ownerDocument; + if (elms.length && !elms.includes(doc.activeElement)) { + elms[0].focus(); + } + } else { + elms.forEach(elm => elm.setAttribute("tabindex", "-1")); + } + } + + shouldComponentUpdate(nextProps) { + return ( + this.props.item !== nextProps.item || + (this.props.shouldItemUpdate && + this.props.shouldItemUpdate(this.props.item, nextProps.item)) || + this.props.focused !== nextProps.focused || + this.props.expanded !== nextProps.expanded + ); + } + + /** + * Get a list of all elements that are focusable with a keyboard inside the + * tree node. + */ + getFocusableElements() { + return this.treeNodeRef.current + ? Array.from( + this.treeNodeRef.current.querySelectorAll(FOCUSABLE_SELECTOR) + ) + : []; + } + + /** + * Wrap and move keyboard focus to first/last focusable element inside the + * tree node to prevent the focus from escaping the tree node boundaries. + * element). + * + * @param {DOMNode} current currently focused element + * @param {Boolean} back direction + * @return {Boolean} true there is a newly focused element. + */ + _wrapMoveFocus(current, back) { + const elms = this.getFocusableElements(); + let next; + + if (elms.length === 0) { + return false; + } + + if (back) { + if (elms.indexOf(current) === 0) { + next = elms[elms.length - 1]; + next.focus(); + } + } else if (elms.indexOf(current) === elms.length - 1) { + next = elms[0]; + next.focus(); + } + + return !!next; + } + + _onKeyDown(e) { + const { target, key, shiftKey } = e; + + if (key !== "Tab") { + return; + } + + const focusMoved = this._wrapMoveFocus(target, shiftKey); + if (focusMoved) { + // Focus was moved to the begining/end of the list, so we need to prevent + // the default focus change that would happen here. + e.preventDefault(); + } + + e.stopPropagation(); + } + + render() { + const { + depth, + id, + item, + focused, + active, + expanded, + renderItem, + isExpandable, + } = this.props; + + const arrow = isExpandable + ? ArrowExpanderFactory({ + item, + expanded, + }) + : null; + + let ariaExpanded; + if (this.props.isExpandable) { + ariaExpanded = false; + } + if (this.props.expanded) { + ariaExpanded = true; + } + + const indents = Array.from({ length: depth }, (_, i) => { + if (i == depth - 1) { + return treeLastIndent; + } + return treeIndent; + }); + + const items = indents.concat( + renderItem(item, depth, focused, arrow, expanded) + ); + + return dom.div( + { + id, + className: `tree-node${focused ? " focused" : ""}${ + active ? " active" : "" + }`, + onClick: this.props.onClick, + onKeyDownCapture: active ? this._onKeyDown : null, + role: "treeitem", + ref: this.treeNodeRef, + "aria-level": depth + 1, + "aria-expanded": ariaExpanded, + "data-expandable": this.props.isExpandable, + }, + ...items + ); + } +} + +const ArrowExpanderFactory = createFactory(ArrowExpander); +const TreeNodeFactory = createFactory(TreeNode); + +/** + * Create a function that calls the given function `fn` only once per animation + * frame. + * + * @param {Function} fn + * @param {Object} options: object that contains the following properties: + * - {Function} getDocument: A function that return the document + * the component is rendered in. + * @returns {Function} + */ +function oncePerAnimationFrame(fn, { getDocument }) { + let animationId = null; + let argsToPass = null; + return function (...args) { + argsToPass = args; + if (animationId !== null) { + return; + } + + const doc = getDocument(); + if (!doc) { + return; + } + + animationId = doc.defaultView.requestAnimationFrame(() => { + fn.call(this, ...argsToPass); + animationId = null; + argsToPass = null; + }); + }; +} + +/** + * A generic tree component. See propTypes for the public API. + * + * This tree component doesn't make any assumptions about the structure of your + * tree data. Whether children are computed on demand, or stored in an array in + * the parent's `_children` property, it doesn't matter. We only require the + * implementation of `getChildren`, `getRoots`, `getParent`, and `isExpanded` + * functions. + * + * This tree component is well tested and reliable. See the tests in ./tests + * and its usage in the memory panel in mozilla-central. + * + * This tree component doesn't make any assumptions about how to render items in + * the tree. You provide a `renderItem` function, and this component will ensure + * that only those items whose parents are expanded and which are visible in the + * viewport are rendered. The `renderItem` function could render the items as a + * "traditional" tree or as rows in a table or anything else. It doesn't + * restrict you to only one certain kind of tree. + * + * The tree comes with basic styling for the indent, the arrow, as well as + * hovered and focused styles which can be override in CSS. + * + * ### Example Usage + * + * Suppose we have some tree data where each item has this form: + * + * { + * id: Number, + * label: String, + * parent: Item or null, + * children: Array of child items, + * expanded: bool, + * } + * + * Here is how we could render that data with this component: + * + * class MyTree extends Component { + * static get propTypes() { + * // The root item of the tree, with the form described above. + * return { + * root: PropTypes.object.isRequired + * }; + * }, + * + * render() { + * return Tree({ + * getRoots: () => [this.props.root], + * + * getParent: item => item.parent, + * getChildren: item => item.children, + * getKey: item => item.id, + * isExpanded: item => item.expanded, + * + * renderItem: (item, depth, isFocused, arrow, isExpanded) => { + * let className = "my-tree-item"; + * if (isFocused) { + * className += " focused"; + * } + * return dom.div({ + * className, + * }, + * arrow, + * // And here is the label for this item. + * dom.span({ className: "my-tree-item-label" }, item.label) + * ); + * }, + * + * onExpand: item => dispatchExpandActionToRedux(item), + * onCollapse: item => dispatchCollapseActionToRedux(item), + * }); + * } + * } + */ +class Tree extends Component { + static get propTypes() { + return { + // Required props + + // A function to get an item's parent, or null if it is a root. + // + // Type: getParent(item: Item) -> Maybe<Item> + // + // Example: + // + // // The parent of this item is stored in its `parent` property. + // getParent: item => item.parent + getParent: PropTypes.func.isRequired, + + // A function to get an item's children. + // + // Type: getChildren(item: Item) -> [Item] + // + // Example: + // + // // This item's children are stored in its `children` property. + // getChildren: item => item.children + getChildren: PropTypes.func.isRequired, + + // A function to check if the tree node for the item should be updated. + // + // Type: shouldItemUpdate(prevItem: Item, nextItem: Item) -> Boolean + // + // Example: + // + // // This item should be updated if it's type is a long string + // shouldItemUpdate: (prevItem, nextItem) => + // nextItem.type === "longstring" + shouldItemUpdate: PropTypes.func, + + // A function which takes an item and ArrowExpander component instance and + // returns a component, or text, or anything else that React considers + // renderable. + // + // Type: renderItem(item: Item, + // depth: Number, + // isFocused: Boolean, + // arrow: ReactComponent, + // isExpanded: Boolean) -> ReactRenderable + // + // Example: + // + // renderItem: (item, depth, isFocused, arrow, isExpanded) => { + // let className = "my-tree-item"; + // if (isFocused) { + // className += " focused"; + // } + // return dom.div( + // { + // className, + // style: { marginLeft: depth * 10 + "px" } + // }, + // arrow, + // dom.span({ className: "my-tree-item-label" }, item.label) + // ); + // }, + renderItem: PropTypes.func.isRequired, + + // A function which returns the roots of the tree (forest). + // + // Type: getRoots() -> [Item] + // + // Example: + // + // // In this case, we only have one top level, root item. You could + // // return multiple items if you have many top level items in your + // // tree. + // getRoots: () => [this.props.rootOfMyTree] + getRoots: PropTypes.func.isRequired, + + // A function to get a unique key for the given item. This helps speed up + // React's rendering a *TON*. + // + // Type: getKey(item: Item) -> String + // + // Example: + // + // getKey: item => `my-tree-item-${item.uniqueId}` + getKey: PropTypes.func.isRequired, + + // A function to get whether an item is expanded or not. If an item is not + // expanded, then it must be collapsed. + // + // Type: isExpanded(item: Item) -> Boolean + // + // Example: + // + // isExpanded: item => item.expanded, + isExpanded: PropTypes.func.isRequired, + + // Optional props + + // The currently focused item, if any such item exists. + focused: PropTypes.any, + + // Handle when a new item is focused. + onFocus: PropTypes.func, + + // The depth to which we should automatically expand new items. + autoExpandDepth: PropTypes.number, + // Should auto expand all new items or just the new items under the first + // root item. + autoExpandAll: PropTypes.bool, + + // Auto expand a node only if number of its children + // are less than autoExpandNodeChildrenLimit + autoExpandNodeChildrenLimit: PropTypes.number, + + // Note: the two properties below are mutually exclusive. Only one of the + // label properties is necessary. + // ID of an element whose textual content serves as an accessible label + // for a tree. + labelledby: PropTypes.string, + // Accessibility label for a tree widget. + label: PropTypes.string, + + // Optional event handlers for when items are expanded or collapsed. + // Useful for dispatching redux events and updating application state, + // maybe lazily loading subtrees from a worker, etc. + // + // Type: + // onExpand(item: Item) + // onCollapse(item: Item) + // + // Example: + // + // onExpand: item => dispatchExpandActionToRedux(item) + onExpand: PropTypes.func, + onCollapse: PropTypes.func, + // The currently active (keyboard) item, if any such item exists. + active: PropTypes.any, + // Optional event handler called with the current focused node when the + // Enter key is pressed. Can be useful to allow further keyboard actions + // within the tree node. + onActivate: PropTypes.func, + isExpandable: PropTypes.func, + // Additional classes to add to the root element. + className: PropTypes.string, + // style object to be applied to the root element. + style: PropTypes.object, + // Prevents blur when Tree loses focus + preventBlur: PropTypes.bool, + initiallyExpanded: PropTypes.func, + }; + } + + static get defaultProps() { + return { + autoExpandDepth: AUTO_EXPAND_DEPTH, + autoExpandAll: true, + }; + } + + constructor(props) { + super(props); + + this.state = { + autoExpanded: new Set(), + }; + + this.treeRef = React.createRef(); + + const opaf = fn => + oncePerAnimationFrame(fn, { + getDocument: () => + this.treeRef.current && this.treeRef.current.ownerDocument, + }); + + this._onExpand = opaf(this._onExpand).bind(this); + this._onCollapse = opaf(this._onCollapse).bind(this); + this._focusPrevNode = opaf(this._focusPrevNode).bind(this); + this._focusNextNode = opaf(this._focusNextNode).bind(this); + this._focusParentNode = opaf(this._focusParentNode).bind(this); + this._focusFirstNode = opaf(this._focusFirstNode).bind(this); + this._focusLastNode = opaf(this._focusLastNode).bind(this); + + this._autoExpand = this._autoExpand.bind(this); + this._preventArrowKeyScrolling = this._preventArrowKeyScrolling.bind(this); + this._preventEvent = this._preventEvent.bind(this); + this._dfs = this._dfs.bind(this); + this._dfsFromRoots = this._dfsFromRoots.bind(this); + this._focus = this._focus.bind(this); + this._activate = this._activate.bind(this); + this._scrollNodeIntoView = this._scrollNodeIntoView.bind(this); + this._onBlur = this._onBlur.bind(this); + this._onKeyDown = this._onKeyDown.bind(this); + this._nodeIsExpandable = this._nodeIsExpandable.bind(this); + } + + componentDidMount() { + this._autoExpand(); + if (this.props.focused) { + this._scrollNodeIntoView(this.props.focused); + } + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + this._autoExpand(); + } + + componentDidUpdate(prevProps, prevState) { + if (this.props.focused && prevProps.focused !== this.props.focused) { + this._scrollNodeIntoView(this.props.focused); + } + } + + _autoExpand() { + const { autoExpandDepth, autoExpandNodeChildrenLimit, initiallyExpanded } = + this.props; + + if (!autoExpandDepth && !initiallyExpanded) { + return; + } + + // Automatically expand the first autoExpandDepth levels for new items. Do + // not use the usual DFS infrastructure because we don't want to ignore + // collapsed nodes. Any initially expanded items will be expanded regardless + // of how deep they are. + const autoExpand = (item, currentDepth) => { + const initial = initiallyExpanded && initiallyExpanded(item); + + if (!initial && currentDepth >= autoExpandDepth) { + return; + } + + const children = this.props.getChildren(item); + if ( + !initial && + autoExpandNodeChildrenLimit && + children.length > autoExpandNodeChildrenLimit + ) { + return; + } + + if (!this.state.autoExpanded.has(item)) { + this.props.onExpand(item); + this.state.autoExpanded.add(item); + } + + const length = children.length; + for (let i = 0; i < length; i++) { + autoExpand(children[i], currentDepth + 1); + } + }; + + const roots = this.props.getRoots(); + const length = roots.length; + if (this.props.autoExpandAll) { + for (let i = 0; i < length; i++) { + autoExpand(roots[i], 0); + } + } else if (length != 0) { + autoExpand(roots[0], 0); + + if (initiallyExpanded) { + for (let i = 1; i < length; i++) { + if (initiallyExpanded(roots[i])) { + autoExpand(roots[i], 0); + } + } + } + } + } + + _preventArrowKeyScrolling(e) { + switch (e.key) { + case "ArrowUp": + case "ArrowDown": + case "ArrowLeft": + case "ArrowRight": + this._preventEvent(e); + break; + } + } + + _preventEvent(e) { + e.preventDefault(); + e.stopPropagation(); + if (e.nativeEvent) { + if (e.nativeEvent.preventDefault) { + e.nativeEvent.preventDefault(); + } + if (e.nativeEvent.stopPropagation) { + e.nativeEvent.stopPropagation(); + } + } + } + + /** + * Perform a pre-order depth-first search from item. + */ + _dfs(item, maxDepth = Infinity, traversal = [], _depth = 0) { + traversal.push({ item, depth: _depth }); + + if (!this.props.isExpanded(item)) { + return traversal; + } + + const nextDepth = _depth + 1; + + if (nextDepth > maxDepth) { + return traversal; + } + + const children = this.props.getChildren(item); + const length = children.length; + for (let i = 0; i < length; i++) { + this._dfs(children[i], maxDepth, traversal, nextDepth); + } + + return traversal; + } + + /** + * Perform a pre-order depth-first search over the whole forest. + */ + _dfsFromRoots(maxDepth = Infinity) { + const traversal = []; + + const roots = this.props.getRoots(); + const length = roots.length; + for (let i = 0; i < length; i++) { + this._dfs(roots[i], maxDepth, traversal); + } + + return traversal; + } + + /** + * Expands current row. + * + * @param {Object} item + * @param {Boolean} expandAllChildren + */ + _onExpand(item, expandAllChildren) { + if (this.props.onExpand) { + this.props.onExpand(item); + + if (expandAllChildren) { + const children = this._dfs(item); + const length = children.length; + for (let i = 0; i < length; i++) { + this.props.onExpand(children[i].item); + } + } + } + } + + /** + * Collapses current row. + * + * @param {Object} item + */ + _onCollapse(item) { + if (this.props.onCollapse) { + this.props.onCollapse(item); + } + } + + /** + * Sets the passed in item to be the focused item. + * + * @param {Object|undefined} item + * The item to be focused, or undefined to focus no item. + * + * @param {Object|undefined} options + * An options object which can contain: + * - dir: "up" or "down" to indicate if we should scroll the element + * to the top or the bottom of the scrollable container when + * the element is off canvas. + */ + _focus(item, options = {}) { + const { preventAutoScroll } = options; + if (item && !preventAutoScroll) { + this._scrollNodeIntoView(item, options); + } + + if (this.props.active != undefined) { + this._activate(undefined); + const doc = this.treeRef.current && this.treeRef.current.ownerDocument; + if (this.treeRef.current !== doc.activeElement) { + this.treeRef.current.focus(); + } + } + + if (this.props.onFocus) { + this.props.onFocus(item); + } + } + + /** + * Sets the passed in item to be the active item. + * + * @param {Object|undefined} item + * The item to be activated, or undefined to activate no item. + */ + _activate(item) { + if (this.props.onActivate) { + this.props.onActivate(item); + } + } + + /** + * Sets the passed in item to be the focused item. + * + * @param {Object|undefined} item + * The item to be scrolled to. + * + * @param {Object|undefined} options + * An options object which can contain: + * - dir: "up" or "down" to indicate if we should scroll the element + * to the top or the bottom of the scrollable container when + * the element is off canvas. + */ + _scrollNodeIntoView(item, options = {}) { + if (item !== undefined) { + const treeElement = this.treeRef.current; + const doc = treeElement && treeElement.ownerDocument; + const element = doc.getElementById(this.props.getKey(item)); + + if (element) { + const { top, bottom } = element.getBoundingClientRect(); + const closestScrolledParent = node => { + if (node == null) { + return null; + } + + if (node.scrollHeight > node.clientHeight) { + return node; + } + return closestScrolledParent(node.parentNode); + }; + const scrolledParent = closestScrolledParent(treeElement); + const scrolledParentRect = scrolledParent + ? scrolledParent.getBoundingClientRect() + : null; + const isVisible = + !scrolledParent || + (top >= scrolledParentRect.top && + bottom <= scrolledParentRect.bottom); + + if (!isVisible) { + const { alignTo } = options; + const scrollToTop = alignTo + ? alignTo === "top" + : !scrolledParentRect || top < scrolledParentRect.top; + element.scrollIntoView(scrollToTop); + } + } + } + } + + /** + * Sets the state to have no focused item. + */ + _onBlur(e) { + if (this.props.active != undefined) { + const { relatedTarget } = e; + if (!this.treeRef.current.contains(relatedTarget)) { + this._activate(undefined); + } + } else if (!this.props.preventBlur) { + this._focus(undefined); + } + } + + /** + * Handles key down events in the tree's container. + * + * @param {Event} e + */ + // eslint-disable-next-line complexity + _onKeyDown(e) { + if (this.props.focused == null) { + return; + } + + // Allow parent nodes to use navigation arrows with modifiers. + if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) { + return; + } + + this._preventArrowKeyScrolling(e); + const doc = this.treeRef.current && this.treeRef.current.ownerDocument; + + switch (e.key) { + case "ArrowUp": + this._focusPrevNode(); + return; + + case "ArrowDown": + this._focusNextNode(); + return; + + case "ArrowLeft": + if ( + this.props.isExpanded(this.props.focused) && + this._nodeIsExpandable(this.props.focused) + ) { + this._onCollapse(this.props.focused); + } else { + this._focusParentNode(); + } + return; + + case "ArrowRight": + if ( + this._nodeIsExpandable(this.props.focused) && + !this.props.isExpanded(this.props.focused) + ) { + this._onExpand(this.props.focused); + } else { + this._focusNextNode(); + } + return; + + case "Home": + this._focusFirstNode(); + return; + + case "End": + this._focusLastNode(); + return; + + case "Enter": + case " ": + if (this.treeRef.current === doc.activeElement) { + this._preventEvent(e); + if (this.props.active !== this.props.focused) { + this._activate(this.props.focused); + } + } + return; + + case "Escape": + this._preventEvent(e); + if (this.props.active != undefined) { + this._activate(undefined); + } + + if (this.treeRef.current !== doc.activeElement) { + this.treeRef.current.focus(); + } + } + } + + /** + * Sets the previous node relative to the currently focused item, to focused. + */ + _focusPrevNode() { + // Start a depth first search and keep going until we reach the currently + // focused node. Focus the previous node in the DFS, if it exists. If it + // doesn't exist, we're at the first node already. + + let prev; + + const traversal = this._dfsFromRoots(); + const length = traversal.length; + for (let i = 0; i < length; i++) { + const item = traversal[i].item; + if (item === this.props.focused) { + break; + } + prev = item; + } + if (prev === undefined) { + return; + } + + this._focus(prev, { alignTo: "top" }); + } + + /** + * Handles the down arrow key which will focus either the next child + * or sibling row. + */ + _focusNextNode() { + // Start a depth first search and keep going until we reach the currently + // focused node. Focus the next node in the DFS, if it exists. If it + // doesn't exist, we're at the last node already. + const traversal = this._dfsFromRoots(); + const length = traversal.length; + let i = 0; + + while (i < length) { + if (traversal[i].item === this.props.focused) { + break; + } + i++; + } + + if (i + 1 < traversal.length) { + this._focus(traversal[i + 1].item, { alignTo: "bottom" }); + } + } + + /** + * Handles the left arrow key, going back up to the current rows' + * parent row. + */ + _focusParentNode() { + const parent = this.props.getParent(this.props.focused); + if (!parent) { + this._focusPrevNode(this.props.focused); + return; + } + + this._focus(parent, { alignTo: "top" }); + } + + _focusFirstNode() { + const traversal = this._dfsFromRoots(); + this._focus(traversal[0].item, { alignTo: "top" }); + } + + _focusLastNode() { + const traversal = this._dfsFromRoots(); + const lastIndex = traversal.length - 1; + this._focus(traversal[lastIndex].item, { alignTo: "bottom" }); + } + + _nodeIsExpandable(item) { + return this.props.isExpandable + ? this.props.isExpandable(item) + : !!this.props.getChildren(item).length; + } + + render() { + const traversal = this._dfsFromRoots(); + const { active, focused } = this.props; + + const nodes = traversal.map((v, i) => { + const { item, depth } = traversal[i]; + const key = this.props.getKey(item, i); + const focusedKey = focused ? this.props.getKey(focused, i) : null; + return TreeNodeFactory({ + // We make a key unique depending on whether the tree node is in active + // or inactive state to make sure that it is actually replaced and the + // tabbable state is reset. + key: `${key}-${active === item ? "active" : "inactive"}`, + id: key, + index: i, + item, + depth, + shouldItemUpdate: this.props.shouldItemUpdate, + renderItem: this.props.renderItem, + focused: focusedKey === key, + active: active === item, + expanded: this.props.isExpanded(item), + isExpandable: this._nodeIsExpandable(item), + onExpand: this._onExpand, + onCollapse: this._onCollapse, + onClick: e => { + // We can stop the propagation since click handler on the node can be + // created in `renderItem`. + e.stopPropagation(); + + // Since the user just clicked the node, there's no need to check if + // it should be scrolled into view. + this._focus(item, { preventAutoScroll: true }); + if (this.props.isExpanded(item)) { + this.props.onCollapse(item, e.altKey); + } else { + this.props.onExpand(item, e.altKey); + } + + // Focus should always remain on the tree container itself. + this.treeRef.current.focus(); + }, + }); + }); + + const style = Object.assign({}, this.props.style || {}); + + return dom.div( + { + className: `tree ${this.props.className ? this.props.className : ""}`, + ref: this.treeRef, + role: "tree", + tabIndex: "0", + onKeyDown: this._onKeyDown, + onKeyPress: this._preventArrowKeyScrolling, + onKeyUp: this._preventArrowKeyScrolling, + onFocus: ({ nativeEvent }) => { + if (focused || !nativeEvent || !this.treeRef.current) { + return; + } + + const { explicitOriginalTarget } = nativeEvent; + // Only set default focus to the first tree node if the focus came + // from outside the tree (e.g. by tabbing to the tree from other + // external elements). + if ( + explicitOriginalTarget !== this.treeRef.current && + !this.treeRef.current.contains(explicitOriginalTarget) + ) { + this._focus(traversal[0].item); + } + }, + onBlur: this._onBlur, + "aria-label": this.props.label, + "aria-labelledby": this.props.labelledby, + "aria-activedescendant": focused && this.props.getKey(focused), + style, + }, + nodes + ); + } +} + +module.exports = Tree; diff --git a/devtools/client/shared/components/VirtualizedTree.js b/devtools/client/shared/components/VirtualizedTree.js new file mode 100644 index 0000000000..4f8dab1bd5 --- /dev/null +++ b/devtools/client/shared/components/VirtualizedTree.js @@ -0,0 +1,1071 @@ +/* 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-env browser */ +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + scrollIntoView, +} = require("resource://devtools/client/shared/scroll.js"); +const { + preventDefaultAndStopPropagation, +} = require("resource://devtools/client/shared/events.js"); + +loader.lazyRequireGetter( + this, + ["wrapMoveFocus", "getFocusableElements"], + "resource://devtools/client/shared/focus.js", + true +); + +const AUTO_EXPAND_DEPTH = 0; +const NUMBER_OF_OFFSCREEN_ITEMS = 1; + +/** + * A fast, generic, expandable and collapsible tree component. + * + * This tree component is fast: it can handle trees with *many* items. It only + * renders the subset of those items which are visible in the viewport. It's + * been battle tested on huge trees in the memory panel. We've optimized tree + * traversal and rendering, even in the presence of cross-compartment wrappers. + * + * This tree component doesn't make any assumptions about the structure of your + * tree data. Whether children are computed on demand, or stored in an array in + * the parent's `_children` property, it doesn't matter. We only require the + * implementation of `getChildren`, `getRoots`, `getParent`, and `isExpanded` + * functions. + * + * This tree component is well tested and reliable. See + * devtools/client/shared/components/test/mochitest/test_tree_* and its usage in + * the performance and memory panels. + * + * This tree component doesn't make any assumptions about how to render items in + * the tree. You provide a `renderItem` function, and this component will ensure + * that only those items whose parents are expanded and which are visible in the + * viewport are rendered. The `renderItem` function could render the items as a + * "traditional" tree or as rows in a table or anything else. It doesn't + * restrict you to only one certain kind of tree. + * + * The only requirement is that every item in the tree render as the same + * height. This is required in order to compute which items are visible in the + * viewport in constant time. + * + * ### Example Usage + * + * Suppose we have some tree data where each item has this form: + * + * { + * id: Number, + * label: String, + * parent: Item or null, + * children: Array of child items, + * expanded: bool, + * } + * + * Here is how we could render that data with this component: + * + * class MyTree extends Component { + * static get propTypes() { + * // The root item of the tree, with the form described above. + * return { + * root: PropTypes.object.isRequired + * }; + * } + * + * render() { + * return Tree({ + * itemHeight: 20, // px + * + * getRoots: () => [this.props.root], + * + * getParent: item => item.parent, + * getChildren: item => item.children, + * getKey: item => item.id, + * isExpanded: item => item.expanded, + * + * renderItem: (item, depth, isFocused, arrow, isExpanded) => { + * let className = "my-tree-item"; + * if (isFocused) { + * className += " focused"; + * } + * return dom.div( + * { + * className, + * // Apply 10px nesting per expansion depth. + * style: { marginLeft: depth * 10 + "px" } + * }, + * // Here is the expando arrow so users can toggle expansion and + * // collapse state. + * arrow, + * // And here is the label for this item. + * dom.span({ className: "my-tree-item-label" }, item.label) + * ); + * }, + * + * onExpand: item => dispatchExpandActionToRedux(item), + * onCollapse: item => dispatchCollapseActionToRedux(item), + * }); + * } + * } + */ +class Tree extends Component { + static get propTypes() { + return { + // Required props + + // A function to get an item's parent, or null if it is a root. + // + // Type: getParent(item: Item) -> Maybe<Item> + // + // Example: + // + // // The parent of this item is stored in its `parent` property. + // getParent: item => item.parent + getParent: PropTypes.func.isRequired, + + // A function to get an item's children. + // + // Type: getChildren(item: Item) -> [Item] + // + // Example: + // + // // This item's children are stored in its `children` property. + // getChildren: item => item.children + getChildren: PropTypes.func.isRequired, + + // A function which takes an item and ArrowExpander component instance and + // returns a component, or text, or anything else that React considers + // renderable. + // + // Type: renderItem(item: Item, + // depth: Number, + // isFocused: Boolean, + // arrow: ReactComponent, + // isExpanded: Boolean) -> ReactRenderable + // + // Example: + // + // renderItem: (item, depth, isFocused, arrow, isExpanded) => { + // let className = "my-tree-item"; + // if (isFocused) { + // className += " focused"; + // } + // return dom.div( + // { + // className, + // style: { marginLeft: depth * 10 + "px" } + // }, + // arrow, + // dom.span({ className: "my-tree-item-label" }, item.label) + // ); + // }, + renderItem: PropTypes.func.isRequired, + + // A function which returns the roots of the tree (forest). + // + // Type: getRoots() -> [Item] + // + // Example: + // + // // In this case, we only have one top level, root item. You could + // // return multiple items if you have many top level items in your + // // tree. + // getRoots: () => [this.props.rootOfMyTree] + getRoots: PropTypes.func.isRequired, + + // A function to get a unique key for the given item. This helps speed up + // React's rendering a *TON*. + // + // Type: getKey(item: Item) -> String + // + // Example: + // + // getKey: item => `my-tree-item-${item.uniqueId}` + getKey: PropTypes.func.isRequired, + + // A function to get whether an item is expanded or not. If an item is not + // expanded, then it must be collapsed. + // + // Type: isExpanded(item: Item) -> Boolean + // + // Example: + // + // isExpanded: item => item.expanded, + isExpanded: PropTypes.func.isRequired, + + // The height of an item in the tree including margin and padding, in + // pixels. + itemHeight: PropTypes.number.isRequired, + + // Optional props + + // The currently focused item, if any such item exists. + focused: PropTypes.any, + + // Handle when a new item is focused. + onFocus: PropTypes.func, + + // The currently active (keyboard) item, if any such item exists. + active: PropTypes.any, + + // Handle when item is activated with a keyboard (using Space or Enter) + onActivate: PropTypes.func, + + // The currently shown item, if any such item exists. + shown: PropTypes.any, + + // Indicates if pressing ArrowRight key should only expand expandable node + // or if the selection should also move to the next node. + preventNavigationOnArrowRight: PropTypes.bool, + + // The depth to which we should automatically expand new items. + autoExpandDepth: PropTypes.number, + + // Note: the two properties below are mutually exclusive. Only one of the + // label properties is necessary. + // ID of an element whose textual content serves as an accessible label for + // a tree. + labelledby: PropTypes.string, + // Accessibility label for a tree widget. + label: PropTypes.string, + + // Optional event handlers for when items are expanded or collapsed. Useful + // for dispatching redux events and updating application state, maybe lazily + // loading subtrees from a worker, etc. + // + // Type: + // onExpand(item: Item) + // onCollapse(item: Item) + // + // Example: + // + // onExpand: item => dispatchExpandActionToRedux(item) + onExpand: PropTypes.func, + onCollapse: PropTypes.func, + }; + } + + static get defaultProps() { + return { + autoExpandDepth: AUTO_EXPAND_DEPTH, + preventNavigationOnArrowRight: true, + }; + } + + constructor(props) { + super(props); + + this.state = { + scroll: 0, + height: window.innerHeight, + seen: new Set(), + mouseDown: false, + }; + + this._onExpand = oncePerAnimationFrame(this._onExpand).bind(this); + this._onCollapse = oncePerAnimationFrame(this._onCollapse).bind(this); + this._onScroll = oncePerAnimationFrame(this._onScroll).bind(this); + this._focusPrevNode = oncePerAnimationFrame(this._focusPrevNode).bind(this); + this._focusNextNode = oncePerAnimationFrame(this._focusNextNode).bind(this); + this._focusParentNode = oncePerAnimationFrame(this._focusParentNode).bind( + this + ); + this._focusFirstNode = oncePerAnimationFrame(this._focusFirstNode).bind( + this + ); + this._focusLastNode = oncePerAnimationFrame(this._focusLastNode).bind(this); + + this._autoExpand = this._autoExpand.bind(this); + this._preventArrowKeyScrolling = this._preventArrowKeyScrolling.bind(this); + this._updateHeight = this._updateHeight.bind(this); + this._onResize = this._onResize.bind(this); + this._dfs = this._dfs.bind(this); + this._dfsFromRoots = this._dfsFromRoots.bind(this); + this._focus = this._focus.bind(this); + this._activate = this._activate.bind(this); + this._onKeyDown = this._onKeyDown.bind(this); + } + + componentDidMount() { + window.addEventListener("resize", this._onResize); + this._autoExpand(); + this._updateHeight(); + this._scrollItemIntoView(); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + this._autoExpand(); + this._updateHeight(); + } + + shouldComponentUpdate(nextProps, nextState) { + const { scroll, height, seen, mouseDown } = this.state; + + return ( + scroll !== nextState.scroll || + height !== nextState.height || + seen !== nextState.seen || + mouseDown === nextState.mouseDown + ); + } + + componentDidUpdate() { + this._scrollItemIntoView(); + } + + componentWillUnmount() { + window.removeEventListener("resize", this._onResize); + } + + _scrollItemIntoView() { + const { shown } = this.props; + if (!shown) { + return; + } + + this._scrollIntoView(shown); + } + + _autoExpand() { + if (!this.props.autoExpandDepth) { + return; + } + + // Automatically expand the first autoExpandDepth levels for new items. Do + // not use the usual DFS infrastructure because we don't want to ignore + // collapsed nodes. + const autoExpand = (item, currentDepth) => { + if ( + currentDepth >= this.props.autoExpandDepth || + this.state.seen.has(item) + ) { + return; + } + + this.props.onExpand(item); + this.state.seen.add(item); + + const children = this.props.getChildren(item); + const length = children.length; + for (let i = 0; i < length; i++) { + autoExpand(children[i], currentDepth + 1); + } + }; + + const roots = this.props.getRoots(); + const length = roots.length; + for (let i = 0; i < length; i++) { + autoExpand(roots[i], 0); + } + } + + _preventArrowKeyScrolling(e) { + switch (e.key) { + case "ArrowUp": + case "ArrowDown": + case "ArrowLeft": + case "ArrowRight": + preventDefaultAndStopPropagation(e); + break; + } + } + + /** + * Updates the state's height based on clientHeight. + */ + _updateHeight() { + this.setState({ height: this.refs.tree.clientHeight }); + } + + /** + * Perform a pre-order depth-first search from item. + */ + _dfs(item, maxDepth = Infinity, traversal = [], _depth = 0) { + traversal.push({ item, depth: _depth }); + + if (!this.props.isExpanded(item)) { + return traversal; + } + + const nextDepth = _depth + 1; + + if (nextDepth > maxDepth) { + return traversal; + } + + const children = this.props.getChildren(item); + const length = children.length; + for (let i = 0; i < length; i++) { + this._dfs(children[i], maxDepth, traversal, nextDepth); + } + + return traversal; + } + + /** + * Perform a pre-order depth-first search over the whole forest. + */ + _dfsFromRoots(maxDepth = Infinity) { + const traversal = []; + + const roots = this.props.getRoots(); + const length = roots.length; + for (let i = 0; i < length; i++) { + this._dfs(roots[i], maxDepth, traversal); + } + + return traversal; + } + + /** + * Expands current row. + * + * @param {Object} item + * @param {Boolean} expandAllChildren + */ + _onExpand(item, expandAllChildren) { + if (this.props.onExpand) { + this.props.onExpand(item); + + if (expandAllChildren) { + const children = this._dfs(item); + const length = children.length; + for (let i = 0; i < length; i++) { + this.props.onExpand(children[i].item); + } + } + } + } + + /** + * Collapses current row. + * + * @param {Object} item + */ + _onCollapse(item) { + if (this.props.onCollapse) { + this.props.onCollapse(item); + } + } + + /** + * Scroll item into view. Depending on whether the item is already rendered, + * we might have to calculate the position of the item based on its index and + * the item height. + * + * @param {Object} item + * The item to be scrolled into view. + * @param {Number|undefined} index + * The index of the item in a full DFS traversal (ignoring collapsed + * nodes) or undefined. + * @param {Object} options + * Optional information regarding item's requested alignement when + * scrolling. + */ + _scrollIntoView(item, index, options = {}) { + const treeElement = this.refs.tree; + if (!treeElement) { + return; + } + + const element = document.getElementById(this.props.getKey(item)); + if (element) { + scrollIntoView(element, { ...options, container: treeElement }); + return; + } + + if (index == null) { + // If index is not provided, determine item index from traversal. + const traversal = this._dfsFromRoots(); + index = traversal.findIndex(({ item: i }) => i === item); + } + + if (index == null || index < 0) { + return; + } + + const { itemHeight } = this.props; + const { clientHeight, scrollTop } = treeElement; + const elementTop = index * itemHeight; + let scrollTo; + if (scrollTop >= elementTop + itemHeight) { + scrollTo = elementTop; + } else if (scrollTop + clientHeight <= elementTop) { + scrollTo = elementTop + itemHeight - clientHeight; + } + + if (scrollTo != undefined) { + treeElement.scrollTo({ + left: 0, + top: scrollTo, + }); + } + } + + /** + * Sets the passed in item to be the focused item. + * + * @param {Number} index + * The index of the item in a full DFS traversal (ignoring collapsed + * nodes). Ignored if `item` is undefined. + * + * @param {Object|undefined} item + * The item to be focused, or undefined to focus no item. + */ + _focus(index, item, options = {}) { + if (item !== undefined && !options.preventAutoScroll) { + this._scrollIntoView(item, index, options); + } + + if (this.props.active != null) { + this._activate(null); + if (this.refs.tree !== this.activeElement) { + this.refs.tree.focus(); + } + } + + if (this.props.onFocus) { + this.props.onFocus(item); + } + } + + _activate(item) { + if (this.props.onActivate) { + this.props.onActivate(item); + } + } + + /** + * Update state height and tree's scrollTop if necessary. + */ + _onResize() { + // When tree size changes without direct user action, scroll top cat get re-set to 0 + // (for example, when tree height changes via CSS rule change). We need to ensure that + // the tree's scrollTop is in sync with the scroll state. + if (this.state.scroll !== this.refs.tree.scrollTop) { + this.refs.tree.scrollTo({ left: 0, top: this.state.scroll }); + } + + this._updateHeight(); + } + + /** + * Fired on a scroll within the tree's container, updates + * the stored position of the view port to handle virtual view rendering. + * + * @param {Event} e + */ + _onScroll(e) { + this.setState({ + scroll: Math.max(this.refs.tree.scrollTop, 0), + height: this.refs.tree.clientHeight, + }); + } + + /** + * Handles key down events in the tree's container. + * + * @param {Event} e + */ + // eslint-disable-next-line complexity + _onKeyDown(e) { + if (this.props.focused == null) { + return; + } + + // Allow parent nodes to use navigation arrows with modifiers. + if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) { + return; + } + + this._preventArrowKeyScrolling(e); + + switch (e.key) { + case "ArrowUp": + this._focusPrevNode(); + break; + + case "ArrowDown": + this._focusNextNode(); + break; + + case "ArrowLeft": + if ( + this.props.isExpanded(this.props.focused) && + this.props.getChildren(this.props.focused).length + ) { + this._onCollapse(this.props.focused); + } else { + this._focusParentNode(); + } + break; + + case "ArrowRight": + if ( + this.props.getChildren(this.props.focused).length && + !this.props.isExpanded(this.props.focused) + ) { + this._onExpand(this.props.focused); + } else if (!this.props.preventNavigationOnArrowRight) { + this._focusNextNode(); + } + break; + + case "Home": + this._focusFirstNode(); + break; + + case "End": + this._focusLastNode(); + break; + + case "Enter": + case " ": + // On space or enter make focused tree node active. This means keyboard focus + // handling is passed on to the tree node itself. + if (this.refs.tree === this.activeElement) { + preventDefaultAndStopPropagation(e); + if (this.props.active !== this.props.focused) { + this._activate(this.props.focused); + } + } + break; + + case "Escape": + preventDefaultAndStopPropagation(e); + if (this.props.active != null) { + this._activate(null); + } + + if (this.refs.tree !== this.activeElement) { + this.refs.tree.focus(); + } + break; + } + } + + get activeElement() { + return this.refs.tree.ownerDocument.activeElement; + } + + _focusFirstNode() { + const traversal = this._dfsFromRoots(); + this._focus(0, traversal[0].item, { alignTo: "top" }); + } + + _focusLastNode() { + const traversal = this._dfsFromRoots(); + const lastIndex = traversal.length - 1; + this._focus(lastIndex, traversal[lastIndex].item, { alignTo: "bottom" }); + } + + /** + * Sets the previous node relative to the currently focused item, to focused. + */ + _focusPrevNode() { + // Start a depth first search and keep going until we reach the currently + // focused node. Focus the previous node in the DFS, if it exists. If it + // doesn't exist, we're at the first node already. + + let prev; + let prevIndex; + + const traversal = this._dfsFromRoots(); + const length = traversal.length; + for (let i = 0; i < length; i++) { + const item = traversal[i].item; + if (item === this.props.focused) { + break; + } + prev = item; + prevIndex = i; + } + + if (prev === undefined) { + return; + } + + this._focus(prevIndex, prev, { alignTo: "top" }); + } + + /** + * Handles the down arrow key which will focus either the next child + * or sibling row. + */ + _focusNextNode() { + // Start a depth first search and keep going until we reach the currently + // focused node. Focus the next node in the DFS, if it exists. If it + // doesn't exist, we're at the last node already. + + const traversal = this._dfsFromRoots(); + const length = traversal.length; + let i = 0; + + while (i < length) { + if (traversal[i].item === this.props.focused) { + break; + } + i++; + } + + if (i + 1 < traversal.length) { + this._focus(i + 1, traversal[i + 1].item, { alignTo: "bottom" }); + } + } + + /** + * Handles the left arrow key, going back up to the current rows' + * parent row. + */ + _focusParentNode() { + const parent = this.props.getParent(this.props.focused); + if (!parent) { + return; + } + + const traversal = this._dfsFromRoots(); + const length = traversal.length; + let parentIndex = 0; + for (; parentIndex < length; parentIndex++) { + if (traversal[parentIndex].item === parent) { + break; + } + } + + this._focus(parentIndex, parent, { alignTo: "top" }); + } + + render() { + const traversal = this._dfsFromRoots(); + + // 'begin' and 'end' are the index of the first (at least partially) visible item + // and the index after the last (at least partially) visible item, respectively. + // `NUMBER_OF_OFFSCREEN_ITEMS` is removed from `begin` and added to `end` so that + // the top and bottom of the page are filled with the `NUMBER_OF_OFFSCREEN_ITEMS` + // previous and next items respectively, which helps the user to see fewer empty + // gaps when scrolling quickly. + const { itemHeight, active, focused } = this.props; + const { scroll, height } = this.state; + const begin = Math.max( + ((scroll / itemHeight) | 0) - NUMBER_OF_OFFSCREEN_ITEMS, + 0 + ); + const end = + Math.ceil((scroll + height) / itemHeight) + NUMBER_OF_OFFSCREEN_ITEMS; + const toRender = traversal.slice(begin, end); + const topSpacerHeight = begin * itemHeight; + const bottomSpacerHeight = Math.max(traversal.length - end, 0) * itemHeight; + + const nodes = [ + dom.div({ + key: "top-spacer", + role: "presentation", + style: { + padding: 0, + margin: 0, + height: topSpacerHeight + "px", + }, + }), + ]; + + for (let i = 0; i < toRender.length; i++) { + const index = begin + i; + const first = index == 0; + const last = index == traversal.length - 1; + const { item, depth } = toRender[i]; + const key = this.props.getKey(item); + nodes.push( + TreeNode({ + // We make a key unique depending on whether the tree node is in active or + // inactive state to make sure that it is actually replaced and the tabbable + // state is reset. + key: `${key}-${active === item ? "active" : "inactive"}`, + index, + first, + last, + item, + depth, + id: key, + renderItem: this.props.renderItem, + focused: focused === item, + active: active === item, + expanded: this.props.isExpanded(item), + hasChildren: !!this.props.getChildren(item).length, + onExpand: this._onExpand, + onCollapse: this._onCollapse, + // Since the user just clicked the node, there's no need to check if + // it should be scrolled into view. + onClick: () => + this._focus(begin + i, item, { preventAutoScroll: true }), + }) + ); + } + + nodes.push( + dom.div({ + key: "bottom-spacer", + role: "presentation", + style: { + padding: 0, + margin: 0, + height: bottomSpacerHeight + "px", + }, + }) + ); + + return dom.div( + { + className: "tree", + ref: "tree", + role: "tree", + tabIndex: "0", + onKeyDown: this._onKeyDown, + onKeyPress: this._preventArrowKeyScrolling, + onKeyUp: this._preventArrowKeyScrolling, + onScroll: this._onScroll, + onMouseDown: () => this.setState({ mouseDown: true }), + onMouseUp: () => this.setState({ mouseDown: false }), + onFocus: () => { + if (focused || this.state.mouseDown) { + return; + } + + // Only set default focus to the first tree node if focused node is + // not yet set and the focus event is not the result of a mouse + // interarction. + this._focus(begin, toRender[0].item); + }, + onBlur: e => { + if (active != null) { + const { relatedTarget } = e; + if (!this.refs.tree.contains(relatedTarget)) { + this._activate(null); + } + } + }, + onClick: () => { + // Focus should always remain on the tree container itself. + this.refs.tree.focus(); + }, + "aria-label": this.props.label, + "aria-labelledby": this.props.labelledby, + "aria-activedescendant": focused && this.props.getKey(focused), + style: { + padding: 0, + margin: 0, + }, + }, + nodes + ); + } +} + +/** + * An arrow that displays whether its node is expanded (▼) or collapsed + * (▶). When its node has no children, it is hidden. + */ +class ArrowExpanderClass extends Component { + static get propTypes() { + return { + item: PropTypes.any.isRequired, + visible: PropTypes.bool.isRequired, + expanded: PropTypes.bool.isRequired, + onCollapse: PropTypes.func.isRequired, + onExpand: PropTypes.func.isRequired, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + this.props.item !== nextProps.item || + this.props.visible !== nextProps.visible || + this.props.expanded !== nextProps.expanded + ); + } + + render() { + const attrs = { + className: "arrow theme-twisty", + // To collapse/expand the tree rows use left/right arrow keys. + tabIndex: "-1", + "aria-hidden": true, + onClick: this.props.expanded + ? () => this.props.onCollapse(this.props.item) + : e => this.props.onExpand(this.props.item, e.altKey), + }; + + if (this.props.expanded) { + attrs.className += " open"; + } + + if (!this.props.visible) { + attrs.style = { + visibility: "hidden", + }; + } + + return dom.div(attrs); + } +} + +class TreeNodeClass extends Component { + static get propTypes() { + return { + id: PropTypes.any.isRequired, + focused: PropTypes.bool.isRequired, + active: PropTypes.bool.isRequired, + item: PropTypes.any.isRequired, + expanded: PropTypes.bool.isRequired, + hasChildren: PropTypes.bool.isRequired, + onExpand: PropTypes.func.isRequired, + index: PropTypes.number.isRequired, + first: PropTypes.bool, + last: PropTypes.bool, + onClick: PropTypes.func, + onCollapse: PropTypes.func.isRequired, + depth: PropTypes.number.isRequired, + renderItem: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + this._onKeyDown = this._onKeyDown.bind(this); + } + + componentDidMount() { + // Make sure that none of the focusable elements inside the tree node container are + // tabbable if the tree node is not active. If the tree node is active and focus is + // outside its container, focus on the first focusable element inside. + const elms = getFocusableElements(this.refs.treenode); + if (elms.length === 0) { + return; + } + + if (!this.props.active) { + elms.forEach(elm => elm.setAttribute("tabindex", "-1")); + return; + } + + if (!elms.includes(this.refs.treenode.ownerDocument.activeElement)) { + elms[0].focus(); + } + } + + _onKeyDown(e) { + const { target, key, shiftKey } = e; + + if (key !== "Tab") { + return; + } + + const focusMoved = !!wrapMoveFocus( + getFocusableElements(this.refs.treenode), + target, + shiftKey + ); + if (focusMoved) { + // Focus was moved to the begining/end of the list, so we need to prevent the + // default focus change that would happen here. + e.preventDefault(); + } + + e.stopPropagation(); + } + + render() { + const arrow = ArrowExpander({ + item: this.props.item, + expanded: this.props.expanded, + visible: this.props.hasChildren, + onExpand: this.props.onExpand, + onCollapse: this.props.onCollapse, + }); + + const classList = ["tree-node", "div"]; + if (this.props.index % 2) { + classList.push("tree-node-odd"); + } + if (this.props.first) { + classList.push("tree-node-first"); + } + if (this.props.last) { + classList.push("tree-node-last"); + } + if (this.props.active) { + classList.push("tree-node-active"); + } + + let ariaExpanded; + if (this.props.hasChildren) { + ariaExpanded = false; + } + if (this.props.expanded) { + ariaExpanded = true; + } + + return dom.div( + { + id: this.props.id, + className: classList.join(" "), + role: "treeitem", + ref: "treenode", + "aria-level": this.props.depth + 1, + onClick: this.props.onClick, + onKeyDownCapture: this.props.active ? this._onKeyDown : undefined, + "aria-expanded": ariaExpanded, + "data-expanded": this.props.expanded ? "" : undefined, + "data-depth": this.props.depth, + style: { + padding: 0, + margin: 0, + }, + }, + + this.props.renderItem( + this.props.item, + this.props.depth, + this.props.focused, + arrow, + this.props.expanded + ) + ); + } +} + +const ArrowExpander = createFactory(ArrowExpanderClass); +const TreeNode = createFactory(TreeNodeClass); + +/** + * Create a function that calls the given function `fn` only once per animation + * frame. + * + * @param {Function} fn + * @returns {Function} + */ +function oncePerAnimationFrame(fn) { + let animationId = null; + let argsToPass = null; + return function (...args) { + argsToPass = args; + if (animationId !== null) { + return; + } + + animationId = requestAnimationFrame(() => { + fn.call(this, ...argsToPass); + animationId = null; + argsToPass = null; + }); + }; +} + +module.exports = Tree; diff --git a/devtools/client/shared/components/VisibilityHandler.js b/devtools/client/shared/components/VisibilityHandler.js new file mode 100644 index 0000000000..be1f3c5f93 --- /dev/null +++ b/devtools/client/shared/components/VisibilityHandler.js @@ -0,0 +1,57 @@ +/* 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"; + +/** + * Helper class to disable panel rendering when it is in background. + * + * Toolbox code hides the iframes when switching to another panel + * and triggers `visibilitychange` events. + * + * See devtools/client/framework/toolbox.js:setIframeVisible(). + */ + +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +class VisibilityHandler extends Component { + static get propTypes() { + return { + children: PropTypes.element.isRequired, + }; + } + + constructor(props) { + super(props); + + this.onVisibilityChange = this.onVisibilityChange.bind(this); + } + + componentDidMount() { + document.addEventListener("visibilitychange", this.onVisibilityChange); + } + + shouldComponentUpdate() { + return document.visibilityState == "visible"; + } + + componentWillUnmount() { + document.removeEventListener("visibilitychange", this.onVisibilityChange); + } + + onVisibilityChange() { + if (document.visibilityState == "visible") { + this.forceUpdate(); + } + } + + render() { + return this.props.children; + } +} + +module.exports = VisibilityHandler; diff --git a/devtools/client/shared/components/menu/MenuButton.js b/devtools/client/shared/components/menu/MenuButton.js new file mode 100644 index 0000000000..3367987c3c --- /dev/null +++ b/devtools/client/shared/components/menu/MenuButton.js @@ -0,0 +1,450 @@ +/* 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-env browser */ +"use strict"; + +// A button that toggles a doorhanger menu. + +const flags = require("resource://devtools/shared/flags.js"); +const { + createRef, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { button } = dom; + +const isMacOS = Services.appinfo.OS === "Darwin"; + +loader.lazyRequireGetter( + this, + "HTMLTooltip", + "resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js", + true +); + +loader.lazyRequireGetter( + this, + "focusableSelector", + "resource://devtools/client/shared/focus.js", + true +); + +loader.lazyRequireGetter( + this, + "createPortal", + "resource://devtools/client/shared/vendor/react-dom.js", + true +); + +// Return a copy of |obj| minus |fields|. +const omit = (obj, fields) => { + const objCopy = { ...obj }; + for (const field of fields) { + delete objCopy[field]; + } + return objCopy; +}; + +class MenuButton extends PureComponent { + static get propTypes() { + return { + // The toolbox document that will be used for rendering the menu popup. + toolboxDoc: PropTypes.object.isRequired, + + // A text content for the button. + label: PropTypes.string, + + // URL of the icon to associate with the MenuButton. (Optional) + // e.g. chrome://devtools/skin/image/foo.svg + icon: PropTypes.string, + + // An optional ID to assign to the menu's container tooltip object. + menuId: PropTypes.string, + + // The preferred side of the anchor element to display the menu. + // Defaults to "bottom". + menuPosition: PropTypes.string.isRequired, + + // The offset of the menu from the anchor element. + // Defaults to -5. + menuOffset: PropTypes.number.isRequired, + + // The menu content. + children: PropTypes.any, + + // Callback function to be invoked when the button is clicked. + onClick: PropTypes.func, + + // Callback function to be invoked when the child panel is closed. + onCloseButton: PropTypes.func, + }; + } + + static get defaultProps() { + return { + menuPosition: "bottom", + menuOffset: -5, + }; + } + + constructor(props) { + super(props); + + this.showMenu = this.showMenu.bind(this); + this.hideMenu = this.hideMenu.bind(this); + this.toggleMenu = this.toggleMenu.bind(this); + this.onHidden = this.onHidden.bind(this); + this.onClick = this.onClick.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onTouchStart = this.onTouchStart.bind(this); + + this.buttonRef = createRef(); + + this.state = { + expanded: false, + // In tests, initialize the menu immediately. + isMenuInitialized: flags.testing || false, + win: props.toolboxDoc.defaultView.top, + }; + this.ignoreNextClick = false; + + this.initializeTooltip(); + } + + componentDidMount() { + if (!this.state.isMenuInitialized) { + // Initialize the menu when the button is focused or moused over. + for (const event of ["focus", "mousemove"]) { + this.buttonRef.current.addEventListener( + event, + () => { + if (!this.state.isMenuInitialized) { + this.setState({ isMenuInitialized: true }); + } + }, + { once: true } + ); + } + } + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + // If the window changes, we need to regenerate the HTMLTooltip or else the + // XUL wrapper element will appear above (in terms of z-index) the old + // window, and not the new. + const win = nextProps.toolboxDoc.defaultView.top; + if ( + nextProps.toolboxDoc !== this.props.toolboxDoc || + this.state.win !== win || + nextProps.menuId !== this.props.menuId + ) { + this.setState({ win }); + this.resetTooltip(); + this.initializeTooltip(); + } + } + + componentDidUpdate() { + // The MenuButton creates the child panel when initializing the MenuButton. + // If the children function is called during the rendering process, + // this child list size might change. So we need to adjust content size here. + if (typeof this.props.children === "function") { + this.resizeContent(); + } + } + + componentWillUnmount() { + this.resetTooltip(); + } + + initializeTooltip() { + const tooltipProps = { + type: "doorhanger", + useXulWrapper: true, + isMenuTooltip: true, + }; + + if (this.props.menuId) { + tooltipProps.id = this.props.menuId; + } + + this.tooltip = new HTMLTooltip(this.props.toolboxDoc, tooltipProps); + this.tooltip.on("hidden", this.onHidden); + } + + async resetTooltip() { + if (!this.tooltip) { + return; + } + + // Mark the menu as closed since the onHidden callback may not be called in + // this case. + this.setState({ expanded: false }); + this.tooltip.off("hidden", this.onHidden); + this.tooltip.destroy(); + this.tooltip = null; + } + + async showMenu(anchor) { + this.setState({ + expanded: true, + }); + + if (!this.tooltip) { + return; + } + + await this.tooltip.show(anchor, { + position: this.props.menuPosition, + y: this.props.menuOffset, + }); + } + + async hideMenu() { + this.setState({ + expanded: false, + }); + + if (!this.tooltip) { + return; + } + + await this.tooltip.hide(); + } + + async toggleMenu(anchor) { + return this.state.expanded ? this.hideMenu() : this.showMenu(anchor); + } + + // Used by the call site to indicate that the menu content has changed so + // its container should be updated. + resizeContent() { + if (!this.state.expanded || !this.tooltip || !this.buttonRef.current) { + return; + } + + this.tooltip.show(this.buttonRef.current, { + position: this.props.menuPosition, + y: this.props.menuOffset, + }); + } + + // When we are closing the menu we will get a 'hidden' event before we get + // a 'click' event. We want to re-enable the pointer-events: auto setting we + // use on the button while the menu is visible, but we don't want to do it + // until after the subsequent click event since otherwise we will end up + // re-opening the menu. + // + // For mouse events, we achieve this by using setTimeout(..., 0) to schedule + // a separate task to run after the click event, but in the case of touch + // events the event order differs and the setTimeout callback will run before + // the click event. + // + // In order to prevent that we detect touch events and set a flag to ignore + // the next click event. However, we need to differentiate between touch drag + // events and long press events (which don't generate a 'click') and "taps" + // (which do). We do that by looking for a 'touchmove' event and clearing the + // flag if we get one. + onTouchStart(evt) { + const touchend = () => { + const anchorRect = this.buttonRef.current.getClientRects()[0]; + const { clientX, clientY } = evt.changedTouches[0]; + // We need to check that the click is inside the bounds since when the + // menu is being closed the button will currently have + // pointer-events: none (and if we don't check the bounds we will end up + // ignoring unrelated clicks). + if ( + anchorRect.x <= clientX && + clientX <= anchorRect.x + anchorRect.width && + anchorRect.y <= clientY && + clientY <= anchorRect.y + anchorRect.height + ) { + this.ignoreNextClick = true; + } + }; + + const touchmove = () => { + this.state.win.removeEventListener("touchend", touchend); + }; + + this.state.win.addEventListener("touchend", touchend, { once: true }); + this.state.win.addEventListener("touchmove", touchmove, { once: true }); + } + + onHidden() { + this.setState({ expanded: false }); + // While the menu is open, if we click _anywhere_ outside the menu, it will + // automatically close. This is performed by the XUL wrapper before we get + // any chance to see any event. To avoid immediately re-opening the menu + // when we process the subsequent click event on this button, we set + // 'pointer-events: none' on the button while the menu is open. + // + // After the menu is closed we need to remove the pointer-events style (so + // the button works again) but we don't want to do it immediately since the + // "popuphidden" event which triggers this callback might be dispatched + // before the "click" event that we want to ignore. As a result, we queue + // up a task using setTimeout() to run after the "click" event. + this.state.win.setTimeout(() => { + if (this.buttonRef.current) { + this.buttonRef.current.style.pointerEvents = "auto"; + } + this.state.win.removeEventListener("touchstart", this.onTouchStart, true); + }, 0); + + this.state.win.addEventListener("touchstart", this.onTouchStart, true); + + if (this.props.onCloseButton) { + this.props.onCloseButton(); + } + } + + async onClick(e) { + if (this.ignoreNextClick) { + this.ignoreNextClick = false; + return; + } + + if (e.target === this.buttonRef.current) { + // On Mac, even after clicking the button it doesn't get focus. + // Force focus to the button so that our keydown handlers get called. + this.buttonRef.current.focus(); + + if (this.props.onClick) { + this.props.onClick(e); + } + + if (!e.defaultPrevented) { + const wasKeyboardEvent = e.screenX === 0 && e.screenY === 0; + // If the popup menu will be shown, disable this button in order to + // prevent reopening the popup menu. See extended comment in onHidden(). + // above. + // + // Also, we should _not_ set 'pointer-events: none' if + // ui.popup.disable_autohide pref is in effect since, in that case, + // there's no redundant hiding behavior and we actually want clicking + // the button to close the menu. + if ( + !this.state.expanded && + !Services.prefs.getBoolPref("ui.popup.disable_autohide", false) + ) { + this.buttonRef.current.style.pointerEvents = "none"; + } + await this.toggleMenu(e.target); + // If the menu was activated by keyboard, focus the first item. + if (wasKeyboardEvent && this.tooltip) { + this.tooltip.focus(); + } + + // MenuButton creates the children dynamically when clicking the button, + // so execute the goggle menu after updating the children panel. + if (typeof this.props.children === "function") { + this.forceUpdate(); + } + } + // If we clicked one of the menu items, then, by default, we should + // auto-collapse the menu. + // + // We check for the defaultPrevented state, however, so that menu items can + // turn this behavior off (e.g. a menu item with an embedded button). + } else if ( + this.state.expanded && + !e.defaultPrevented && + e.target.matches(focusableSelector) + ) { + this.hideMenu(); + } + } + + onKeyDown(e) { + if (!this.state.expanded) { + return; + } + + const isButtonFocussed = + this.props.toolboxDoc && + this.props.toolboxDoc.activeElement === this.buttonRef.current; + + switch (e.key) { + case "Escape": + this.hideMenu(); + e.preventDefault(); + break; + + case "Tab": + case "ArrowDown": + if (isButtonFocussed && this.tooltip) { + if (this.tooltip.focus()) { + e.preventDefault(); + } + } + break; + + case "ArrowUp": + if (isButtonFocussed && this.tooltip) { + if (this.tooltip.focusEnd()) { + e.preventDefault(); + } + } + break; + case "t": + if ((isMacOS && e.metaKey) || (!isMacOS && e.ctrlKey)) { + // Close the menu if the user opens a new tab while it is still open. + // + // Bug 1499271: Once toolbox has been converted to XUL we should watch + // for the 'visibilitychange' event instead of explicitly looking for + // Ctrl+T. + this.hideMenu(); + } + break; + } + } + + render() { + const buttonProps = { + // Pass through any props set on the button, except the ones we handle + // here. + ...omit(this.props, Object.keys(MenuButton.propTypes)), + onClick: this.onClick, + "aria-expanded": this.state.expanded, + "aria-haspopup": "menu", + ref: this.buttonRef, + }; + + if (this.state.expanded) { + buttonProps.onKeyDown = this.onKeyDown; + } + + if (this.props.menuId) { + buttonProps["aria-controls"] = this.props.menuId; + } + + if (this.props.icon) { + const iconClass = "menu-button--iconic"; + buttonProps.className = buttonProps.className + ? `${buttonProps.className} ${iconClass}` + : iconClass; + buttonProps.style = { + "--menuitem-icon-image": "url(" + this.props.icon + ")", + }; + } + + if (this.state.isMenuInitialized) { + const menu = createPortal( + typeof this.props.children === "function" + ? this.props.children() + : this.props.children, + this.tooltip.panel + ); + + return button(buttonProps, this.props.label, menu); + } + + return button(buttonProps, this.props.label); + } +} + +module.exports = MenuButton; diff --git a/devtools/client/shared/components/menu/MenuItem.js b/devtools/client/shared/components/menu/MenuItem.js new file mode 100644 index 0000000000..c3efa6db6c --- /dev/null +++ b/devtools/client/shared/components/menu/MenuItem.js @@ -0,0 +1,211 @@ +/* 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-env browser */ +"use strict"; + +// A command in a menu. + +const { + createFactory, + createRef, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { button, li, span } = dom; +loader.lazyGetter(this, "Localized", () => + createFactory( + require("resource://devtools/client/shared/vendor/fluent-react.js") + .Localized + ) +); + +class MenuItem extends PureComponent { + static get propTypes() { + return { + // An optional keyboard shortcut to display next to the item. + // (This does not actually register the event listener for the key.) + accelerator: PropTypes.string, + + // A tri-state value that may be true/false if item should be checkable, + // and undefined otherwise. + checked: PropTypes.bool, + + // Any additional classes to assign to the button specified as + // a space-separated string. + className: PropTypes.string, + + // A disabled state of the menu item. + disabled: PropTypes.bool, + + // URL of the icon to associate with the MenuItem. (Optional) + // + // e.g. chrome://devtools/skim/image/foo.svg + // + // This may also be set in CSS using the --menuitem-icon-image variable. + // Note that in this case, the variable should specify the CSS <image> to + // use, not simply the URL (e.g. + // "url(chrome://devtools/skim/image/foo.svg)"). + icon: PropTypes.string, + + // An optional ID to be assigned to the item. + id: PropTypes.string, + + // The item label for use with legacy localization systems. + label: PropTypes.string, + + // The Fluent ID for localizing the label. + l10nID: PropTypes.string, + + // An optional callback to be invoked when the item is selected. + onClick: PropTypes.func, + + // Optional menu item role override. Use this property with a value + // "menuitemradio" if the menu item is a radio. + role: PropTypes.string, + + // An optional text for the item tooltip. + tooltip: PropTypes.string, + }; + } + + /** + * Use this as a fallback `icon` prop if your MenuList contains MenuItems + * with or without icon in order to keep all MenuItems aligned. + */ + static get DUMMY_ICON() { + return `data:image/svg+xml,${encodeURIComponent( + '<svg height="16" width="16"></svg>' + )}`; + } + + constructor(props) { + super(props); + this.labelRef = createRef(); + } + + componentDidMount() { + if (!this.labelRef.current) { + return; + } + + // Pre-fetch any backgrounds specified for the item. + const win = this.labelRef.current.ownerDocument.defaultView; + this.preloadCallback = win.requestIdleCallback(() => { + this.preloadCallback = null; + if (!this.labelRef.current) { + return; + } + + const backgrounds = win + .getComputedStyle(this.labelRef.current, ":before") + .getCSSImageURLs("background-image"); + for (const background of backgrounds) { + const image = new Image(); + image.src = background; + } + }); + } + + componentWillUnmount() { + if (!this.labelRef.current || !this.preloadCallback) { + return; + } + + const win = this.labelRef.current.ownerDocument.defaultView; + if (win) { + win.cancelIdleCallback(this.preloadCallback); + } + this.preloadCallback = null; + } + + render() { + const attr = { + className: "command", + }; + + if (this.props.id) { + attr.id = this.props.id; + } + + if (this.props.className) { + attr.className += " " + this.props.className; + } + + if (this.props.icon) { + attr.className += " iconic"; + attr.style = { "--menuitem-icon-image": "url(" + this.props.icon + ")" }; + } + + if (this.props.onClick) { + attr.onClick = this.props.onClick; + } + + if (this.props.tooltip) { + attr.title = this.props.tooltip; + } + + if (this.props.disabled) { + attr.disabled = this.props.disabled; + } + + if (this.props.role) { + attr.role = this.props.role; + } else if (typeof this.props.checked !== "undefined") { + attr.role = "menuitemcheckbox"; + } else { + attr.role = "menuitem"; + } + + if (this.props.checked) { + attr["aria-checked"] = true; + } + + const children = []; + const className = "label"; + + // Add the text label. + if (this.props.l10nID) { + // Fluent localized label. + children.push( + Localized( + { id: this.props.l10nID, key: "label" }, + span({ className, ref: this.labelRef }) + ) + ); + } else { + children.push( + span({ key: "label", className, ref: this.labelRef }, this.props.label) + ); + } + + if (this.props.l10nID && this.props.label) { + console.warn( + "<MenuItem> should only take either an l10nID or a label, not both" + ); + } + if (!this.props.l10nID && !this.props.label) { + console.warn("<MenuItem> requires either an l10nID, or a label prop."); + } + + if (typeof this.props.accelerator !== "undefined") { + const acceleratorLabel = span( + { key: "accelerator", className: "accelerator" }, + this.props.accelerator + ); + children.push(acceleratorLabel); + } + + return li( + { + className: "menuitem", + role: "presentation", + }, + button(attr, children) + ); + } +} + +module.exports = MenuItem; diff --git a/devtools/client/shared/components/menu/MenuList.js b/devtools/client/shared/components/menu/MenuList.js new file mode 100644 index 0000000000..4c355cca10 --- /dev/null +++ b/devtools/client/shared/components/menu/MenuList.js @@ -0,0 +1,164 @@ +/* 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-env browser */ +"use strict"; + +// A list of menu items. +// +// This component provides keyboard navigation amongst any focusable +// children. + +const { + Children, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { div } = dom; +const { + focusableSelector, +} = require("resource://devtools/client/shared/focus.js"); + +class MenuList extends PureComponent { + static get propTypes() { + return { + // ID to assign to the list container. + id: PropTypes.string, + + // Children of the list. + children: PropTypes.any, + + // Called whenever there is a change to the hovered or selected child. + // The callback is passed the ID of the highlighted child or null if no + // child is highlighted. + onHighlightedChildChange: PropTypes.func, + }; + } + + constructor(props) { + super(props); + + this.onKeyDown = this.onKeyDown.bind(this); + this.onMouseOverOrFocus = this.onMouseOverOrFocus.bind(this); + this.onMouseOutOrBlur = this.onMouseOutOrBlur.bind(this); + this.notifyHighlightedChildChange = + this.notifyHighlightedChildChange.bind(this); + + this.setWrapperRef = element => { + this.wrapperRef = element; + }; + } + + onMouseOverOrFocus(e) { + this.notifyHighlightedChildChange(e.target.id); + } + + onMouseOutOrBlur(e) { + const hoveredElem = this.wrapperRef.querySelector(":hover"); + if (!hoveredElem) { + this.notifyHighlightedChildChange(null); + } + } + + notifyHighlightedChildChange(id) { + if (this.props.onHighlightedChildChange) { + this.props.onHighlightedChildChange(id); + } + } + + onKeyDown(e) { + // Check if the focus is in the list. + if ( + !this.wrapperRef || + !this.wrapperRef.contains(e.target.ownerDocument.activeElement) + ) { + return; + } + + const getTabList = () => + Array.from(this.wrapperRef.querySelectorAll(focusableSelector)); + + switch (e.key) { + case "Tab": + case "ArrowUp": + case "ArrowDown": + { + const tabList = getTabList(); + const currentElement = e.target.ownerDocument.activeElement; + const currentIndex = tabList.indexOf(currentElement); + if (currentIndex !== -1) { + let nextIndex; + if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) { + nextIndex = + currentIndex === tabList.length - 1 ? 0 : currentIndex + 1; + } else { + nextIndex = + currentIndex === 0 ? tabList.length - 1 : currentIndex - 1; + } + tabList[nextIndex].focus(); + e.preventDefault(); + } + } + break; + + case "Home": + { + const firstItem = this.wrapperRef.querySelector(focusableSelector); + if (firstItem) { + firstItem.focus(); + e.preventDefault(); + } + } + break; + + case "End": + { + const tabList = getTabList(); + if (tabList.length) { + tabList[tabList.length - 1].focus(); + e.preventDefault(); + } + } + break; + } + } + + render() { + const attr = { + role: "menu", + ref: this.setWrapperRef, + onKeyDown: this.onKeyDown, + onMouseOver: this.onMouseOverOrFocus, + onMouseOut: this.onMouseOutOrBlur, + onFocus: this.onMouseOverOrFocus, + onBlur: this.onMouseOutOrBlur, + className: "menu-standard-padding", + }; + + if (this.props.id) { + attr.id = this.props.id; + } + + // Add padding for checkbox image if necessary. + let hasCheckbox = false; + Children.forEach(this.props.children, (child, i) => { + if (child == null || typeof child == "undefined") { + console.warn("MenuList children at index", i, "is", child); + return; + } + + if (typeof child?.props?.checked !== "undefined") { + hasCheckbox = true; + } + }); + if (hasCheckbox) { + attr.className = "checkbox-container menu-standard-padding"; + } + + return div(attr, this.props.children); + } +} + +module.exports = MenuList; diff --git a/devtools/client/shared/components/menu/moz.build b/devtools/client/shared/components/menu/moz.build new file mode 100644 index 0000000000..08046199e5 --- /dev/null +++ b/devtools/client/shared/components/menu/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( + "MenuButton.js", + "MenuItem.js", + "MenuList.js", + "utils.js", +) diff --git a/devtools/client/shared/components/menu/utils.js b/devtools/client/shared/components/menu/utils.js new file mode 100644 index 0000000000..e6fca96822 --- /dev/null +++ b/devtools/client/shared/components/menu/utils.js @@ -0,0 +1,62 @@ +/* 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 Menu = require("resource://devtools/client/framework/menu.js"); +const MenuItem = require("resource://devtools/client/framework/menu-item.js"); + +/** + * Helper function for opening context menu. + * + * @param {Array} items + * List of menu items. + * @param {Object} options: + * @property {Element} button + * Button element used to open the menu. + * @property {Number} screenX + * Screen x coordinate of the menu on the screen. + * @property {Number} screenY + * Screen y coordinate of the menu on the screen. + */ +function showMenu(items, options) { + if (items.length === 0) { + return; + } + + // Build the menu object from provided menu items. + const menu = new Menu(); + items.forEach(item => { + if (item == "-") { + item = { type: "separator" }; + } + + const menuItem = new MenuItem(item); + const subItems = item.submenu; + + if (subItems) { + const subMenu = new Menu(); + subItems.forEach(subItem => { + subMenu.append(new MenuItem(subItem)); + }); + menuItem.submenu = subMenu; + } + + menu.append(menuItem); + }); + + // Calculate position on the screen according to + // the parent button if available. + if (options.button) { + menu.popupAtTarget(options.button); + } else { + const screenX = options.screenX; + const screenY = options.screenY; + menu.popup(screenX, screenY, window.document); + } +} + +module.exports = { + showMenu, +}; diff --git a/devtools/client/shared/components/moz.build b/devtools/client/shared/components/moz.build new file mode 100644 index 0000000000..32f55ecaac --- /dev/null +++ b/devtools/client/shared/components/moz.build @@ -0,0 +1,41 @@ +# -*- 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 += [ + "object-inspector", + "menu", + "reps", + "splitter", + "tabs", + "throttling", + "tree", +] + +DevToolsModules( + "Accordion.js", + "AppErrorBoundary.js", + "Frame.js", + "HSplitBox.js", + "List.js", + "MdnLink.js", + "NotificationBox.js", + "SearchBox.js", + "SearchBoxAutocompletePopup.js", + "SearchModifiers.js", + "Sidebar.js", + "SidebarToggle.js", + "SmartTrace.js", + "StackTrace.js", + "Tree.js", + "VirtualizedTree.js", + "VisibilityHandler.js", +) + +MOCHITEST_CHROME_MANIFESTS += ["test/chrome/chrome.toml"] +BROWSER_CHROME_MANIFESTS += [ + "test/browser/browser.toml", + "test/node/stubs/reps/stubs.toml", +] diff --git a/devtools/client/shared/components/object-inspector/actions.js b/devtools/client/shared/components/object-inspector/actions.js new file mode 100644 index 0000000000..370f1b161a --- /dev/null +++ b/devtools/client/shared/components/object-inspector/actions.js @@ -0,0 +1,225 @@ +/* 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/>. */ + +const { loadItemProperties } = require("resource://devtools/client/shared/components/object-inspector/utils/load-properties.js"); +const { + getPathExpression, + getParentFront, + getParentGripValue, + getValue, + nodeIsBucket, + getFront, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); +const { getLoadedProperties, getWatchpoints } = require("resource://devtools/client/shared/components/object-inspector/reducer.js"); + +/** + * This action is responsible for expanding a given node, which also means that + * it will call the action responsible to fetch properties. + */ +function nodeExpand(node, actor) { + return async ({ dispatch }) => { + dispatch({ type: "NODE_EXPAND", data: { node } }); + dispatch(nodeLoadProperties(node, actor)); + }; +} + +function nodeCollapse(node) { + return { + type: "NODE_COLLAPSE", + data: { node }, + }; +} + +/* + * This action checks if we need to fetch properties, entries, prototype and + * symbols for a given node. If we do, it will call the appropriate ObjectFront + * functions. + */ +function nodeLoadProperties(node, actor) { + return async ({ dispatch, client, getState }) => { + const state = getState(); + const loadedProperties = getLoadedProperties(state); + if (loadedProperties.has(node.path)) { + return; + } + + try { + const properties = await loadItemProperties( + node, + client, + loadedProperties + ); + + // If the client does not have a releaseActor function, it means the actors are + // handled directly by the consumer, so we don't need to track them. + if (!client || !client.releaseActor) { + actor = null; + } + + dispatch(nodePropertiesLoaded(node, actor, properties)); + } catch (e) { + console.error(e); + } + }; +} + +function nodePropertiesLoaded(node, actor, properties) { + return { + type: "NODE_PROPERTIES_LOADED", + data: { node, actor, properties }, + }; +} + +/* + * This action adds a property watchpoint to an object + */ +function addWatchpoint(item, watchpoint) { + return async function({ dispatch, client }) { + const { parent, name } = item; + let object = getValue(parent); + + if (nodeIsBucket(parent)) { + object = getValue(parent.parent); + } + + if (!object) { + return; + } + + const path = parent.path; + const property = name; + const label = getPathExpression(item); + const actor = object.actor; + + await client.addWatchpoint(object, property, label, watchpoint); + + dispatch({ + type: "SET_WATCHPOINT", + data: { path, watchpoint, property, actor }, + }); + }; +} + +/* + * This action removes a property watchpoint from an object + */ +function removeWatchpoint(item) { + return async function({ dispatch, client }) { + const { parent, name } = item; + let object = getValue(parent); + + if (nodeIsBucket(parent)) { + object = getValue(parent.parent); + } + + const property = name; + const path = parent.path; + const actor = object.actor; + + await client.removeWatchpoint(object, property); + + dispatch({ + type: "REMOVE_WATCHPOINT", + data: { path, property, actor }, + }); + }; +} + +function getActorIDs(roots) { + if (!roots) { + return [] + } + + const actorIds = []; + for (const root of roots) { + const front = getFront(root); + if (front?.actorID) { + actorIds.push(front.actorID); + } + } + + return actorIds; +} + +function closeObjectInspector(roots) { + return ({ client }) => { + releaseActors(client, roots); + }; +} + +/* + * This action is dispatched when the `roots` prop, provided by a consumer of + * the ObjectInspector (inspector, console, …), is modified. It will clean the + * internal state properties (expandedPaths, loadedProperties, …) and release + * the actors consumed with the previous roots. + * It takes a props argument which reflects what is passed by the upper-level + * consumer. + */ +function rootsChanged(roots, oldRoots) { + return ({ dispatch, client }) => { + releaseActors(client, oldRoots, roots); + dispatch({ + type: "ROOTS_CHANGED", + data: roots, + }); + }; +} + +/** + * Release any actors we don't need anymore + * + * @param {Object} client: Object with a `releaseActor` method + * @param {Array} oldRoots: The roots in which we want to cleanup now-unused actors + * @param {Array} newRoots: The current roots (might have item that are also in oldRoots) + */ +async function releaseActors(client, oldRoots, newRoots = []) { + if (!client?.releaseActor ) { + return; + } + + let actorIdsToRelease = getActorIDs(oldRoots); + if (newRoots.length) { + const newActorIds = getActorIDs(newRoots); + actorIdsToRelease = actorIdsToRelease.filter(id => !newActorIds.includes(id)); + } + + if (!actorIdsToRelease.length) { + return; + } + await Promise.all(actorIdsToRelease.map(client.releaseActor)); +} + +function invokeGetter(node, receiverId) { + return async ({ dispatch, client, getState }) => { + try { + const objectFront = + getParentFront(node) || + client.createObjectFront(getParentGripValue(node)); + const getterName = node.propertyName || node.name; + + const result = await objectFront.getPropertyValue(getterName, receiverId); + dispatch({ + type: "GETTER_INVOKED", + data: { + node, + result, + }, + }); + } catch (e) { + console.error(e); + } + }; +} + +module.exports = { + closeObjectInspector, + invokeGetter, + nodeExpand, + nodeCollapse, + nodeLoadProperties, + nodePropertiesLoaded, + rootsChanged, + addWatchpoint, + removeWatchpoint, +}; diff --git a/devtools/client/shared/components/object-inspector/components/ObjectInspector.css b/devtools/client/shared/components/object-inspector/components/ObjectInspector.css new file mode 100644 index 0000000000..726e2ed8b8 --- /dev/null +++ b/devtools/client/shared/components/object-inspector/components/ObjectInspector.css @@ -0,0 +1,115 @@ +/* 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/. */ + +.tree.object-inspector .node.object-node { + display: inline-block; +} + +.tree.object-inspector .object-label, +.tree.object-inspector .object-label * { + color: var(--theme-highlight-blue); +} + +.tree.object-inspector .node .unavailable { + color: var(--theme-comment); +} + +.tree.object-inspector .lessen, +.tree.object-inspector .lessen *, +.tree.object-inspector .lessen .object-label, +.tree.object-inspector .lessen .object-label * { + color: var(--theme-comment); +} + +.tree.object-inspector .block .object-label, +.tree.object-inspector .block .object-label * { + color: var(--theme-body-color); +} + +.tree.object-inspector .block .object-label::before { + content: "☲"; + font-size: 1.1em; + display: inline; + padding-inline-end: 2px; + line-height: 14px; +} + +.object-inspector .object-delimiter { + color: var(--theme-comment); + white-space: pre-wrap; +} + +.object-inspector .tree-node .arrow { + display: inline-block; + vertical-align: middle; + margin-inline-start: -1px; +} + +/* Focused styles */ +.tree.object-inspector .tree-node.focused * { + color: inherit; +} + +.tree-node.focused button.open-inspector { + fill: currentColor; +} + +.tree-node.focused button.invoke-getter { + background-color: currentColor; +} + +button[class*="remove-watchpoint-"] { + background: url("chrome://devtools/content/debugger/images/webconsole-logpoint.svg") + no-repeat; + display: inline-block; + vertical-align: top; + height: 13px; + width: 15px; + margin: 1px 4px 0px 20px; + padding: 0; + border: none; + -moz-context-properties: fill, stroke; + cursor: pointer; +} + +button.remove-watchpoint-set { + fill: var(--breakpoint-fill); + stroke: var(--breakpoint-fill); +} + +button.remove-watchpoint-get { + fill: var(--purple-60); + stroke: var(--purple-60); +} + +button.remove-watchpoint-getorset { + fill: var(--yellow-60); + stroke: var(--yellow-60); +} + +.tree-node.focused button[class*="remove-watchpoint-"] { + stroke: white; +} + +/* Don't display the light grey background we have on button hover */ +.theme-dark button[class*="remove-watchpoint-"]:hover, +.theme-light button[class*="remove-watchpoint-"]:hover { + background-color: transparent; +} + + +/* Specific style for when root nodes are displayed as header (e.g. in debugger preview popup */ +.tree.object-inspector.header-root-node { + .tree-node[aria-level="1"] { + border-block-end: 1px solid var(--theme-splitter-color); + padding-block-end: 4px; + margin-block-end: 4px; + overflow-x: clip; + word-break: keep-all; + } + + .tree-node:not([aria-level="1"]) .tree-indent:first-of-type { + width: 0; + } +} diff --git a/devtools/client/shared/components/object-inspector/components/ObjectInspector.js b/devtools/client/shared/components/object-inspector/components/ObjectInspector.js new file mode 100644 index 0000000000..c4f77d0d2a --- /dev/null +++ b/devtools/client/shared/components/object-inspector/components/ObjectInspector.js @@ -0,0 +1,387 @@ +/* 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 { + Component, + createFactory, + createElement, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + connect, + Provider, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +loader.lazyRequireGetter( + this, + "createStore", + "resource://devtools/client/shared/redux/create-store.js" +); + +const actions = require("resource://devtools/client/shared/components/object-inspector/actions.js"); +const { + getExpandedPaths, + getLoadedProperties, + getEvaluations, + default: reducer, +} = require("resource://devtools/client/shared/components/object-inspector/reducer.js"); + +const Tree = createFactory(require("resource://devtools/client/shared/components/Tree.js")); + +const ObjectInspectorItem = createFactory( + require("resource://devtools/client/shared/components/object-inspector/components/ObjectInspectorItem.js") +); + +const Utils = require("resource://devtools/client/shared/components/object-inspector/utils/index.js"); +const { renderRep, shouldRenderRootsInReps } = Utils; +const { + getChildrenWithEvaluations, + getActor, + getEvaluatedItem, + getParent, + getValue, + nodeIsPrimitive, + nodeHasGetter, + nodeHasSetter, +} = Utils.node; +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); + +// This implements a component that renders an interactive inspector +// for looking at JavaScript objects. It expects descriptions of +// objects from the protocol, and will dynamically fetch children +// properties as objects are expanded. +// +// If you want to inspect a single object, pass the name and the +// protocol descriptor of it: +// +// ObjectInspector({ +// name: "foo", +// desc: { writable: true, ..., { value: { actor: "1", ... }}}, +// ... +// }) +// +// If you want multiple top-level objects (like scopes), you can pass +// an array of manually constructed nodes as `roots`: +// +// ObjectInspector({ +// roots: [{ name: ... }, ...], +// ... +// }); + +// There are 3 types of nodes: a simple node with a children array, an +// object that has properties that should be children when they are +// fetched, and a primitive value that should be displayed with no +// children. + +class ObjectInspector extends Component { + static defaultProps = { + autoReleaseObjectActors: true + }; + constructor(props) { + super(); + this.cachedNodes = new Map(); + + const self = this; + + self.getItemChildren = this.getItemChildren.bind(this); + self.isNodeExpandable = this.isNodeExpandable.bind(this); + self.setExpanded = this.setExpanded.bind(this); + self.focusItem = this.focusItem.bind(this); + self.activateItem = this.activateItem.bind(this); + self.getRoots = this.getRoots.bind(this); + self.getNodeKey = this.getNodeKey.bind(this); + self.shouldItemUpdate = this.shouldItemUpdate.bind(this); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillMount() { + this.roots = this.props.roots; + this.focusedItem = this.props.focusedItem; + this.activeItem = this.props.activeItem; + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillUpdate(nextProps) { + this.removeOutdatedNodesFromCache(nextProps); + + if (this.roots !== nextProps.roots) { + // Since the roots changed, we assume the properties did as well, + // so we need to cleanup the component internal state. + const oldRoots = this.roots; + this.roots = nextProps.roots; + this.focusedItem = nextProps.focusedItem; + this.activeItem = nextProps.activeItem; + if (this.props.rootsChanged) { + this.props.rootsChanged(this.roots, oldRoots); + } + } + } + + removeOutdatedNodesFromCache(nextProps) { + // When the roots changes, we can wipe out everything. + if (this.roots !== nextProps.roots) { + this.cachedNodes.clear(); + return; + } + + for (const [path, properties] of nextProps.loadedProperties) { + if (properties !== this.props.loadedProperties.get(path)) { + this.cachedNodes.delete(path); + } + } + + // If there are new evaluations, we want to remove the existing cached + // nodes from the cache. + if (nextProps.evaluations > this.props.evaluations) { + for (const key of nextProps.evaluations.keys()) { + if (!this.props.evaluations.has(key)) { + this.cachedNodes.delete(key); + } + } + } + } + + shouldComponentUpdate(nextProps) { + const { expandedPaths, loadedProperties, evaluations } = this.props; + + // We should update if: + // - there are new loaded properties + // - OR there are new evaluations + // - OR the expanded paths number changed, and all of them have properties + // loaded + // - OR the expanded paths number did not changed, but old and new sets + // differ + // - OR the focused node changed. + // - OR the active node changed. + return ( + loadedProperties !== nextProps.loadedProperties || + loadedProperties.size !== nextProps.loadedProperties.size || + evaluations.size !== nextProps.evaluations.size || + (expandedPaths.size !== nextProps.expandedPaths.size && + [...nextProps.expandedPaths].every(path => + nextProps.loadedProperties.has(path) + )) || + (expandedPaths.size === nextProps.expandedPaths.size && + [...nextProps.expandedPaths].some(key => !expandedPaths.has(key))) || + this.focusedItem !== nextProps.focusedItem || + this.activeItem !== nextProps.activeItem || + this.roots !== nextProps.roots + ); + } + + componentWillUnmount() { + if (this.props.autoReleaseObjectActors){ + this.props.closeObjectInspector(this.props.roots); + } + } + + getItemChildren(item) { + const { loadedProperties, evaluations } = this.props; + const { cachedNodes } = this; + + return getChildrenWithEvaluations({ + evaluations, + loadedProperties, + cachedNodes, + item, + }); + } + + getRoots() { + const { evaluations, roots } = this.props; + const length = roots.length; + + for (let i = 0; i < length; i++) { + let rootItem = roots[i]; + + if (evaluations.has(rootItem.path)) { + roots[i] = getEvaluatedItem(rootItem, evaluations); + } + } + + return roots; + } + + getNodeKey(item) { + return item.path && typeof item.path.toString === "function" + ? item.path.toString() + : JSON.stringify(item); + } + + isNodeExpandable(item) { + if ( + nodeIsPrimitive(item) || + item.contents?.value?.useCustomFormatter + ) { + return false; + } + + if (nodeHasSetter(item) || nodeHasGetter(item)) { + return false; + } + + return true; + } + + setExpanded(item, expand) { + if ( + !this.isNodeExpandable(item) || + // Don't allow to collapse header root node + ( + this.props.displayRootNodeAsHeader && + !expand && + this.props.roots[0] == item + ) + ) { + return; + } + + const { + nodeExpand, + nodeCollapse, + recordTelemetryEvent, + setExpanded, + roots, + } = this.props; + + if (expand === true) { + const actor = getActor(item, roots); + nodeExpand(item, actor); + if (recordTelemetryEvent) { + recordTelemetryEvent("object_expanded"); + } + } else { + nodeCollapse(item); + } + + if (setExpanded) { + setExpanded(item, expand); + } + } + + focusItem(item) { + const { focusable = true, onFocus } = this.props; + + if (focusable && this.focusedItem !== item) { + this.focusedItem = item; + this.forceUpdate(); + + if (onFocus) { + onFocus(item); + } + } + } + + activateItem(item) { + const { focusable = true, onActivate } = this.props; + + if (focusable && this.activeItem !== item) { + this.activeItem = item; + this.forceUpdate(); + + if (onActivate) { + onActivate(item); + } + } + } + + shouldItemUpdate(prevItem, nextItem) { + const value = getValue(nextItem); + // Long string should always update because fullText loading will not + // trigger item re-render. + return value && value.type === "longString"; + } + + render() { + const { + autoExpandAll = true, + autoExpandDepth = 1, + initiallyExpanded, + focusable = true, + disableWrap = false, + expandedPaths, + inline, + displayRootNodeAsHeader = false, + } = this.props; + + const classNames = ["object-inspector"]; + if (inline) { + classNames.push("inline"); + } + if (disableWrap) { + classNames.push("nowrap"); + } + if (displayRootNodeAsHeader) { + classNames.push("header-root-node"); + } + + return Tree({ + className: classNames.join(" "), + + autoExpandAll, + autoExpandDepth, + initiallyExpanded, + isExpanded: item => expandedPaths && expandedPaths.has(item.path), + isExpandable: this.isNodeExpandable, + focused: this.focusedItem, + active: this.activeItem, + + getRoots: this.getRoots, + getParent, + getChildren: this.getItemChildren, + getKey: this.getNodeKey, + + onExpand: item => this.setExpanded(item, true), + onCollapse: item => this.setExpanded(item, false), + onFocus: focusable ? this.focusItem : null, + onActivate: focusable ? this.activateItem : null, + + shouldItemUpdate: this.shouldItemUpdate, + renderItem: (item, depth, focused, arrow, expanded) => + ObjectInspectorItem({ + ...this.props, + item, + depth, + focused, + arrow, + mode: displayRootNodeAsHeader && this.props.roots[0] == item ? MODE.HEADER : this.props.mode , + expanded, + setExpanded: this.setExpanded, + }), + }); + } +} + +function mapStateToProps(state, props) { + return { + expandedPaths: getExpandedPaths(state), + loadedProperties: getLoadedProperties(state), + evaluations: getEvaluations(state), + }; +} + +const OI = connect(mapStateToProps, actions)(ObjectInspector); + +module.exports = props => { + const { roots, standalone = false } = props; + + if (roots.length == 0) { + return null; + } + + if (shouldRenderRootsInReps(roots, props)) { + return renderRep(roots[0], props); + } + + const oiElement = createElement(OI, props); + + if (!standalone) { + return oiElement; + } + + const store = createStore(reducer); + return createElement(Provider, { store }, oiElement); +}; diff --git a/devtools/client/shared/components/object-inspector/components/ObjectInspectorItem.js b/devtools/client/shared/components/object-inspector/components/ObjectInspectorItem.js new file mode 100644 index 0000000000..4fce30b726 --- /dev/null +++ b/devtools/client/shared/components/object-inspector/components/ObjectInspectorItem.js @@ -0,0 +1,285 @@ +/* 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/>. */ + +const { Component } = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const isMacOS = Services.appinfo.OS === "Darwin"; + +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); + +const Utils = require("resource://devtools/client/shared/components/object-inspector/utils/index.js"); + +const { + getValue, + nodeHasAccessors, + nodeHasProperties, + nodeIsBlock, + nodeIsDefaultProperties, + nodeIsFunction, + nodeIsGetter, + nodeIsMapEntry, + nodeIsMissingArguments, + nodeIsOptimizedOut, + nodeIsPrimitive, + nodeIsPrototype, + nodeIsSetter, + nodeIsUninitializedBinding, + nodeIsUnmappedBinding, + nodeIsUnscopedBinding, + nodeIsWindow, + nodeIsLongString, + nodeHasFullText, + nodeHasGetter, + getNonPrototypeParentGripValue, +} = Utils.node; + +class ObjectInspectorItem extends Component { + static get defaultProps() { + return { + onContextMenu: () => {}, + renderItemActions: () => null, + }; + } + + // eslint-disable-next-line complexity + getLabelAndValue() { + const { item, depth, expanded, mode } = this.props; + + const label = item.name; + const isPrimitive = nodeIsPrimitive(item); + + if (nodeIsOptimizedOut(item)) { + return { + label, + value: dom.span({ className: "unavailable" }, "(optimized away)"), + }; + } + + if (nodeIsUninitializedBinding(item)) { + return { + label, + value: dom.span({ className: "unavailable" }, "(uninitialized)"), + }; + } + + if (nodeIsUnmappedBinding(item)) { + return { + label, + value: dom.span({ className: "unavailable" }, "(unmapped)"), + }; + } + + if (nodeIsUnscopedBinding(item)) { + return { + label, + value: dom.span({ className: "unavailable" }, "(unscoped)"), + }; + } + + const itemValue = getValue(item); + const unavailable = + isPrimitive && + itemValue && + itemValue.hasOwnProperty && + itemValue.hasOwnProperty("unavailable"); + + if (nodeIsMissingArguments(item) || unavailable) { + return { + label, + value: dom.span({ className: "unavailable" }, "(unavailable)"), + }; + } + + if ( + nodeIsFunction(item) && + !nodeIsGetter(item) && + !nodeIsSetter(item) && + (mode === MODE.TINY || !mode) + ) { + return { + label: Utils.renderRep(item, { + ...this.props, + functionName: label, + }), + }; + } + + if ( + nodeHasProperties(item) || + nodeHasAccessors(item) || + nodeIsMapEntry(item) || + nodeIsLongString(item) || + isPrimitive + ) { + const repProps = { ...this.props }; + if (depth > 0) { + repProps.mode = mode === MODE.LONG ? MODE.SHORT : MODE.TINY; + } + + + if (nodeIsLongString(item)) { + repProps.member = { + open: nodeHasFullText(item) && expanded, + }; + } + + if (nodeHasGetter(item)) { + const receiverGrip = getNonPrototypeParentGripValue(item); + if (receiverGrip) { + Object.assign(repProps, { + onInvokeGetterButtonClick: () => + this.props.invokeGetter(item, receiverGrip.actor), + }); + } + } + + return { + label, + value: Utils.renderRep(item, repProps), + }; + } + + return { + label, + }; + } + + getTreeItemProps() { + const { + item, + depth, + focused, + expanded, + onCmdCtrlClick, + onDoubleClick, + dimTopLevelWindow, + onContextMenu, + } = this.props; + + const classNames = ["node", "object-node"]; + if (focused) { + classNames.push("focused"); + } + + if (nodeIsBlock(item)) { + classNames.push("block"); + } + + if ( + !expanded && + (nodeIsDefaultProperties(item) || + nodeIsPrototype(item) || + nodeIsGetter(item) || + nodeIsSetter(item) || + (dimTopLevelWindow === true && nodeIsWindow(item) && depth === 0)) + ) { + classNames.push("lessen"); + } + + const parentElementProps = { + className: classNames.join(" "), + onClick: e => { + if ( + onCmdCtrlClick && + ((isMacOS && e.metaKey) || (!isMacOS && e.ctrlKey)) + ) { + onCmdCtrlClick(item, { + depth, + event: e, + focused, + expanded, + }); + e.stopPropagation(); + return; + } + + // If this click happened because the user selected some text, bail out. + // Note that if the user selected some text before and then clicks here, + // the previously selected text will be first unselected, unless the + // user clicked on the arrow itself. Indeed because the arrow is an + // image, clicking on it does not remove any existing text selection. + // So we need to also check if the arrow was clicked. + if ( + e.target && + Utils.selection.documentHasSelection(e.target.ownerDocument) && + !(e.target.matches && e.target.matches(".arrow")) + ) { + e.stopPropagation(); + } + }, + onContextMenu: e => onContextMenu(e, item), + }; + + if (onDoubleClick) { + parentElementProps.onDoubleClick = e => { + e.stopPropagation(); + onDoubleClick(item, { + depth, + focused, + expanded, + }); + }; + } + + return parentElementProps; + } + + renderLabel(label) { + if (label === null || typeof label === "undefined") { + return null; + } + + const { item, depth, focused, expanded, onLabelClick } = this.props; + return dom.span( + { + className: "object-label", + onClick: onLabelClick + ? event => { + event.stopPropagation(); + + // If the user selected text, bail out. + if ( + Utils.selection.documentHasSelection(event.target.ownerDocument) + ) { + return; + } + + onLabelClick(item, { + depth, + focused, + expanded, + setExpanded: this.props.setExpanded, + }); + } + : undefined, + }, + label + ); + } + + render() { + const { arrow, renderItemActions, item } = this.props; + + const { label, value } = this.getLabelAndValue(); + const labelElement = this.renderLabel(label); + const delimiter = + value && labelElement + ? dom.span({ className: "object-delimiter" }, ": ") + : null; + + return dom.div( + this.getTreeItemProps(), + this.props.mode === MODE.HEADER ? null : arrow, + labelElement, + delimiter, + value, + renderItemActions(item) + ); + } +} + +module.exports = ObjectInspectorItem; diff --git a/devtools/client/shared/components/object-inspector/components/moz.build b/devtools/client/shared/components/object-inspector/components/moz.build new file mode 100644 index 0000000000..a1744891f2 --- /dev/null +++ b/devtools/client/shared/components/object-inspector/components/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( + "ObjectInspector.js", + "ObjectInspectorItem.js", +) diff --git a/devtools/client/shared/components/object-inspector/index.js b/devtools/client/shared/components/object-inspector/index.js new file mode 100644 index 0000000000..34e4d30086 --- /dev/null +++ b/devtools/client/shared/components/object-inspector/index.js @@ -0,0 +1,10 @@ +/* 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/>. */ + +const ObjectInspector = require("resource://devtools/client/shared/components/object-inspector/components/ObjectInspector.js"); +const utils = require("resource://devtools/client/shared/components/object-inspector/utils/index.js"); +const reducer = require("resource://devtools/client/shared/components/object-inspector/reducer.js"); +const actions = require("resource://devtools/client/shared/components/object-inspector/actions.js"); + +module.exports = { ObjectInspector, utils, actions, reducer }; diff --git a/devtools/client/shared/components/object-inspector/moz.build b/devtools/client/shared/components/object-inspector/moz.build new file mode 100644 index 0000000000..14f9c285ba --- /dev/null +++ b/devtools/client/shared/components/object-inspector/moz.build @@ -0,0 +1,16 @@ +# -*- 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 += [ + "components", + "utils", +] + +DevToolsModules( + "actions.js", + "index.js", + "reducer.js", +) diff --git a/devtools/client/shared/components/object-inspector/reducer.js b/devtools/client/shared/components/object-inspector/reducer.js new file mode 100644 index 0000000000..aa8af2b529 --- /dev/null +++ b/devtools/client/shared/components/object-inspector/reducer.js @@ -0,0 +1,147 @@ +/* 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/>. */ + +function initialOIState(overrides) { + return { + expandedPaths: new Set(), + loadedProperties: new Map(), + evaluations: new Map(), + watchpoints: new Map(), + ...overrides, + }; +} + +function reducer(state = initialOIState(), action = {}) { + const { type, data } = action; + + const cloneState = overrides => ({ ...state, ...overrides }); + + if (type === "NODE_EXPAND") { + return cloneState({ + expandedPaths: new Set(state.expandedPaths).add(data.node.path), + }); + } + + if (type === "NODE_COLLAPSE") { + const expandedPaths = new Set(state.expandedPaths); + expandedPaths.delete(data.node.path); + return cloneState({ expandedPaths }); + } + + if (type == "SET_WATCHPOINT") { + const { watchpoint, property, path } = data; + const obj = state.loadedProperties.get(path); + + return cloneState({ + loadedProperties: new Map(state.loadedProperties).set( + path, + updateObject(obj, property, watchpoint) + ), + watchpoints: new Map(state.watchpoints).set(data.actor, data.watchpoint), + }); + } + + if (type === "REMOVE_WATCHPOINT") { + const { path, property, actor } = data; + const obj = state.loadedProperties.get(path); + const watchpoints = new Map(state.watchpoints); + watchpoints.delete(actor); + + return cloneState({ + loadedProperties: new Map(state.loadedProperties).set( + path, + updateObject(obj, property, null) + ), + watchpoints: watchpoints, + }); + } + + if (type === "NODE_PROPERTIES_LOADED") { + return cloneState({ + loadedProperties: new Map(state.loadedProperties).set( + data.node.path, + action.data.properties + ), + }); + } + + if (type === "ROOTS_CHANGED") { + return cloneState(); + } + + if (type === "GETTER_INVOKED") { + return cloneState({ + evaluations: new Map(state.evaluations).set(data.node.path, { + getterValue: + data.result && + data.result.value && + (data.result.value.throw || data.result.value.return), + }), + }); + } + + // NOTE: we clear the state on resume because otherwise the scopes pane + // would be out of date. Bug 1514760 + if (type === "RESUME" || type == "NAVIGATE") { + return initialOIState({ watchpoints: state.watchpoints }); + } + + return state; +} + +function updateObject(obj, property, watchpoint) { + return { + ...obj, + ownProperties: { + ...obj.ownProperties, + [property]: { + ...obj.ownProperties[property], + watchpoint, + }, + }, + }; +} + +function getObjectInspectorState(state) { + return state.objectInspector || state; +} + +function getExpandedPaths(state) { + return getObjectInspectorState(state).expandedPaths; +} + +function getExpandedPathKeys(state) { + return [...getExpandedPaths(state).keys()]; +} + +function getWatchpoints(state) { + return getObjectInspectorState(state).watchpoints; +} + +function getLoadedProperties(state) { + return getObjectInspectorState(state).loadedProperties; +} + +function getLoadedPropertyKeys(state) { + return [...getLoadedProperties(state).keys()]; +} + +function getEvaluations(state) { + return getObjectInspectorState(state).evaluations; +} + +const selectors = { + getWatchpoints, + getEvaluations, + getExpandedPathKeys, + getExpandedPaths, + getLoadedProperties, + getLoadedPropertyKeys, +}; + +Object.defineProperty(module.exports, "__esModule", { + value: true, +}); +module.exports = { ...selectors, initialOIState }; +module.exports.default = reducer; diff --git a/devtools/client/shared/components/object-inspector/utils/client.js b/devtools/client/shared/components/object-inspector/utils/client.js new file mode 100644 index 0000000000..eaa42be05a --- /dev/null +++ b/devtools/client/shared/components/object-inspector/utils/client.js @@ -0,0 +1,124 @@ +/* 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/>. */ + +const { + getValue, + nodeHasFullText, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); + +async function enumIndexedProperties(objectFront, start, end) { + try { + const iterator = await objectFront.enumProperties({ + ignoreNonIndexedProperties: true, + }); + const response = await iteratorSlice(iterator, start, end); + return response; + } catch (e) { + console.error("Error in enumIndexedProperties", e); + return {}; + } +} + +async function enumNonIndexedProperties(objectFront, start, end) { + try { + const iterator = await objectFront.enumProperties({ + ignoreIndexedProperties: true, + }); + const response = await iteratorSlice(iterator, start, end); + return response; + } catch (e) { + console.error("Error in enumNonIndexedProperties", e); + return {}; + } +} + +async function enumEntries(objectFront, start, end) { + try { + const iterator = await objectFront.enumEntries(); + const response = await iteratorSlice(iterator, start, end); + return response; + } catch (e) { + console.error("Error in enumEntries", e); + return {}; + } +} + +async function enumSymbols(objectFront, start, end) { + try { + const iterator = await objectFront.enumSymbols(); + const response = await iteratorSlice(iterator, start, end); + return response; + } catch (e) { + console.error("Error in enumSymbols", e); + return {}; + } +} + +async function enumPrivateProperties(objectFront, start, end) { + try { + const iterator = await objectFront.enumPrivateProperties(); + const response = await iteratorSlice(iterator, start, end); + return response; + } catch (e) { + console.error("Error in enumPrivateProperties", e); + return {}; + } +} + +async function getPrototype(objectFront) { + if (typeof objectFront.getPrototype !== "function") { + console.error("objectFront.getPrototype is not a function"); + return Promise.resolve({}); + } + return objectFront.getPrototype(); +} + +async function getFullText(longStringFront, item) { + const { initial, fullText, length } = getValue(item); + // Return fullText property if it exists so that it can be added to the + // loadedProperties map. + if (nodeHasFullText(item)) { + return { fullText }; + } + + try { + const substring = await longStringFront.substring(initial.length, length); + return { + fullText: initial + substring, + }; + } catch (e) { + console.error("LongStringFront.substring", e); + throw e; + } +} + +async function getPromiseState(objectFront) { + return objectFront.getPromiseState(); +} + +async function getProxySlots(objectFront) { + return objectFront.getProxySlots(); +} + +function iteratorSlice(iterator, start, end) { + start = start || 0; + const count = end ? end - start + 1 : iterator.count; + + if (count === 0) { + return Promise.resolve({}); + } + return iterator.slice(start, count); +} + +module.exports = { + enumEntries, + enumIndexedProperties, + enumNonIndexedProperties, + enumPrivateProperties, + enumSymbols, + getPrototype, + getFullText, + getPromiseState, + getProxySlots, +}; diff --git a/devtools/client/shared/components/object-inspector/utils/index.js b/devtools/client/shared/components/object-inspector/utils/index.js new file mode 100644 index 0000000000..13b3fd0049 --- /dev/null +++ b/devtools/client/shared/components/object-inspector/utils/index.js @@ -0,0 +1,52 @@ +/* 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/>. */ + +const client = require("resource://devtools/client/shared/components/object-inspector/utils/client.js"); +const loadProperties = require("resource://devtools/client/shared/components/object-inspector/utils/load-properties.js"); +const node = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); +const { nodeIsError, nodeIsPrimitive } = node; +const selection = require("resource://devtools/client/shared/components/object-inspector/utils/selection.js"); + +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const { + REPS: { Rep, Grip }, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +function shouldRenderRootsInReps(roots, props = {}) { + if (roots.length !== 1) { + return false; + } + + const root = roots[0]; + const name = root && root.name; + + return ( + (name === null || typeof name === "undefined") && + (nodeIsPrimitive(root) || + (root?.contents?.value?.useCustomFormatter === true && + Array.isArray(root?.contents?.value?.header)) || + (nodeIsError(root) && props?.customFormat === true)) + ); +} + +function renderRep(item, props) { + return Rep({ + ...props, + front: item.contents.front, + object: node.getValue(item), + mode: props.mode || MODE.TINY, + defaultRep: Grip, + }); +} + +module.exports = { + client, + loadProperties, + node, + renderRep, + selection, + shouldRenderRootsInReps, +}; diff --git a/devtools/client/shared/components/object-inspector/utils/load-properties.js b/devtools/client/shared/components/object-inspector/utils/load-properties.js new file mode 100644 index 0000000000..42525e54f1 --- /dev/null +++ b/devtools/client/shared/components/object-inspector/utils/load-properties.js @@ -0,0 +1,260 @@ +/* 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/>. */ + +const { + enumEntries, + enumIndexedProperties, + enumNonIndexedProperties, + enumPrivateProperties, + enumSymbols, + getPrototype, + getFullText, + getPromiseState, + getProxySlots, +} = require("resource://devtools/client/shared/components/object-inspector/utils/client.js"); + +const { + getClosestGripNode, + getClosestNonBucketNode, + getFront, + getValue, + nodeHasAccessors, + nodeHasProperties, + nodeIsBucket, + nodeIsDefaultProperties, + nodeIsEntries, + nodeIsMapEntry, + nodeIsPrimitive, + nodeIsPromise, + nodeIsProxy, + nodeNeedsNumericalBuckets, + nodeIsLongString, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); + +function loadItemProperties(item, client, loadedProperties, threadActorID) { + const gripItem = getClosestGripNode(item); + const value = getValue(gripItem); + let front = getFront(gripItem); + + if (!front && value && client && client.getFrontByID) { + front = client.getFrontByID(value.actor); + } + + const getObjectFront = function() { + if (!front) { + front = client.createObjectFront( + value, + client.getFrontByID(threadActorID) + ); + } + + return front; + }; + + const [start, end] = item.meta + ? [item.meta.startIndex, item.meta.endIndex] + : []; + + const promises = []; + + if (shouldLoadItemIndexedProperties(item, loadedProperties)) { + promises.push(enumIndexedProperties(getObjectFront(), start, end)); + } + + if (shouldLoadItemNonIndexedProperties(item, loadedProperties)) { + promises.push(enumNonIndexedProperties(getObjectFront(), start, end)); + } + + if (shouldLoadItemEntries(item, loadedProperties)) { + promises.push(enumEntries(getObjectFront(), start, end)); + } + + if (shouldLoadItemPrototype(item, loadedProperties)) { + promises.push(getPrototype(getObjectFront())); + } + + if (shouldLoadItemPrivateProperties(item, loadedProperties)) { + promises.push(enumPrivateProperties(getObjectFront(), start, end)); + } + + if (shouldLoadItemSymbols(item, loadedProperties)) { + promises.push(enumSymbols(getObjectFront(), start, end)); + } + + if (shouldLoadItemFullText(item, loadedProperties)) { + const longStringFront = front || client.createLongStringFront(value); + promises.push(getFullText(longStringFront, item)); + } + + if (shouldLoadItemPromiseState(item, loadedProperties)) { + promises.push(getPromiseState(getObjectFront())); + } + + if (shouldLoadItemProxySlots(item, loadedProperties)) { + promises.push(getProxySlots(getObjectFront())); + } + + return Promise.all(promises).then(mergeResponses); +} + +function mergeResponses(responses) { + const data = {}; + + for (const response of responses) { + if (response.hasOwnProperty("ownProperties")) { + data.ownProperties = { ...data.ownProperties, ...response.ownProperties }; + } + + if (response.privateProperties && response.privateProperties.length > 0) { + data.privateProperties = response.privateProperties; + } + + if (response.ownSymbols && response.ownSymbols.length > 0) { + data.ownSymbols = response.ownSymbols; + } + + if (response.prototype) { + data.prototype = response.prototype; + } + + if (response.fullText) { + data.fullText = response.fullText; + } + + if (response.promiseState) { + data.promiseState = response.promiseState; + } + + if (response.proxyTarget && response.proxyHandler) { + data.proxyTarget = response.proxyTarget; + data.proxyHandler = response.proxyHandler; + } + } + + return data; +} + +function shouldLoadItemIndexedProperties(item, loadedProperties = new Map()) { + const gripItem = getClosestGripNode(item); + const value = getValue(gripItem); + + return ( + value && + nodeHasProperties(gripItem) && + !loadedProperties.has(item.path) && + !nodeIsProxy(item) && + !nodeNeedsNumericalBuckets(item) && + !nodeIsEntries(getClosestNonBucketNode(item)) && + // The data is loaded when expanding the window node. + !nodeIsDefaultProperties(item) + ); +} + +function shouldLoadItemNonIndexedProperties( + item, + loadedProperties = new Map() +) { + const gripItem = getClosestGripNode(item); + const value = getValue(gripItem); + + return ( + value && + nodeHasProperties(gripItem) && + !loadedProperties.has(item.path) && + !nodeIsProxy(item) && + !nodeIsEntries(getClosestNonBucketNode(item)) && + !nodeIsBucket(item) && + // The data is loaded when expanding the window node. + !nodeIsDefaultProperties(item) + ); +} + +function shouldLoadItemEntries(item, loadedProperties = new Map()) { + const gripItem = getClosestGripNode(item); + const value = getValue(gripItem); + + return ( + value && + nodeIsEntries(getClosestNonBucketNode(item)) && + !loadedProperties.has(item.path) && + !nodeNeedsNumericalBuckets(item) + ); +} + +function shouldLoadItemPrototype(item, loadedProperties = new Map()) { + const value = getValue(item); + + return ( + value && + !loadedProperties.has(item.path) && + !nodeIsBucket(item) && + !nodeIsMapEntry(item) && + !nodeIsEntries(item) && + !nodeIsDefaultProperties(item) && + !nodeHasAccessors(item) && + !nodeIsPrimitive(item) && + !nodeIsLongString(item) && + !nodeIsProxy(item) + ); +} + +function shouldLoadItemSymbols(item, loadedProperties = new Map()) { + const value = getValue(item); + + return ( + value && + !loadedProperties.has(item.path) && + !nodeIsBucket(item) && + !nodeIsMapEntry(item) && + !nodeIsEntries(item) && + !nodeIsDefaultProperties(item) && + !nodeHasAccessors(item) && + !nodeIsPrimitive(item) && + !nodeIsLongString(item) && + !nodeIsProxy(item) + ); +} + +function shouldLoadItemPrivateProperties(item, loadedProperties = new Map()) { + const value = getValue(item); + + return ( + value && + value?.preview?.privatePropertiesLength && + !loadedProperties.has(item.path) && + !nodeIsBucket(item) && + !nodeIsMapEntry(item) && + !nodeIsEntries(item) && + !nodeIsDefaultProperties(item) && + !nodeHasAccessors(item) && + !nodeIsPrimitive(item) && + !nodeIsLongString(item) && + !nodeIsProxy(item) + ); +} + +function shouldLoadItemFullText(item, loadedProperties = new Map()) { + return !loadedProperties.has(item.path) && nodeIsLongString(item); +} + +function shouldLoadItemPromiseState(item, loadedProperties = new Map()) { + return !loadedProperties.has(item.path) && nodeIsPromise(item); +} + +function shouldLoadItemProxySlots(item, loadedProperties = new Map()) { + return !loadedProperties.has(item.path) && nodeIsProxy(item); +} + +module.exports = { + loadItemProperties, + mergeResponses, + shouldLoadItemEntries, + shouldLoadItemIndexedProperties, + shouldLoadItemNonIndexedProperties, + shouldLoadItemPrototype, + shouldLoadItemSymbols, + shouldLoadItemFullText, + shouldLoadItemPromiseState, + shouldLoadItemProxySlots, +}; diff --git a/devtools/client/shared/components/object-inspector/utils/moz.build b/devtools/client/shared/components/object-inspector/utils/moz.build new file mode 100644 index 0000000000..1301b2aca6 --- /dev/null +++ b/devtools/client/shared/components/object-inspector/utils/moz.build @@ -0,0 +1,13 @@ +# -*- 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( + "client.js", + "index.js", + "load-properties.js", + "node.js", + "selection.js", +) diff --git a/devtools/client/shared/components/object-inspector/utils/node.js b/devtools/client/shared/components/object-inspector/utils/node.js new file mode 100644 index 0000000000..1ee0255d1d --- /dev/null +++ b/devtools/client/shared/components/object-inspector/utils/node.js @@ -0,0 +1,1059 @@ +/* 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/>. */ + +const { + maybeEscapePropertyName, +} = require("resource://devtools/client/shared/components/reps/reps/rep-utils.js"); +const ArrayRep = require("resource://devtools/client/shared/components/reps/reps/array.js"); +const GripArrayRep = require("resource://devtools/client/shared/components/reps/reps/grip-array.js"); +const GripMap = require("resource://devtools/client/shared/components/reps/reps/grip-map.js"); +const GripEntryRep = require("resource://devtools/client/shared/components/reps/reps/grip-entry.js"); +const ErrorRep = require("resource://devtools/client/shared/components/reps/reps/error.js"); +const BigIntRep = require("resource://devtools/client/shared/components/reps/reps/big-int.js"); +const { + isLongString, +} = require("resource://devtools/client/shared/components/reps/reps/string.js"); + +const MAX_NUMERICAL_PROPERTIES = 100; + +const NODE_TYPES = { + BUCKET: Symbol("[n…m]"), + DEFAULT_PROPERTIES: Symbol("<default properties>"), + ENTRIES: Symbol("<entries>"), + GET: Symbol("<get>"), + GRIP: Symbol("GRIP"), + MAP_ENTRY_KEY: Symbol("<key>"), + MAP_ENTRY_VALUE: Symbol("<value>"), + PROMISE_REASON: Symbol("<reason>"), + PROMISE_STATE: Symbol("<state>"), + PROMISE_VALUE: Symbol("<value>"), + PROXY_HANDLER: Symbol("<handler>"), + PROXY_TARGET: Symbol("<target>"), + SET: Symbol("<set>"), + PROTOTYPE: Symbol("<prototype>"), + BLOCK: Symbol("☲"), + PRIMITIVE_VALUE: Symbol("<primitive value>") +}; + +let WINDOW_PROPERTIES = {}; + +if (typeof window === "object") { + WINDOW_PROPERTIES = Object.getOwnPropertyNames(window); +} + +function getType(item) { + return item.type; +} + +function getValue(item) { + if (nodeHasValue(item)) { + return item.contents.value; + } + + if (nodeHasGetterValue(item)) { + return item.contents.getterValue; + } + + if (nodeHasAccessors(item)) { + return item.contents; + } + + return undefined; +} + +function getFront(item) { + return item && item.contents && item.contents.front; +} + +function getActor(item, roots) { + const isRoot = isNodeRoot(item, roots); + const value = getValue(item); + return isRoot || !value ? null : value.actor; +} + +function isNodeRoot(item, roots) { + const gripItem = getClosestGripNode(item); + const value = getValue(gripItem); + + return ( + value && + roots.some(root => { + const rootValue = getValue(root); + return rootValue && rootValue.actor === value.actor; + }) + ); +} + +function nodeIsBucket(item) { + return getType(item) === NODE_TYPES.BUCKET; +} + +function nodeIsEntries(item) { + return getType(item) === NODE_TYPES.ENTRIES; +} + +function nodeIsMapEntry(item) { + return GripEntryRep.supportsObject(getValue(item)); +} + +function nodeHasChildren(item) { + return Array.isArray(item.contents); +} + +function nodeHasValue(item) { + return item && item.contents && item.contents.hasOwnProperty("value"); +} + +function nodeHasGetterValue(item) { + return item && item.contents && item.contents.hasOwnProperty("getterValue"); +} + +function nodeIsObject(item) { + const value = getValue(item); + return value && value.type === "object"; +} + +function nodeIsArrayLike(item) { + const value = getValue(item); + return GripArrayRep.supportsObject(value) || ArrayRep.supportsObject(value); +} + +function nodeIsFunction(item) { + const value = getValue(item); + return value && value.class === "Function"; +} + +function nodeIsOptimizedOut(item) { + const value = getValue(item); + return !nodeHasChildren(item) && value && value.optimizedOut; +} + +function nodeIsUninitializedBinding(item) { + const value = getValue(item); + return value && value.uninitialized; +} + +// Used to check if an item represents a binding that exists in a sourcemap's +// original file content, but does not match up with a binding found in the +// generated code. +function nodeIsUnmappedBinding(item) { + const value = getValue(item); + return value && value.unmapped; +} + +// Used to check if an item represents a binding that exists in the debugger's +// parser result, but does not match up with a binding returned by the +// devtools server. +function nodeIsUnscopedBinding(item) { + const value = getValue(item); + return value && value.unscoped; +} + +function nodeIsMissingArguments(item) { + const value = getValue(item); + return !nodeHasChildren(item) && value && value.missingArguments; +} + +function nodeHasProperties(item) { + return !nodeHasChildren(item) && nodeIsObject(item); +} + +function nodeIsPrimitive(item) { + return ( + nodeIsBigInt(item) || + (!nodeHasChildren(item) && + !nodeHasProperties(item) && + !nodeIsEntries(item) && + !nodeIsMapEntry(item) && + !nodeHasAccessors(item) && + !nodeIsBucket(item) && + !nodeIsLongString(item)) + ); +} + +function nodeIsDefaultProperties(item) { + return getType(item) === NODE_TYPES.DEFAULT_PROPERTIES; +} + +function isDefaultWindowProperty(name) { + return WINDOW_PROPERTIES.includes(name); +} + +function nodeIsPromise(item) { + const value = getValue(item); + if (!value) { + return false; + } + + return value.class == "Promise"; +} + +function nodeIsProxy(item) { + const value = getValue(item); + if (!value) { + return false; + } + + return value.class == "Proxy"; +} + +function nodeIsPrototype(item) { + return getType(item) === NODE_TYPES.PROTOTYPE; +} + +function nodeIsWindow(item) { + const value = getValue(item); + if (!value) { + return false; + } + + return value.class == "Window"; +} + +function nodeIsGetter(item) { + return getType(item) === NODE_TYPES.GET; +} + +function nodeIsSetter(item) { + return getType(item) === NODE_TYPES.SET; +} + +function nodeIsBlock(item) { + return getType(item) === NODE_TYPES.BLOCK; +} + +function nodeIsError(item) { + return ErrorRep.supportsObject(getValue(item)); +} + +function nodeIsLongString(item) { + return isLongString(getValue(item)); +} + +function nodeIsBigInt(item) { + return BigIntRep.supportsObject(getValue(item)); +} + +function nodeHasFullText(item) { + const value = getValue(item); + return nodeIsLongString(item) && value.hasOwnProperty("fullText"); +} + +function nodeHasGetter(item) { + const getter = getNodeGetter(item); + return getter && getter.type !== "undefined"; +} + +function nodeHasSetter(item) { + const setter = getNodeSetter(item); + return setter && setter.type !== "undefined"; +} + +function nodeHasAccessors(item) { + return nodeHasGetter(item) || nodeHasSetter(item); +} + +function nodeSupportsNumericalBucketing(item) { + // We exclude elements with entries since it's the <entries> node + // itself that can have buckets. + return ( + (nodeIsArrayLike(item) && !nodeHasEntries(item)) || + nodeIsEntries(item) || + nodeIsBucket(item) + ); +} + +function nodeHasEntries(item) { + const value = getValue(item); + if (!value) { + return false; + } + + const className = value.class; + return ( + className === "Map" || + className === "Set" || + className === "WeakMap" || + className === "WeakSet" || + className === "Storage" || + className === "URLSearchParams" || + className === "Headers" || + className === "FormData" || + className === "MIDIInputMap" || + className === "MIDIOutputMap" || + className === "HighlightRegistry" + ); +} + +function nodeNeedsNumericalBuckets(item) { + return ( + nodeSupportsNumericalBucketing(item) && + getNumericalPropertiesCount(item) > MAX_NUMERICAL_PROPERTIES + ); +} + +function makeNodesForPromiseProperties(loadedProps, item) { + const { reason, value, state } = loadedProps.promiseState; + const properties = []; + + if (state) { + properties.push( + createNode({ + parent: item, + name: "<state>", + contents: { value: state }, + type: NODE_TYPES.PROMISE_STATE, + }) + ); + } + + if (reason) { + properties.push( + createNode({ + parent: item, + name: "<reason>", + contents: { + value: reason.getGrip ? reason.getGrip() : reason, + front: reason.getGrip ? reason : null, + }, + type: NODE_TYPES.PROMISE_REASON, + }) + ); + } + + if (value) { + properties.push( + createNode({ + parent: item, + name: "<value>", + contents: { + value: value.getGrip ? value.getGrip() : value, + front: value.getGrip ? value : null, + }, + type: NODE_TYPES.PROMISE_VALUE, + }) + ); + } + + return properties; +} + +function makeNodesForProxyProperties(loadedProps, item) { + const { proxyHandler, proxyTarget } = loadedProps; + + const isProxyHandlerFront = proxyHandler && proxyHandler.getGrip; + const proxyHandlerGrip = isProxyHandlerFront + ? proxyHandler.getGrip() + : proxyHandler; + const proxyHandlerFront = isProxyHandlerFront ? proxyHandler : null; + + const isProxyTargetFront = proxyTarget && proxyTarget.getGrip; + const proxyTargetGrip = isProxyTargetFront + ? proxyTarget.getGrip() + : proxyTarget; + const proxyTargetFront = isProxyTargetFront ? proxyTarget : null; + + return [ + createNode({ + parent: item, + name: "<target>", + contents: { value: proxyTargetGrip, front: proxyTargetFront }, + type: NODE_TYPES.PROXY_TARGET, + }), + createNode({ + parent: item, + name: "<handler>", + contents: { value: proxyHandlerGrip, front: proxyHandlerFront }, + type: NODE_TYPES.PROXY_HANDLER, + }), + ]; +} + +function makeNodesForEntries(item) { + const nodeName = "<entries>"; + + return createNode({ + parent: item, + name: nodeName, + contents: null, + type: NODE_TYPES.ENTRIES, + }); +} + +function makeNodeForPrimitiveValue(parent, value) { + const nodeName = "<primitive value>"; + + return createNode({ + parent, + name: nodeName, + contents: {value}, + type: NODE_TYPES.PRIMITIVE_VALUE, + }); +} + +function makeNodesForMapEntry(item) { + const nodeValue = getValue(item); + if (!nodeValue || !nodeValue.preview) { + return []; + } + + const { key, value } = nodeValue.preview; + const isKeyFront = key && key.getGrip; + const keyGrip = isKeyFront ? key.getGrip() : key; + const keyFront = isKeyFront ? key : null; + + const isValueFront = value && value.getGrip; + const valueGrip = isValueFront ? value.getGrip() : value; + const valueFront = isValueFront ? value : null; + + return [ + createNode({ + parent: item, + name: "<key>", + contents: { value: keyGrip, front: keyFront }, + type: NODE_TYPES.MAP_ENTRY_KEY, + }), + createNode({ + parent: item, + name: "<value>", + contents: { value: valueGrip, front: valueFront }, + type: NODE_TYPES.MAP_ENTRY_VALUE, + }), + ]; +} + +function getNodeGetter(item) { + return item && item.contents ? item.contents.get : undefined; +} + +function getNodeSetter(item) { + return item && item.contents ? item.contents.set : undefined; +} + +function sortProperties(properties) { + return properties.sort((a, b) => { + // Sort numbers in ascending order and sort strings lexicographically + const aInt = parseInt(a, 10); + const bInt = parseInt(b, 10); + + if (isNaN(aInt) || isNaN(bInt)) { + return a > b ? 1 : -1; + } + + return aInt - bInt; + }); +} + +function makeNumericalBuckets(parent) { + const numProperties = getNumericalPropertiesCount(parent); + + // We want to have at most a hundred slices. + const bucketSize = + 10 ** Math.max(2, Math.ceil(Math.log10(numProperties)) - 2); + const numBuckets = Math.ceil(numProperties / bucketSize); + + const buckets = []; + for (let i = 1; i <= numBuckets; i++) { + const minKey = (i - 1) * bucketSize; + const maxKey = Math.min(i * bucketSize - 1, numProperties - 1); + const startIndex = nodeIsBucket(parent) ? parent.meta.startIndex : 0; + const minIndex = startIndex + minKey; + const maxIndex = startIndex + maxKey; + const bucketName = `[${minIndex}…${maxIndex}]`; + + buckets.push( + createNode({ + parent, + name: bucketName, + contents: null, + type: NODE_TYPES.BUCKET, + meta: { + startIndex: minIndex, + endIndex: maxIndex, + }, + }) + ); + } + return buckets; +} + +function makeDefaultPropsBucket(propertiesNames, parent, ownProperties) { + const userPropertiesNames = []; + const defaultProperties = []; + + propertiesNames.forEach(name => { + if (isDefaultWindowProperty(name)) { + defaultProperties.push(name); + } else { + userPropertiesNames.push(name); + } + }); + + const nodes = makeNodesForOwnProps( + userPropertiesNames, + parent, + ownProperties + ); + + if (defaultProperties.length > 0) { + const defaultPropertiesNode = createNode({ + parent, + name: "<default properties>", + contents: null, + type: NODE_TYPES.DEFAULT_PROPERTIES, + }); + + const defaultNodes = makeNodesForOwnProps( + defaultProperties, + defaultPropertiesNode, + ownProperties + ); + nodes.push(setNodeChildren(defaultPropertiesNode, defaultNodes)); + } + return nodes; +} + +function makeNodesForOwnProps(propertiesNames, parent, ownProperties) { + return propertiesNames.map(name => { + const property = ownProperties[name]; + + let propertyValue = property; + if (property && property.hasOwnProperty("getterValue")) { + propertyValue = property.getterValue; + } else if (property && property.hasOwnProperty("value")) { + propertyValue = property.value; + } + + // propertyValue can be a front (LongString or Object) or a primitive grip. + const isFront = propertyValue && propertyValue.getGrip; + const front = isFront ? propertyValue : null; + const grip = isFront ? front.getGrip() : propertyValue; + + return createNode({ + parent, + name: maybeEscapePropertyName(name), + propertyName: name, + contents: { + ...(property || {}), + value: grip, + front, + }, + }); + }); +} + +function makeNodesForProperties(objProps, parent) { + const { + ownProperties = {}, + ownSymbols, + privateProperties, + prototype, + safeGetterValues, + } = objProps; + + const parentValue = getValue(parent); + const allProperties = { ...ownProperties, ...safeGetterValues }; + + // Ignore properties that are neither non-concrete nor getters/setters. + const propertiesNames = sortProperties(Object.keys(allProperties)).filter( + name => { + if (!allProperties[name]) { + return false; + } + + const properties = Object.getOwnPropertyNames(allProperties[name]); + return properties.some(property => + ["value", "getterValue", "get", "set"].includes(property) + ); + } + ); + + const isParentNodeWindow = parentValue && parentValue.class == "Window"; + const nodes = isParentNodeWindow + ? makeDefaultPropsBucket(propertiesNames, parent, allProperties) + : makeNodesForOwnProps(propertiesNames, parent, allProperties); + + if (Array.isArray(ownSymbols)) { + ownSymbols.forEach((ownSymbol, index) => { + const descriptorValue = ownSymbol?.descriptor?.value; + const hasGrip = descriptorValue?.getGrip; + const symbolGrip = hasGrip ? descriptorValue.getGrip() : descriptorValue; + const symbolFront = hasGrip ? ownSymbol.descriptor.value : null; + + nodes.push( + createNode({ + parent, + name: ownSymbol.name, + path: `symbol-${index}`, + contents: { + value: symbolGrip, + front: symbolFront, + }, + }) + ); + }, this); + } + + if (Array.isArray(privateProperties)) { + privateProperties.forEach((privateProperty, index) => { + const descriptorValue = privateProperty?.descriptor?.value; + const hasGrip = descriptorValue?.getGrip; + const privatePropertyGrip = hasGrip + ? descriptorValue.getGrip() + : descriptorValue; + const privatePropertyFront = hasGrip + ? privateProperty.descriptor.value + : null; + + nodes.push( + createNode({ + parent, + name: privateProperty.name, + path: `private-${index}`, + contents: { + value: privatePropertyGrip, + front: privatePropertyFront, + }, + }) + ); + }, this); + } + + if (nodeIsPromise(parent)) { + nodes.push(...makeNodesForPromiseProperties(objProps, parent)); + } + + if (nodeHasEntries(parent)) { + nodes.push(makeNodesForEntries(parent)); + } + + // Add accessor nodes if needed + const defaultPropertiesNode = isParentNodeWindow + ? nodes.find(node => nodeIsDefaultProperties(node)) + : null; + + for (const name of propertiesNames) { + const property = allProperties[name]; + const isDefaultProperty = + isParentNodeWindow && + defaultPropertiesNode && + isDefaultWindowProperty(name); + const parentNode = isDefaultProperty ? defaultPropertiesNode : parent; + const parentContentsArray = + isDefaultProperty && defaultPropertiesNode + ? defaultPropertiesNode.contents + : nodes; + + if (property.get && property.get.type !== "undefined") { + parentContentsArray.push( + createGetterNode({ + parent: parentNode, + property, + name, + }) + ); + } + + if (property.set && property.set.type !== "undefined") { + parentContentsArray.push( + createSetterNode({ + parent: parentNode, + property, + name, + }) + ); + } + } + + const preview = parentValue?.preview; + + if (preview && Object.hasOwn(preview, 'wrappedValue')) { + const primitiveValue = preview.wrappedValue + nodes.push(makeNodeForPrimitiveValue(parentValue, primitiveValue)) + } + + // Add the prototype if it exists and is not null + if (prototype && prototype.type !== "null") { + nodes.push(makeNodeForPrototype(objProps, parent)); + } + + return nodes; +} + +function setNodeFullText(loadedProps, node) { + if (nodeHasFullText(node) || !nodeIsLongString(node)) { + return node; + } + + const { fullText } = loadedProps; + if (nodeHasValue(node)) { + node.contents.value.fullText = fullText; + } else if (nodeHasGetterValue(node)) { + node.contents.getterValue.fullText = fullText; + } + + return node; +} + +function makeNodeForPrototype(objProps, parent) { + const { prototype } = objProps || {}; + + // Add the prototype if it exists and is not null + if (prototype && prototype.type !== "null") { + return createNode({ + parent, + name: "<prototype>", + contents: { + value: prototype.getGrip ? prototype.getGrip() : prototype, + front: prototype.getGrip ? prototype : null, + }, + type: NODE_TYPES.PROTOTYPE, + }); + } + + return null; +} + +function createNode(options) { + const { + parent, + name, + propertyName, + path, + contents, + type = NODE_TYPES.GRIP, + meta, + } = options; + + if (contents === undefined) { + return null; + } + + // The path is important to uniquely identify the item in the entire + // tree. This helps debugging & optimizes React's rendering of large + // lists. The path will be separated by property name. + + return { + parent, + name, + // `name` can be escaped; propertyName contains the original property name. + propertyName, + path: createPath(parent && parent.path, path || name), + contents, + type, + meta, + }; +} + +function createGetterNode({ parent, property, name }) { + const isFront = property.get && property.get.getGrip; + const grip = isFront ? property.get.getGrip() : property.get; + const front = isFront ? property.get : null; + + return createNode({ + parent, + name: `<get ${name}()>`, + contents: { value: grip, front }, + type: NODE_TYPES.GET, + }); +} + +function createSetterNode({ parent, property, name }) { + const isFront = property.set && property.set.getGrip; + const grip = isFront ? property.set.getGrip() : property.set; + const front = isFront ? property.set : null; + + return createNode({ + parent, + name: `<set ${name}()>`, + contents: { value: grip, front }, + type: NODE_TYPES.SET, + }); +} + +function setNodeChildren(node, children) { + node.contents = children; + return node; +} + +function getEvaluatedItem(item, evaluations) { + if (!evaluations.has(item.path)) { + return item; + } + + const evaluation = evaluations.get(item.path); + const isFront = + evaluation && evaluation.getterValue && evaluation.getterValue.getGrip; + + const contents = isFront + ? { + getterValue: evaluation.getterValue.getGrip(), + front: evaluation.getterValue, + } + : evaluations.get(item.path); + + return { + ...item, + contents, + }; +} + +function getChildrenWithEvaluations(options) { + const { item, loadedProperties, cachedNodes, evaluations } = options; + + const children = getChildren({ + loadedProperties, + cachedNodes, + item, + }); + + if (Array.isArray(children)) { + return children.map(i => getEvaluatedItem(i, evaluations)); + } + + if (children) { + return getEvaluatedItem(children, evaluations); + } + + return []; +} + +function getChildren(options) { + const { cachedNodes, item, loadedProperties = new Map() } = options; + + const key = item.path; + if (cachedNodes && cachedNodes.has(key)) { + return cachedNodes.get(key); + } + + const loadedProps = loadedProperties.get(key); + const hasLoadedProps = loadedProperties.has(key); + + // Because we are dynamically creating the tree as the user + // expands it (not precalculated tree structure), we cache child + // arrays. This not only helps performance, but is necessary + // because the expanded state depends on instances of nodes + // being the same across renders. If we didn't do this, each + // node would be a new instance every render. + // If the node needs properties, we only add children to + // the cache if the properties are loaded. + const addToCache = children => { + if (cachedNodes) { + cachedNodes.set(item.path, children); + } + return children; + }; + + // Nodes can either have children already, or be an object with + // properties that we need to go and fetch. + if (nodeHasChildren(item)) { + return addToCache(item.contents); + } + + if (nodeIsMapEntry(item)) { + return addToCache(makeNodesForMapEntry(item)); + } + + if (nodeIsProxy(item) && hasLoadedProps) { + return addToCache(makeNodesForProxyProperties(loadedProps, item)); + } + + if (nodeIsLongString(item) && hasLoadedProps) { + // Set longString object's fullText to fetched one. + return addToCache(setNodeFullText(loadedProps, item)); + } + + if (nodeNeedsNumericalBuckets(item) && hasLoadedProps) { + // Even if we have numerical buckets, we should have loaded non indexed + // properties. + const bucketNodes = makeNumericalBuckets(item); + return addToCache( + bucketNodes.concat(makeNodesForProperties(loadedProps, item)) + ); + } + + if (!nodeIsEntries(item) && !nodeIsBucket(item) && !nodeHasProperties(item)) { + return []; + } + + if (!hasLoadedProps) { + return []; + } + + return addToCache(makeNodesForProperties(loadedProps, item)); +} + +// Builds an expression that resolves to the value of the item in question +// e.g. `b` in { a: { b: 2 } } resolves to `a.b` +function getPathExpression(item) { + if (item && item.parent) { + const parent = nodeIsBucket(item.parent) ? item.parent.parent : item.parent; + return `${getPathExpression(parent)}.${item.name}`; + } + + return item.name; +} + +function getParent(item) { + return item.parent; +} + +function getNumericalPropertiesCount(item) { + if (nodeIsBucket(item)) { + return item.meta.endIndex - item.meta.startIndex + 1; + } + + const value = getValue(getClosestGripNode(item)); + if (!value) { + return 0; + } + + if (GripArrayRep.supportsObject(value)) { + return GripArrayRep.getLength(value); + } + + if (GripMap.supportsObject(value)) { + return GripMap.getLength(value); + } + + // TODO: We can also have numerical properties on Objects, but at the + // moment we don't have a way to distinguish them from non-indexed properties, + // as they are all computed in a ownPropertiesLength property. + + return 0; +} + +function getClosestGripNode(item) { + const type = getType(item); + if ( + type !== NODE_TYPES.BUCKET && + type !== NODE_TYPES.DEFAULT_PROPERTIES && + type !== NODE_TYPES.ENTRIES + ) { + return item; + } + + const parent = getParent(item); + if (!parent) { + return null; + } + + return getClosestGripNode(parent); +} + +function getClosestNonBucketNode(item) { + const type = getType(item); + + if (type !== NODE_TYPES.BUCKET) { + return item; + } + + const parent = getParent(item); + if (!parent) { + return null; + } + + return getClosestNonBucketNode(parent); +} + +function getParentGripNode(item) { + const parentNode = getParent(item); + if (!parentNode) { + return null; + } + + return getClosestGripNode(parentNode); +} + +function getParentGripValue(item) { + const parentGripNode = getParentGripNode(item); + if (!parentGripNode) { + return null; + } + + return getValue(parentGripNode); +} + +function getParentFront(item) { + const parentGripNode = getParentGripNode(item); + if (!parentGripNode) { + return null; + } + + return getFront(parentGripNode); +} + +function getNonPrototypeParentGripValue(item) { + const parentGripNode = getParentGripNode(item); + if (!parentGripNode) { + return null; + } + + if (getType(parentGripNode) === NODE_TYPES.PROTOTYPE) { + return getNonPrototypeParentGripValue(parentGripNode); + } + + return getValue(parentGripNode); +} + +function createPath(parentPath, path) { + return parentPath ? `${parentPath}◦${path}` : path; +} + +module.exports = { + createNode, + createGetterNode, + createSetterNode, + getActor, + getChildren, + getChildrenWithEvaluations, + getClosestGripNode, + getClosestNonBucketNode, + getEvaluatedItem, + getFront, + getPathExpression, + getParent, + getParentFront, + getParentGripValue, + getNonPrototypeParentGripValue, + getNumericalPropertiesCount, + getValue, + makeNodesForEntries, + makeNodesForPromiseProperties, + makeNodesForProperties, + makeNumericalBuckets, + nodeHasAccessors, + nodeHasChildren, + nodeHasEntries, + nodeHasProperties, + nodeHasGetter, + nodeHasSetter, + nodeIsBlock, + nodeIsBucket, + nodeIsDefaultProperties, + nodeIsEntries, + nodeIsError, + nodeIsLongString, + nodeHasFullText, + nodeIsFunction, + nodeIsGetter, + nodeIsMapEntry, + nodeIsMissingArguments, + nodeIsObject, + nodeIsOptimizedOut, + nodeIsPrimitive, + nodeIsPromise, + nodeIsPrototype, + nodeIsProxy, + nodeIsSetter, + nodeIsUninitializedBinding, + nodeIsUnmappedBinding, + nodeIsUnscopedBinding, + nodeIsWindow, + nodeNeedsNumericalBuckets, + nodeSupportsNumericalBucketing, + setNodeChildren, + sortProperties, + NODE_TYPES, +}; diff --git a/devtools/client/shared/components/object-inspector/utils/selection.js b/devtools/client/shared/components/object-inspector/utils/selection.js new file mode 100644 index 0000000000..fdcca7ff6b --- /dev/null +++ b/devtools/client/shared/components/object-inspector/utils/selection.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +function documentHasSelection(doc = document) { + const selection = doc.defaultView.getSelection(); + if (!selection) { + return false; + } + + return selection.type === "Range"; +} + +module.exports = { + documentHasSelection, +}; diff --git a/devtools/client/shared/components/reps/images/input.svg b/devtools/client/shared/components/reps/images/input.svg new file mode 100644 index 0000000000..830b651e9e --- /dev/null +++ b/devtools/client/shared/components/reps/images/input.svg @@ -0,0 +1,7 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="context-fill #0b0b0b"> + <path d="M11.04 5.46L7.29 1.71a.75.75 0 0 0-1.06 1.06L9.45 6 6.23 9.21a.75.75 0 1 0 1.06 1.06l3.75-3.75c.3-.3.3-.77 0-1.06z"/> + <path d="M6.04 5.46L2.29 1.71a.75.75 0 0 0-1.06 1.06L4.45 6 1.23 9.21a.75.75 0 1 0 1.06 1.06l3.75-3.75c.3-.3.3-.77 0-1.06z"/> +</svg>
\ No newline at end of file diff --git a/devtools/client/shared/components/reps/images/jump-definition.svg b/devtools/client/shared/components/reps/images/jump-definition.svg new file mode 100644 index 0000000000..9ac071523d --- /dev/null +++ b/devtools/client/shared/components/reps/images/jump-definition.svg @@ -0,0 +1,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/. --> +<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" stroke="context-stroke" fill="none" stroke-linecap="round"> + <path d="M5.5 3.5l2 2M5.5 7.5l2-2"/> + <path d="M7 5.5H4.006c-1.012 0-1.995 1.017-2.011 2.024-.005.023-.005 1.347 0 3.971" stroke-linejoin="round"/> + <path d="M10.5 5.5h4M9.5 3.5h5M9.5 7.5h5"/> +</svg>
\ No newline at end of file diff --git a/devtools/client/shared/components/reps/images/open-a11y.svg b/devtools/client/shared/components/reps/images/open-a11y.svg new file mode 100644 index 0000000000..cba2ab93c9 --- /dev/null +++ b/devtools/client/shared/components/reps/images/open-a11y.svg @@ -0,0 +1,10 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15" fill="context-fill #0C0C0D"> + <path d="M9.5 2.5C9.5 3.60457 8.60457 4.5 7.5 4.5C6.39543 4.5 5.5 3.60457 5.5 2.5C5.5 1.39543 6.39543 0.5 7.5 0.5C8.60457 0.5 9.5 1.39543 9.5 2.5Z"/> + <path d="M1.5 6C1.5 5.44772 1.94772 5 2.5 5H12.5C13.0523 5 13.5 5.44772 13.5 6C13.5 6.55228 13.0523 7 12.5 7H2.5C1.94772 7 1.5 6.55228 1.5 6Z"/> + <path d="M6 5C6.55228 5 7 5.44772 7 6L7 13C7 13.5523 6.55228 14 6 14C5.44771 14 5 13.5523 5 13L5 6C5 5.44772 5.44772 5 6 5Z"/> + <path d="M9 5C9.55228 5 10 5.44772 10 6V13C10 13.5523 9.55228 14 9 14C8.44771 14 8 13.5523 8 13L8 6C8 5.44772 8.44772 5 9 5Z"/> + <path d="M5 7H10V10.03H5V7Z"/> +</svg> diff --git a/devtools/client/shared/components/reps/images/open-inspector.svg b/devtools/client/shared/components/reps/images/open-inspector.svg new file mode 100644 index 0000000000..9e8a277e7c --- /dev/null +++ b/devtools/client/shared/components/reps/images/open-inspector.svg @@ -0,0 +1,6 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15" fill="context-fill"> + <path d="M7 3H5a2 2 0 0 0-2 2v2H1.5a.5.5 0 0 0 0 1H3v2c0 1.1.9 2 2 2h2v1.5a.5.5 0 0 0 1 0V12h2a2 2 0 0 0 2-2V8h1.5a.5.5 0 0 0 0-1H12V5a2 2 0 0 0-2-2H8V1.5a.5.5 0 0 0-1 0V3zM5 5h5v5H5V5z"/> +</svg> diff --git a/devtools/client/shared/components/reps/index.js b/devtools/client/shared/components/reps/index.js new file mode 100644 index 0000000000..e99e642c38 --- /dev/null +++ b/devtools/client/shared/components/reps/index.js @@ -0,0 +1,32 @@ +/* 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 { + MODE, +} = require("devtools/client/shared/components/reps/reps/constants"); +const { + REPS, + getRep, +} = require("devtools/client/shared/components/reps/reps/rep"); +const objectInspector = require("devtools/client/shared/components/object-inspector/index"); + +const { + parseURLEncodedText, + parseURLParams, + maybeEscapePropertyName, + getGripPreviewItems, +} = require("devtools/client/shared/components/reps/reps/rep-utils"); + +module.exports = { + REPS, + getRep, + MODE, + maybeEscapePropertyName, + parseURLEncodedText, + parseURLParams, + getGripPreviewItems, + objectInspector, +}; diff --git a/devtools/client/shared/components/reps/moz.build b/devtools/client/shared/components/reps/moz.build new file mode 100644 index 0000000000..058e8046a7 --- /dev/null +++ b/devtools/client/shared/components/reps/moz.build @@ -0,0 +1,14 @@ +# -*- 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 += [ + "reps", + "shared", +] + +DevToolsModules( + "index.js", +) diff --git a/devtools/client/shared/components/reps/reps.css b/devtools/client/shared/components/reps/reps.css new file mode 100644 index 0000000000..68eee2e78b --- /dev/null +++ b/devtools/client/shared/components/reps/reps.css @@ -0,0 +1,400 @@ +/* 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/. */ + +.theme-dark, +.theme-light { + --number-color: var(--theme-highlight-green); + --string-color: var(--theme-highlight-red); + --null-color: var(--theme-comment); + --object-color: var(--theme-highlight-blue); + --caption-color: var(--theme-highlight-blue); + --location-color: var(--theme-comment); + --source-link-color: var(--theme-link-color); + --node-color: var(--theme-highlight-purple); + --reference-color: var(--theme-highlight-blue); + --comment-node-color: var(--theme-comment); +} + +/******************************************************************************/ + +.inline { + display: inline; + white-space: normal; +} + +.objectBox-object { + font-weight: bold; + color: var(--object-color); + white-space: pre-wrap; +} + +.objectBox-string, +.objectBox-symbol, +.objectBox-text, +.objectBox-textNode, +.objectBox-table { + white-space: pre-wrap; +} + +:is( + .objectBox-string, + .objectBox-textNode, + .objectBox > .nodeName, + .objectBox-node .tag-name, + .objectBox-node .attrName +).has-rtl-char { + unicode-bidi: isolate; +} + +.objectBox-number, +.objectBox-styleRule, +.objectBox-element, +.objectBox-textNode, +.objectBox-array > .length { + color: var(--number-color); +} + +.objectBox-textNode, +.objectBox-string, +.objectBox-symbol { + color: var(--string-color); +} + +.objectBox-empty-string { + font-style: italic; +} + +.objectBox-string a { + word-break: break-all; +} + +.objectBox-string a, +.objectBox-string a:visited { + color: currentColor; + text-decoration: underline; + text-decoration-skip-ink: none; + font-style: italic; + cursor: pointer; +} + +/* Visually hide the middle of "cropped" url */ +.objectBox-string a .cropped-url-middle { + max-width: 0; + max-height: 0; + display: inline-block; + overflow: hidden; + vertical-align: bottom; +} + +.objectBox-string a .cropped-url-end::before { + content: "…"; +} + + +.objectBox-function, +.objectBox-profile { + color: var(--object-color); +} + +.objectBox-stackTrace.reps-custom-format, +.objectBox-stackTrace.reps-custom-format > .objectBox-string { + color: var(--error-color); +} + +.objectBox-stackTrace-grid { + display: inline-grid; + grid-template-columns: auto auto; + margin-top: 3px; +} + +.objectBox-stackTrace-fn { + color: var(--console-output-color); + padding-inline-start: 17px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-inline-end: 5px; +} + +.objectBox-stackTrace-location { + color: var(--frame-link-source, currentColor); + direction: rtl; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: end; +} + +.objectBox-stackTrace-location:hover { + text-decoration: underline; +} + +.objectBox-stackTrace-location { + cursor: pointer; +} + +.objectBox-Location, +.location { + color: var(--location-color); +} + +.objectBox-null, +.objectBox-undefined, +.objectBox-hint, +.objectBox-nan, +.logRowHint { + color: var(--null-color); +} + +.objectBox-sourceLink { + position: absolute; + right: 4px; + top: 2px; + padding-left: 8px; + font-weight: bold; + color: var(--source-link-color); +} + +.objectBox-failure { + color: var(--string-color); + border-width: 1px; + border-style: solid; + border-radius: 2px; + font-size: 0.8em; + padding: 0 2px; +} + +.objectBox-accessible.clickable, +.objectBox-node.clickable { + cursor: pointer; +} + +/* JsonML reps can be nested, though only the top-level rep needs layout + * adjustments to align it with the toggle arrow and fit its width to its + * contents. */ +.objectBox-jsonml-wrapper { + display: inline-flex; + flex-direction: column; + width: fit-content; + word-break: break-word; + line-height: normal; +} + +.objectBox-jsonml-wrapper[data-expandable="true"] { + cursor: default; +} + +.objectBox-jsonml-wrapper .jsonml-header-collapse-button { + margin: 0 4px 2px 0; + padding: 0; + vertical-align: middle; +} + +.objectBox-jsonml-wrapper .jsonml-header-collapse-button::before { + content: ""; + display: block; + width: 10px; + height: 10px; + background: url("chrome://devtools/skin/images/arrow.svg") no-repeat center; + background-size: 10px; + transform: rotate(-90deg); + transition: transform 125ms ease; + -moz-context-properties: fill; + fill: var(--theme-icon-dimmed-color); +} + +.objectBox-jsonml-wrapper .jsonml-header-collapse-button[aria-expanded="true"]::before { + transform: rotate(0deg); +} + +/******************************************************************************/ + +.objectBox-event, +.objectBox-eventLog, +.objectBox-regexp, +.objectBox-object { + color: var(--object-color); + white-space: pre-wrap; +} + +.objectBox .Date { + color: var(--string-color); + white-space: pre-wrap; +} + +/******************************************************************************/ + +.objectBox.theme-comment { + color: var(--comment-node-color); +} + +.accessible-role, +.tag-name { + color: var(--object-color); +} + +.attrName { + color: var(--string-color); +} + +.attrEqual, +.objectEqual { + color: var(--comment-node-color); +} + +.attrValue, +.attrValue.objectBox-string { + color: var(--node-color); +} + +.angleBracket { + color: var(--theme-body-color); +} + +/******************************************************************************/ +/* Length bubble for arraylikes and maplikes */ + +.objectLengthBubble { + color: var(--null-color); +} + +/******************************************************************************/ + +.objectLeftBrace, +.objectRightBrace, +.arrayLeftBracket, +.arrayRightBracket { + color: var(--object-color); +} + +/******************************************************************************/ +/* Cycle reference */ + +.objectBox-Reference { + font-weight: bold; + color: var(--reference-color); +} + +[class*="objectBox"] > .objectTitle { + color: var(--object-color); +} + +.caption { + color: var(--caption-color); +} + +/******************************************************************************/ +/* Themes */ + +.theme-dark .objectBox-null, +.theme-dark .objectBox-undefined, +.theme-light .objectBox-null, +.theme-light .objectBox-undefined { + font-style: normal; +} + +.theme-dark .objectBox-object, +.theme-light .objectBox-object { + font-weight: normal; + white-space: pre-wrap; +} + +.theme-dark .caption, +.theme-light .caption { + font-weight: normal; +} + +/******************************************************************************/ +/* Open DOMNode in inspector or Accessible in accessibility inspector button */ + +:is(button, [role="button"]).open-accessibility-inspector { + background: url("chrome://devtools/content/shared/components/reps/images/open-a11y.svg") + no-repeat; +} + +:is(button, [role="button"]).open-inspector { + background: url("chrome://devtools/content/shared/components/reps/images/open-inspector.svg") + no-repeat; +} + +:is(button, [role="button"]).highlight-node { + background: url("chrome://devtools/skin/images/highlight-selector.svg") + no-repeat; +} + + +:is(button, [role="button"]):is(.open-accessibility-inspector, .open-inspector, .highlight-node) { + display: inline-block; + vertical-align: top; + height: 15px; + width: 15px; + margin: 0 4px; + padding: 0; + border: none; + fill: var(--theme-icon-color); + cursor: pointer; + -moz-context-properties: fill; +} + +.objectBox-accessible:hover .open-accessibility-inspector, +.objectBox-node:hover .open-inspector, +.objectBox-textNode:hover .open-inspector, +.open-accessibility-inspector:hover, +.highlight-node:hover, +.open-inspector:hover { + fill: var(--theme-icon-checked-color); +} + +/******************************************************************************/ +/* Jump to definition button */ + +button.jump-definition { + display: inline-block; + height: 16px; + margin-left: 0.25em; + vertical-align: middle; + background: 0% 50% + url("chrome://devtools/content/shared/components/reps/images/jump-definition.svg") + no-repeat; + border-color: transparent; + stroke: var(--theme-icon-color); + -moz-context-properties: stroke; + cursor: pointer; +} + +.jump-definition:hover { + stroke: var(--theme-icon-checked-color); +} + +.tree-node.focused .jump-definition { + stroke: currentColor; +} + +/******************************************************************************/ +/* Invoke getter button */ + +button.invoke-getter { + mask: url(chrome://devtools/content/shared/components/reps/images/input.svg) + no-repeat; + display: inline-block; + background-color: var(--theme-icon-color); + height: 10px; + vertical-align: middle; + border: none; +} + +.invoke-getter:hover { + background-color: var(--theme-icon-checked-color); +} + +/******************************************************************************/ +/* "more…" ellipsis */ +.more-ellipsis { + color: var(--comment-node-color); +} + +/* function parameters */ +.objectBox-function .param { + color: var(--theme-highlight-red); +} diff --git a/devtools/client/shared/components/reps/reps/accessible.js b/devtools/client/shared/components/reps/reps/accessible.js new file mode 100644 index 0000000000..796a850161 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/accessible.js @@ -0,0 +1,197 @@ +/* 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"; +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { + button, + span, + } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Utils + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + rep: StringRep, + } = require("devtools/client/shared/components/reps/reps/string"); + + /** + * Renders Accessible object. + */ + + Accessible.propTypes = { + object: PropTypes.object.isRequired, + inspectIconTitle: PropTypes.string, + nameMaxLength: PropTypes.number, + onAccessibleClick: PropTypes.func, + onAccessibleMouseOver: PropTypes.func, + onAccessibleMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + roleFirst: PropTypes.bool, + separatorText: PropTypes.string, + shouldRenderTooltip: PropTypes.bool, + }; + + function Accessible(props) { + const { + object, + inspectIconTitle, + nameMaxLength, + onAccessibleClick, + onInspectIconClick, + roleFirst, + separatorText, + } = props; + + const isInTree = object.preview && object.preview.isConnected === true; + + const config = getElementConfig({ ...props, isInTree }); + const elements = getElements( + object, + nameMaxLength, + roleFirst, + separatorText + ); + const inspectIcon = getInspectIcon({ + object, + onInspectIconClick, + inspectIconTitle, + onAccessibleClick, + isInTree, + }); + + return span(config, ...elements, inspectIcon); + } + + // Get React Config Obj + function getElementConfig(opts) { + const { + object, + isInTree, + onAccessibleClick, + onAccessibleMouseOver, + onAccessibleMouseOut, + shouldRenderTooltip, + roleFirst, + } = opts; + const { name, role } = object.preview; + + // Initiate config + const config = { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-accessible", + }; + + if (isInTree) { + if (onAccessibleClick) { + Object.assign(config, { + onClick: _ => onAccessibleClick(object), + className: `${config.className} clickable`, + }); + } + + if (onAccessibleMouseOver) { + Object.assign(config, { + onMouseOver: _ => onAccessibleMouseOver(object), + }); + } + + if (onAccessibleMouseOut) { + Object.assign(config, { + onMouseOut: onAccessibleMouseOut, + }); + } + } + + // If tooltip, build tooltip + if (shouldRenderTooltip) { + let tooltip; + if (!name) { + tooltip = role; + } else { + const quotedName = `"${name}"`; + tooltip = `${roleFirst ? role : quotedName}: ${ + roleFirst ? quotedName : role + }`; + } + + config.title = tooltip; + } + + // Return config obj + return config; + } + + // Get Content Elements + function getElements( + grip, + nameMaxLength, + roleFirst = false, + separatorText = ": " + ) { + const { name, role } = grip.preview; + const elements = []; + + // If there's a `name` value in `grip.preview`, render it with the + // StringRep and push element into Elements array + + if (name) { + elements.push( + StringRep({ + className: "accessible-name", + object: name, + cropLimit: nameMaxLength, + }), + span({ className: "separator" }, separatorText) + ); + } + + elements.push(span({ className: "accessible-role" }, role)); + return roleFirst ? elements.reverse() : elements; + } + + // Get Icon + function getInspectIcon(opts) { + const { + object, + onInspectIconClick, + inspectIconTitle, + onAccessibleClick, + isInTree, + } = opts; + + if (!isInTree || !onInspectIconClick) { + return null; + } + + return button({ + className: "open-accessibility-inspector", + title: inspectIconTitle, + onClick: e => { + if (onAccessibleClick) { + e.stopPropagation(); + } + + onInspectIconClick(object, e); + }, + }); + } + + // Registration + function supportsObject(object) { + return ( + object?.preview && object.typeName && object.typeName === "accessible" + ); + } + + // Exports from this module + module.exports = { + rep: wrapRender(Accessible), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/accessor.js b/devtools/client/shared/components/reps/reps/accessor.js new file mode 100644 index 0000000000..b234d814b3 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/accessor.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"; +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const { + button, + span, + } = require("devtools/client/shared/vendor/react-dom-factories"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + /** + * Renders an object. An object is represented by a list of its + * properties enclosed in curly brackets. + */ + + Accessor.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + shouldRenderTooltip: PropTypes.bool, + }; + + function Accessor(props) { + const { + object, + evaluation, + onInvokeGetterButtonClick, + shouldRenderTooltip, + } = props; + + if (evaluation) { + const { + Rep, + Grip, + } = require("devtools/client/shared/components/reps/reps/rep"); + return span( + { + className: "objectBox objectBox-accessor objectTitle", + }, + Rep({ + ...props, + object: evaluation.getterValue, + mode: props.mode || MODE.TINY, + defaultRep: Grip, + }) + ); + } + + if (hasGetter(object) && onInvokeGetterButtonClick) { + return button({ + className: "invoke-getter", + title: "Invoke getter", + onClick: event => { + onInvokeGetterButtonClick(); + event.stopPropagation(); + }, + }); + } + + const accessors = []; + if (hasGetter(object)) { + accessors.push("Getter"); + } + + if (hasSetter(object)) { + accessors.push("Setter"); + } + + const accessorsString = accessors.join(" & "); + + return span( + { + className: "objectBox objectBox-accessor objectTitle", + title: shouldRenderTooltip ? accessorsString : null, + }, + accessorsString + ); + } + + function hasGetter(object) { + return object && object.get && object.get.type !== "undefined"; + } + + function hasSetter(object) { + return object && object.set && object.set.type !== "undefined"; + } + + function supportsObject(object) { + return hasGetter(object) || hasSetter(object); + } + + // Exports from this module + module.exports = { + rep: wrapRender(Accessor), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/array.js b/devtools/client/shared/components/reps/reps/array.js new file mode 100644 index 0000000000..f8797fa4d3 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/array.js @@ -0,0 +1,170 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + const ModePropType = PropTypes.oneOf(Object.values(MODE)); + + /** + * Renders an array. The array is enclosed by left and right bracket + * and the max number of rendered items depends on the current mode. + */ + + ArrayRep.propTypes = { + mode: ModePropType, + object: PropTypes.array.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function ArrayRep(props) { + const { object, mode = MODE.SHORT, shouldRenderTooltip = true } = props; + + let brackets; + let items; + const needSpace = function (space) { + return space ? { left: "[ ", right: " ]" } : { left: "[", right: "]" }; + }; + + if (mode === MODE.TINY) { + const isEmpty = object.length === 0; + if (isEmpty) { + items = []; + } else { + items = [ + span( + { + className: "more-ellipsis", + }, + "…" + ), + ]; + } + brackets = needSpace(false); + } else { + items = arrayIterator(props, object, maxLengthMap.get(mode)); + brackets = needSpace(!!items.length); + } + + return span( + { + className: "objectBox objectBox-array", + title: shouldRenderTooltip ? "Array" : null, + }, + span( + { + className: "arrayLeftBracket", + }, + brackets.left + ), + ...items, + span( + { + className: "arrayRightBracket", + }, + brackets.right + ) + ); + } + + function arrayIterator(props, array, max) { + const items = []; + + for (let i = 0; i < array.length && i < max; i++) { + const config = { + mode: MODE.TINY, + delim: i == array.length - 1 ? "" : ", ", + }; + let item; + + try { + item = ItemRep({ + ...props, + ...config, + object: array[i], + }); + } catch (exc) { + item = ItemRep({ + ...props, + ...config, + object: exc, + }); + } + items.push(item); + } + + if (array.length > max) { + items.push( + span( + { + className: "more-ellipsis", + }, + "…" + ) + ); + } + + return items; + } + + /** + * Renders array item. Individual values are separated by a comma. + */ + + ItemRep.propTypes = { + object: PropTypes.any.isRequired, + delim: PropTypes.string.isRequired, + mode: ModePropType, + }; + + function ItemRep(props) { + const { Rep } = require("devtools/client/shared/components/reps/reps/rep"); + + const { object, delim, mode } = props; + return span( + {}, + Rep({ + ...props, + object, + mode, + }), + delim + ); + } + + function getLength(object) { + return object.length; + } + + function supportsObject(object, noGrip = false) { + return ( + noGrip && + (Array.isArray(object) || + Object.prototype.toString.call(object) === "[object Arguments]") + ); + } + + const maxLengthMap = new Map(); + maxLengthMap.set(MODE.SHORT, 3); + maxLengthMap.set(MODE.LONG, 10); + + // Exports from this module + module.exports = { + rep: wrapRender(ArrayRep), + supportsObject, + maxLengthMap, + getLength, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/attribute.js b/devtools/client/shared/components/reps/reps/attribute.js new file mode 100644 index 0000000000..d9c4e7c237 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/attribute.js @@ -0,0 +1,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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + appendRTLClassNameIfNeeded, + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + rep: StringRep, + } = require("devtools/client/shared/components/reps/reps/string"); + + /** + * Renders DOM attribute + */ + + Attribute.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function Attribute(props) { + const { object, shouldRenderTooltip } = props; + const value = object.preview.value; + const attrName = getTitle(object); + + const config = getElementConfig({ + attrName, + shouldRenderTooltip, + value, + object, + }); + + return span( + config, + span( + { + className: appendRTLClassNameIfNeeded("attrName", attrName), + }, + attrName + ), + span({ className: "attrEqual" }, "="), + StringRep({ className: "attrValue", object: value }) + ); + } + + function getTitle(grip) { + return grip.preview.nodeName; + } + + function getElementConfig(opts) { + const { attrName, shouldRenderTooltip, value, object } = opts; + + return { + "data-link-actor-id": object.actor, + className: "objectBox-Attr", + title: shouldRenderTooltip ? `${attrName}="${value}"` : null, + }; + } + + // Registration + function supportsObject(grip, noGrip = false) { + return getGripType(grip, noGrip) == "Attr" && grip?.preview; + } + + module.exports = { + rep: wrapRender(Attribute), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/big-int.js b/devtools/client/shared/components/reps/reps/big-int.js new file mode 100644 index 0000000000..4bb7db507f --- /dev/null +++ b/devtools/client/shared/components/reps/reps/big-int.js @@ -0,0 +1,57 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a BigInt Number + */ + + BigInt.propTypes = { + object: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.number, + PropTypes.bool, + ]).isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function BigInt(props) { + const { object, shouldRenderTooltip } = props; + const text = object.text; + const config = getElementConfig({ text, shouldRenderTooltip }); + + return span(config, `${text}n`); + } + + function getElementConfig(opts) { + const { text, shouldRenderTooltip } = opts; + + return { + className: "objectBox objectBox-number", + title: shouldRenderTooltip ? `${text}n` : null, + }; + } + function supportsObject(object, noGrip = false) { + return getGripType(object, noGrip) === "BigInt"; + } + + // Exports from this module + + module.exports = { + rep: wrapRender(BigInt), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/comment-node.js b/devtools/client/shared/components/reps/reps/comment-node.js new file mode 100644 index 0000000000..5d4ce49e3d --- /dev/null +++ b/devtools/client/shared/components/reps/reps/comment-node.js @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + const { + cropString, + cropMultipleLines, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + const nodeConstants = require("devtools/client/shared/components/reps/shared/dom-node-constants"); + + /** + * Renders DOM comment node. + */ + + CommentNode.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + shouldRenderTooltip: PropTypes.bool, + }; + + function CommentNode(props) { + const { object, mode = MODE.SHORT, shouldRenderTooltip } = props; + + let { textContent } = object.preview; + if (mode === MODE.TINY || mode === MODE.HEADER) { + textContent = cropMultipleLines(textContent, 30); + } else if (mode === MODE.SHORT) { + textContent = cropString(textContent, 50); + } + + const config = getElementConfig({ + object, + textContent, + shouldRenderTooltip, + }); + + return span(config, `<!-- ${textContent} -->`); + } + + function getElementConfig(opts) { + const { object, shouldRenderTooltip } = opts; + + // Run textContent through cropString to sanitize + const uncroppedText = shouldRenderTooltip + ? cropString(object.preview.textContent) + : null; + + return { + className: "objectBox theme-comment", + "data-link-actor-id": object.actor, + title: shouldRenderTooltip ? `<!-- ${uncroppedText} -->` : null, + }; + } + + // Registration + function supportsObject(object) { + return object?.preview?.nodeType === nodeConstants.COMMENT_NODE; + } + + // Exports from this module + module.exports = { + rep: wrapRender(CommentNode), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/constants.js b/devtools/client/shared/components/reps/reps/constants.js new file mode 100644 index 0000000000..2599ccab75 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/constants.js @@ -0,0 +1,18 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + module.exports = { + MODE: { + TINY: Symbol("TINY"), + SHORT: Symbol("SHORT"), + LONG: Symbol("LONG"), + // Used by Debugger Preview popup + HEADER: Symbol("HEADER"), + }, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/custom-formatter.js b/devtools/client/shared/components/reps/reps/custom-formatter.js new file mode 100644 index 0000000000..469866f790 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/custom-formatter.js @@ -0,0 +1,256 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + // Dependencies + const { + Component, + createElement, + createFactory, + } = require("devtools/client/shared/vendor/react"); + const { + cleanupStyle, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const flags = require("resource://devtools/shared/flags.js"); + + const ALLOWED_TAGS = new Set([ + "span", + "div", + "ol", + "ul", + "li", + "table", + "tr", + "td", + ]); + + class CustomFormatter extends Component { + static get propTypes() { + return { + autoExpandDepth: PropTypes.number, + client: PropTypes.object, + createElement: PropTypes.func, + frame: PropTypes.object, + front: PropTypes.object, + object: PropTypes.object.isRequired, + }; + } + + constructor(props) { + super(props); + this.state = { open: false }; + this.toggleBody = this.toggleBody.bind(this); + } + + componentDidThrow(e) { + console.error("Error in CustomFormatter", e); + this.setState(state => ({ ...state, hasError: true })); + } + + async toggleBody(evt) { + evt.stopPropagation(); + + const open = !this.state.open; + if (open && !this.state.bodyJsonMl) { + let front = this.props.front; + if (!front && this.props.client?.createObjectFront) { + if (flags.testing && !this.props.frame) { + throw new Error("props.frame is mandatory"); + } + front = this.props.client.createObjectFront( + this.props.object, + this.props.frame + ); + } + if (!front) { + return; + } + + const response = await front.customFormatterBody(); + + const bodyJsonMl = renderJsonMl(response.customFormatterBody, { + ...this.props, + autoExpandDepth: this.props.autoExpandDepth + ? this.props.autoExpandDepth - 1 + : 0, + object: null, + }); + + this.setState(state => ({ + ...state, + bodyJsonMl, + open, + })); + } else { + this.setState(state => ({ + ...state, + bodyJsonMl: null, + open, + })); + } + } + + render() { + if (this.state && this.state.hasError) { + return createElement( + "span", + { + className: "objectBox objectBox-failure", + title: + "This object could not be rendered, " + + "please file a bug on bugzilla.mozilla.org", + }, + "Invalid custom formatter object" + ); + } + + const headerJsonMl = renderJsonMl(this.props.object.header, { + ...this.props, + open: this.state?.open, + }); + + return createElement( + "span", + { + className: "objectBox-jsonml-wrapper", + "data-expandable": this.props.object.hasBody, + "aria-expanded": this.state.open, + onClick: this.props.object.hasBody ? this.toggleBody : null, + }, + headerJsonMl, + this.state.bodyJsonMl + ? createElement( + "div", + { className: "objectBox-jsonml-body-wrapper" }, + this.state.bodyJsonMl + ) + : null + ); + } + } + + function renderJsonMl(jsonMl, props, index = 0) { + // The second item of the array can either be an object holding the attributes + // for the element or the first child element. Therefore, all array items after the + // first one are fetched together and split afterwards if needed. + let [tagName, ...attributesAndChildren] = jsonMl ?? []; + + if (!ALLOWED_TAGS.has(tagName)) { + tagName = "div"; + } + + const attributes = attributesAndChildren[0]; + const hasAttributes = + Object(attributes) === attributes && !Array.isArray(attributes); + const style = + hasAttributes && attributes?.style && props.createElement + ? cleanupStyle(attributes.style, props.createElement) + : null; + const children = attributesAndChildren; + if (hasAttributes) { + children.shift(); + } + + const childElements = []; + + if (props.object?.hasBody) { + childElements.push( + createElement("button", { + "aria-expanded": props.open, + className: `collapse-button jsonml-header-collapse-button${ + props.open ? " expanded" : "" + }`, + }) + ); + } + + if (Array.isArray(children)) { + children.forEach((child, childIndex) => { + let childElement; + // If the child is an array, it should be a JsonML item, so use this function to + // render them. + if (Array.isArray(child)) { + childElement = renderJsonMl( + child, + { ...props, object: null }, + childIndex + ); + } else if (typeof child === "object" && child !== null) { + // If we don't have an array, this means that we're probably dealing with + // a front or a grip. If the object has a `getGrip` function, call it to get the + // actual grip. + const gripOrPrimitive = + (child.typeName == "obj" || child.typeName == "string") && + typeof child?.getGrip == "function" + ? child.getGrip() + : child; + + // If the grip represents an object that was custom formatted, we should render + // it using this component. + if (supportsObject(gripOrPrimitive)) { + childElement = createElement(CustomFormatter, { + ...props, + object: gripOrPrimitive, + front: child && !!child.typeName ? child : null, + }); + } else { + // Here we have a non custom-formatted grip, so we let the ObjectInspector + // handles it. + const { + objectInspector, + MODE, + } = require("devtools/client/shared/components/reps/index"); + childElement = createElement(objectInspector.ObjectInspector, { + ...props, + mode: props.mode == MODE.LONG ? MODE.SHORT : MODE.TINY, + roots: [ + { + path: `${ + gripOrPrimitive?.actorID ?? gripOrPrimitive?.actor ?? null + }`, + contents: { + value: gripOrPrimitive, + front: child && !!child.typeName ? child : null, + }, + }, + ], + }); + } + } else { + // Here we have a primitive. We don't want to use Rep to render them as reps come + // with their own styling which might clash with the style defined in the JsonMl. + childElement = child; + } + childElements.push(childElement); + }); + } else { + childElements.push(children); + } + + return createElement( + tagName, + { + className: `objectBox objectBox-jsonml`, + key: `jsonml-${tagName}-${index}`, + style, + }, + childElements + ); + } + + function supportsObject(grip) { + return grip?.useCustomFormatter === true && Array.isArray(grip?.header); + } + + // Exports from this module + module.exports = { + rep: createFactory(CustomFormatter), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/date-time.js b/devtools/client/shared/components/reps/reps/date-time.js new file mode 100644 index 0000000000..36d15fcbaa --- /dev/null +++ b/devtools/client/shared/components/reps/reps/date-time.js @@ -0,0 +1,95 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Used to render JS built-in Date() object. + */ + + DateTime.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function DateTime(props) { + const { object: grip, shouldRenderTooltip } = props; + let date; + try { + const dateObject = new Date(grip.preview.timestamp); + // Calling `toISOString` will throw if the date is invalid, + // so we can render an `Invalid Date` element. + dateObject.toISOString(); + + const dateObjectString = dateObject.toString(); + + const config = getElementConfig({ + grip, + dateObjectString, + shouldRenderTooltip, + }); + + date = span( + config, + getTitle(grip), + span({ className: "Date" }, dateObjectString) + ); + } catch (e) { + date = span( + { + className: "objectBox", + title: shouldRenderTooltip ? "Invalid Date" : null, + }, + "Invalid Date" + ); + } + + return date; + } + + function getElementConfig(opts) { + const { grip, dateObjectString, shouldRenderTooltip } = opts; + + return { + "data-link-actor-id": grip.actor, + className: "objectBox", + title: shouldRenderTooltip ? `${grip.class} ${dateObjectString}` : null, + }; + } + + // getTitle() is used to render the `Date ` before the stringified date object, + // not to render the actual span "title". + + function getTitle(grip) { + return span( + { + className: "objectTitle", + }, + `${grip.class} ` + ); + } + + // Registration + function supportsObject(grip, noGrip = false) { + return getGripType(grip, noGrip) == "Date" && grip?.preview; + } + + // Exports from this module + module.exports = { + rep: wrapRender(DateTime), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/document-type.js b/devtools/client/shared/components/reps/reps/document-type.js new file mode 100644 index 0000000000..36442858fc --- /dev/null +++ b/devtools/client/shared/components/reps/reps/document-type.js @@ -0,0 +1,60 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders DOM documentType object. + */ + + DocumentType.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function DocumentType(props) { + const { object, shouldRenderTooltip } = props; + const name = + object && object.preview && object.preview.nodeName + ? ` ${object.preview.nodeName}` + : ""; + + const config = getElementConfig({ object, shouldRenderTooltip, name }); + + return span(config, `<!DOCTYPE${name}>`); + } + + function getElementConfig(opts) { + const { object, shouldRenderTooltip, name } = opts; + + return { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-document", + title: shouldRenderTooltip ? `<!DOCTYPE${name}>` : null, + }; + } + + // Registration + function supportsObject(object, noGrip = false) { + return object?.preview && getGripType(object, noGrip) === "DocumentType"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(DocumentType), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/document.js b/devtools/client/shared/components/reps/reps/document.js new file mode 100644 index 0000000000..1ee4eeb9a7 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/document.js @@ -0,0 +1,79 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getGripType, + getURLDisplayString, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders DOM document object. + */ + + Document.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function Document(props) { + const grip = props.object; + const shouldRenderTooltip = props.shouldRenderTooltip; + const location = getLocation(grip); + const config = getElementConfig({ grip, location, shouldRenderTooltip }); + return span( + config, + getTitle(grip), + location ? span({ className: "location" }, ` ${location}`) : null + ); + } + + function getElementConfig(opts) { + const { grip, location, shouldRenderTooltip } = opts; + const config = { + "data-link-actor-id": grip.actor, + className: "objectBox objectBox-document", + }; + + if (!shouldRenderTooltip || !location) { + return config; + } + config.title = `${grip.class} ${location}`; + return config; + } + + function getLocation(grip) { + const location = grip.preview.location; + return location ? getURLDisplayString(location) : null; + } + + function getTitle(grip) { + return span( + { + className: "objectTitle", + }, + grip.class + ); + } + + // Registration + function supportsObject(object, noGrip = false) { + return object?.preview && getGripType(object, noGrip) === "HTMLDocument"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(Document), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/element-node.js b/devtools/client/shared/components/reps/reps/element-node.js new file mode 100644 index 0000000000..a31fb4225b --- /dev/null +++ b/devtools/client/shared/components/reps/reps/element-node.js @@ -0,0 +1,322 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const { + button, + span, + } = require("devtools/client/shared/vendor/react-dom-factories"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + // Utils + const { + appendRTLClassNameIfNeeded, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + rep: StringRep, + isLongString, + } = require("devtools/client/shared/components/reps/reps/string"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + const nodeConstants = require("devtools/client/shared/components/reps/shared/dom-node-constants"); + + const MAX_ATTRIBUTE_LENGTH = 50; + + /** + * Renders DOM element node. + */ + + ElementNode.propTypes = { + object: PropTypes.object.isRequired, + // The class should be in reps.css + inspectIconTitle: PropTypes.oneOf(["open-inspector", "highlight-node"]), + inspectIconClassName: PropTypes.string, + mode: PropTypes.oneOf(Object.values(MODE)), + onDOMNodeClick: PropTypes.func, + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + shouldRenderTooltip: PropTypes.bool, + }; + + function ElementNode(props) { + const { object, mode, shouldRenderTooltip } = props; + + const { + isAfterPseudoElement, + isBeforePseudoElement, + isMarkerPseudoElement, + } = object.preview; + + let renderElements = []; + const isInTree = object.preview && object.preview.isConnected === true; + let config = getElementConfig({ ...props, isInTree }); + const inspectIcon = getInspectIcon({ ...props, isInTree }); + + // Elements Case 1: Pseudo Element + if ( + isAfterPseudoElement || + isBeforePseudoElement || + isMarkerPseudoElement + ) { + const pseudoNodeElement = getPseudoNodeElement(object); + + // Regenerate config if shouldRenderTooltip + if (shouldRenderTooltip) { + const tooltipString = pseudoNodeElement.content; + config = getElementConfig({ ...props, tooltipString, isInTree }); + } + + // Return ONLY pseudo node element as array[0] + renderElements = [ + span(pseudoNodeElement.config, pseudoNodeElement.content), + ]; + } else if (mode === MODE.TINY) { + // Elements Case 2: MODE.TINY + const tinyElements = getTinyElements(object); + + // Regenerate config to include tooltip title + if (shouldRenderTooltip) { + // Reduce for plaintext + const tooltipString = tinyElements.reduce(function (acc, cur) { + return acc.concat(cur.content); + }, ""); + + config = getElementConfig({ ...props, tooltipString, isInTree }); + } + + // Reduce for React elements + const tinyElementsRender = tinyElements.reduce(function (acc, cur) { + acc.push(span(cur.config, cur.content)); + return acc; + }, []); + + // Render array of React spans + renderElements = tinyElementsRender; + } else { + // Elements Default case + renderElements = getElements(props); + } + + return span(config, ...renderElements, inspectIcon ? inspectIcon : null); + } + + function getElementConfig(opts) { + const { + object, + isInTree, + onDOMNodeClick, + onDOMNodeMouseOver, + onDOMNodeMouseOut, + shouldRenderTooltip, + tooltipString, + } = opts; + + // Initiate config + const config = { + "data-link-actor-id": object.actor, + "data-link-content-dom-reference": JSON.stringify( + object.contentDomReference + ), + className: "objectBox objectBox-node", + }; + + // Triage event handlers + if (isInTree) { + if (onDOMNodeClick) { + Object.assign(config, { + onClick: _ => onDOMNodeClick(object), + className: `${config.className} clickable`, + }); + } + + if (onDOMNodeMouseOver) { + Object.assign(config, { + onMouseOver: _ => onDOMNodeMouseOver(object), + }); + } + + if (onDOMNodeMouseOut) { + Object.assign(config, { + onMouseOut: _ => onDOMNodeMouseOut(object), + }); + } + } + + // If tooltip, build tooltip + if (tooltipString && shouldRenderTooltip) { + config.title = tooltipString; + } + + // Return config obj + return config; + } + + function getElements(opts) { + const { object: grip } = opts; + + const { attributes, nodeName } = grip.preview; + + const nodeNameElement = span( + { + className: appendRTLClassNameIfNeeded("tag-name", nodeName), + }, + nodeName + ); + + const attributeKeys = Object.keys(attributes); + if (attributeKeys.includes("class")) { + attributeKeys.splice(attributeKeys.indexOf("class"), 1); + attributeKeys.unshift("class"); + } + if (attributeKeys.includes("id")) { + attributeKeys.splice(attributeKeys.indexOf("id"), 1); + attributeKeys.unshift("id"); + } + const attributeElements = attributeKeys.reduce((arr, name, i, keys) => { + const value = attributes[name]; + + let title = isLongString(value) ? value.initial : value; + if (title.length < MAX_ATTRIBUTE_LENGTH) { + title = null; + } + + const attribute = span( + {}, + span( + { + className: appendRTLClassNameIfNeeded("attrName", name), + }, + name + ), + span({ className: "attrEqual" }, "="), + StringRep({ + className: "attrValue", + object: value, + cropLimit: MAX_ATTRIBUTE_LENGTH, + title, + }) + ); + + return arr.concat([" ", attribute]); + }, []); + + return [ + span({ className: "angleBracket" }, "<"), + nodeNameElement, + ...attributeElements, + span({ className: "angleBracket" }, ">"), + ]; + } + + function getTinyElements(grip) { + const { attributes, nodeName } = grip.preview; + + // Initialize elements array + const elements = [ + { + config: { + className: appendRTLClassNameIfNeeded("tag-name", nodeName), + }, + content: nodeName, + }, + ]; + + // Push ID element + if (attributes.id) { + elements.push({ + config: { + className: appendRTLClassNameIfNeeded("attrName", attributes.id), + }, + content: `#${attributes.id}`, + }); + } + + // Push Classes + if (attributes.class) { + const elementClasses = attributes.class + .trim() + .split(/\s+/) + .map(cls => `.${cls}`) + .join(""); + elements.push({ + config: { + className: appendRTLClassNameIfNeeded("attrName", elementClasses), + }, + content: elementClasses, + }); + } + + return elements; + } + + function getPseudoNodeElement(grip) { + const { + isAfterPseudoElement, + isBeforePseudoElement, + isMarkerPseudoElement, + } = grip.preview; + + let pseudoNodeName; + + if (isAfterPseudoElement) { + pseudoNodeName = "after"; + } else if (isBeforePseudoElement) { + pseudoNodeName = "before"; + } else if (isMarkerPseudoElement) { + pseudoNodeName = "marker"; + } + + return { + config: { className: "attrName" }, + content: `::${pseudoNodeName}`, + }; + } + + function getInspectIcon(opts) { + const { + object, + isInTree, + onInspectIconClick, + inspectIconTitle, + inspectIconClassName, + onDOMNodeClick, + } = opts; + + if (!isInTree || !onInspectIconClick) { + return null; + } + + return button({ + className: inspectIconClassName || "open-inspector", + // TODO: Localize this with "openNodeInInspector" when Bug 1317038 lands + title: inspectIconTitle || "Click to select the node in the inspector", + onClick: e => { + if (onDOMNodeClick) { + e.stopPropagation(); + } + + onInspectIconClick(object, e); + }, + }); + } + + // Registration + function supportsObject(object) { + return object?.preview?.nodeType === nodeConstants.ELEMENT_NODE; + } + + // Exports from this module + module.exports = { + rep: wrapRender(ElementNode), + supportsObject, + MAX_ATTRIBUTE_LENGTH, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/error.js b/devtools/client/shared/components/reps/reps/error.js new file mode 100644 index 0000000000..617bf8c8a2 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/error.js @@ -0,0 +1,338 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { + div, + span, + } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Utils + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + cleanFunctionName, + } = require("devtools/client/shared/components/reps/reps/function"); + const { + isLongString, + } = require("devtools/client/shared/components/reps/reps/string"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + const IGNORED_SOURCE_URLS = ["debugger eval code"]; + + /** + * Renders Error objects. + */ + ErrorRep.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + // An optional function that will be used to render the Error stacktrace. + renderStacktrace: PropTypes.func, + shouldRenderTooltip: PropTypes.bool, + }; + + /** + * Render an Error object. + * The customFormat prop allows to print a simplified view of the object, with only the + * message and the stacktrace, e.g.: + * Error: "blah" + * <anonymous> debugger eval code:1 + * + * The customFormat prop will only be taken into account if the mode isn't tiny and the + * depth is 0. This is because we don't want error in previews or in object to be + * displayed unlike other objects: + * - Object { err: Error } + * - â–¼ { + * err: Error: "blah" + * } + */ + function ErrorRep(props) { + const { object, mode, shouldRenderTooltip, depth } = props; + const preview = object.preview; + const customFormat = + props.customFormat && + mode !== MODE.TINY && + mode !== MODE.HEADER && + !depth; + + const name = getErrorName(props); + const errorTitle = + mode === MODE.TINY || mode === MODE.HEADER ? name : `${name}: `; + const content = []; + + if (customFormat) { + content.push(errorTitle); + } else { + content.push( + span({ className: "objectTitle", key: "title" }, errorTitle) + ); + } + + if (mode !== MODE.TINY && mode !== MODE.HEADER) { + const { + Rep, + } = require("devtools/client/shared/components/reps/reps/rep"); + content.push( + Rep({ + ...props, + key: "message", + object: preview.message, + mode: props.mode || MODE.TINY, + useQuotes: false, + }) + ); + } + const renderStack = preview.stack && customFormat; + if (renderStack) { + const stacktrace = props.renderStacktrace + ? props.renderStacktrace(parseStackString(preview.stack)) + : getStacktraceElements(props, preview); + content.push(stacktrace); + } + + const renderCause = customFormat && preview.hasOwnProperty("cause"); + if (renderCause) { + content.push(getCauseElement(props, preview)); + } + + return span( + { + "data-link-actor-id": object.actor, + className: `objectBox-stackTrace ${ + customFormat ? "reps-custom-format" : "" + }`, + title: shouldRenderTooltip ? `${name}: "${preview.message}"` : null, + }, + ...content + ); + } + + function getErrorName(props) { + const { object } = props; + const preview = object.preview; + + let name; + if (typeof preview?.name === "string" && preview.kind) { + switch (preview.kind) { + case "Error": + name = preview.name; + break; + case "DOMException": + name = preview.kind; + break; + default: + throw new Error("Unknown preview kind for the Error rep."); + } + } else { + name = "Error"; + } + + return name; + } + + /** + * Returns a React element reprensenting the Error stacktrace, i.e. + * transform error.stack from: + * + * semicolon@debugger eval code:1:109 + * jkl@debugger eval code:1:63 + * asdf@debugger eval code:1:28 + * @debugger eval code:1:227 + * + * Into a column layout: + * + * semicolon (<anonymous>:8:10) + * jkl (<anonymous>:5:10) + * asdf (<anonymous>:2:10) + * (<anonymous>:11:1) + */ + function getStacktraceElements(props, preview) { + const stack = []; + if (!preview.stack) { + return stack; + } + + parseStackString(preview.stack).forEach((frame, index, frames) => { + let onLocationClick; + const { filename, lineNumber, columnNumber, functionName, location } = + frame; + + if ( + props.onViewSourceInDebugger && + !IGNORED_SOURCE_URLS.includes(filename) + ) { + onLocationClick = e => { + // Don't trigger ObjectInspector expand/collapse. + e.stopPropagation(); + props.onViewSourceInDebugger({ + url: filename, + line: lineNumber, + column: columnNumber, + }); + }; + } + + stack.push( + "\t", + span( + { + key: `fn${index}`, + className: "objectBox-stackTrace-fn", + }, + cleanFunctionName(functionName) + ), + " ", + span( + { + key: `location${index}`, + className: "objectBox-stackTrace-location", + onClick: onLocationClick, + title: onLocationClick + ? `View source in debugger → ${location}` + : undefined, + }, + location + ), + "\n" + ); + }); + + return span( + { + key: "stack", + className: "objectBox-stackTrace-grid", + }, + stack + ); + } + + /** + * Returns a React element representing the cause of the Error i.e. the `cause` + * property in the second parameter of the Error constructor (`new Error("message", { cause })`) + * + * Example: + * Caused by: Error: original error + */ + function getCauseElement(props, preview) { + const { Rep } = require("devtools/client/shared/components/reps/reps/rep"); + return div( + { + key: "cause-container", + className: "error-rep-cause", + }, + "Caused by: ", + Rep({ + ...props, + key: "cause", + object: preview.cause, + mode: props.mode || MODE.TINY, + }) + ); + } + + /** + * Parse a string that should represent a stack trace and returns an array of + * the frames. The shape of the frames are extremely important as they can then + * be processed here or in the toolbox by other components. + * @param {String} stack + * @returns {Array} Array of frames, which are object with the following shape: + * - {String} filename + * - {String} functionName + * - {String} location + * - {Number} columnNumber + * - {Number} lineNumber + */ + function parseStackString(stack) { + if (!stack) { + return []; + } + + const isStacktraceALongString = isLongString(stack); + const stackString = isStacktraceALongString ? stack.initial : stack; + + if (typeof stackString !== "string") { + return []; + } + + const res = []; + stackString.split("\n").forEach((frame, index, frames) => { + if (!frame) { + // Skip any blank lines + return; + } + + // If the stacktrace is a longString, don't include the last frame in the + // array, since it is certainly incomplete. + // Can be removed when https://bugzilla.mozilla.org/show_bug.cgi?id=1448833 + // is fixed. + if (isStacktraceALongString && index === frames.length - 1) { + return; + } + + let functionName; + let location; + + // Retrieve the index of the first @ to split the frame string. + const atCharIndex = frame.indexOf("@"); + if (atCharIndex > -1) { + functionName = frame.slice(0, atCharIndex); + location = frame.slice(atCharIndex + 1); + } + + if (location && location.includes(" -> ")) { + // If the resource was loaded by base-loader.sys.mjs, the location looks like: + // resource://devtools/shared/base-loader.sys.mjs -> resource://path/to/file.js . + // What's needed is only the last part after " -> ". + location = location.split(" -> ").pop(); + } + + if (!functionName) { + functionName = "<anonymous>"; + } + + // Given the input: "scriptLocation:2:100" + // Result: + // ["scriptLocation:2:100", "scriptLocation", "2", "100"] + const locationParts = location + ? location.match(/^(.*):(\d+):(\d+)$/) + : null; + + if (location && locationParts) { + const [, filename, line, column] = locationParts; + res.push({ + filename, + functionName, + location, + columnNumber: Number(column), + lineNumber: Number(line), + }); + } + }); + + return res; + } + + // Registration + function supportsObject(object) { + return ( + object?.isError || + object?.class === "DOMException" || + object?.class === "Exception" + ); + } + + // Exports from this module + module.exports = { + rep: wrapRender(ErrorRep), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/event.js b/devtools/client/shared/components/reps/reps/event.js new file mode 100644 index 0000000000..460f7e8c31 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/event.js @@ -0,0 +1,115 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + // Reps + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + const { rep } = require("devtools/client/shared/components/reps/reps/grip"); + + /** + * Renders DOM event objects. + */ + Event.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + }; + + function Event(props) { + const gripProps = { + ...props, + title: getTitle(props), + object: { + ...props.object, + preview: { + ...props.object.preview, + ownProperties: {}, + }, + }, + }; + + if (gripProps.object.preview.target) { + Object.assign(gripProps.object.preview.ownProperties, { + target: gripProps.object.preview.target, + }); + } + Object.assign( + gripProps.object.preview.ownProperties, + gripProps.object.preview.properties + ); + + delete gripProps.object.preview.properties; + gripProps.object.ownPropertyLength = Object.keys( + gripProps.object.preview.ownProperties + ).length; + + switch (gripProps.object.class) { + case "MouseEvent": + gripProps.isInterestingProp = (type, value, name) => { + return ["target", "clientX", "clientY", "layerX", "layerY"].includes( + name + ); + }; + break; + case "KeyboardEvent": + gripProps.isInterestingProp = (type, value, name) => { + return ["target", "key", "charCode", "keyCode"].includes(name); + }; + break; + case "MessageEvent": + gripProps.isInterestingProp = (type, value, name) => { + return ["target", "isTrusted", "data"].includes(name); + }; + break; + default: + gripProps.isInterestingProp = (type, value, name) => { + // We want to show the properties in the order they are declared. + return Object.keys(gripProps.object.preview.ownProperties).includes( + name + ); + }; + } + + return rep(gripProps); + } + + function getTitle(props) { + const preview = props.object.preview; + let title = preview.type; + + if ( + preview.eventKind == "key" && + preview.modifiers && + preview.modifiers.length + ) { + title = `${title} ${preview.modifiers.join("-")}`; + } + return title; + } + + // Registration + function supportsObject(grip) { + return grip?.preview?.kind == "DOMEvent"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(Event), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/function.js b/devtools/client/shared/components/reps/reps/function.js new file mode 100644 index 0000000000..54d8905c20 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/function.js @@ -0,0 +1,264 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { + button, + span, + } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getGripType, + cropString, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + const IGNORED_SOURCE_URLS = ["debugger eval code"]; + + /** + * This component represents a template for Function objects. + */ + + FunctionRep.propTypes = { + object: PropTypes.object.isRequired, + onViewSourceInDebugger: PropTypes.func, + shouldRenderTooltip: PropTypes.bool, + }; + + function FunctionRep(props) { + const { + object: grip, + onViewSourceInDebugger, + recordTelemetryEvent, + shouldRenderTooltip, + } = props; + + let jumpToDefinitionButton; + + // Test to see if we should display the link back to the original function definition + if ( + onViewSourceInDebugger && + grip.location && + grip.location.url && + !IGNORED_SOURCE_URLS.includes(grip.location.url) + ) { + jumpToDefinitionButton = button({ + className: "jump-definition", + draggable: false, + title: "Jump to definition", + onClick: async e => { + // Stop the event propagation so we don't trigger ObjectInspector + // expand/collapse. + e.stopPropagation(); + if (recordTelemetryEvent) { + recordTelemetryEvent("jump_to_definition"); + } + + onViewSourceInDebugger(grip.location); + }, + }); + } + + const elProps = { + "data-link-actor-id": grip.actor, + className: "objectBox objectBox-function", + // Set dir="ltr" to prevent parentheses from + // appearing in the wrong direction + dir: "ltr", + }; + + const parameterNames = (grip.parameterNames || []).filter(Boolean); + const fnTitle = getFunctionTitle(grip, props); + const fnName = getFunctionName(grip, props); + + if (grip.isClassConstructor) { + const classTitle = getClassTitle(grip, props); + const classBodyTooltip = getClassBody(parameterNames, true, props); + const classTooltip = `${classTitle ? classTitle.props.children : ""}${ + fnName ? fnName : "" + }${classBodyTooltip.join("")}`; + + elProps.title = shouldRenderTooltip ? classTooltip : null; + + return span( + elProps, + classTitle, + fnName, + ...getClassBody(parameterNames, false, props), + jumpToDefinitionButton + ); + } + + const fnTooltip = `${fnTitle ? fnTitle.props.children : ""}${ + fnName ? fnName : "" + }(${parameterNames.join(", ")})`; + + elProps.title = shouldRenderTooltip ? fnTooltip : null; + + const returnSpan = span( + elProps, + fnTitle, + fnName, + "(", + ...getParams(parameterNames), + ")", + jumpToDefinitionButton + ); + + return returnSpan; + } + + function getClassTitle(grip) { + return span( + { + className: "objectTitle", + }, + "class " + ); + } + + function getFunctionTitle(grip, props) { + const { mode } = props; + + if (mode === MODE.TINY && !grip.isGenerator && !grip.isAsync) { + return null; + } + + let title = mode === MODE.TINY ? "" : "function "; + + if (grip.isGenerator) { + title = mode === MODE.TINY ? "* " : "function* "; + } + + if (grip.isAsync) { + title = `${"async" + " "}${title}`; + } + + return span( + { + className: "objectTitle", + }, + title + ); + } + + /** + * Returns a ReactElement representing the function name. + * + * @param {Object} grip : Function grip + * @param {Object} props: Function rep props + */ + function getFunctionName(grip, props = {}) { + let { functionName } = props; + let name; + + if (functionName) { + const end = functionName.length - 1; + functionName = + functionName.startsWith('"') && functionName.endsWith('"') + ? functionName.substring(1, end) + : functionName; + } + + if ( + grip.displayName != undefined && + functionName != undefined && + grip.displayName != functionName + ) { + name = `${functionName}:${grip.displayName}`; + } else { + name = cleanFunctionName( + grip.userDisplayName || + grip.displayName || + grip.name || + props.functionName || + "" + ); + } + + return cropString(name, 100); + } + + const objectProperty = /([\w\d\$]+)$/; + const arrayProperty = /\[(.*?)\]$/; + const functionProperty = /([\w\d]+)[\/\.<]*?$/; + const annonymousProperty = /([\w\d]+)\(\^\)$/; + + /** + * Decodes an anonymous naming scheme that + * spider monkey implements based on "Naming Anonymous JavaScript Functions" + * http://johnjbarton.github.io/nonymous/index.html + * + * @param {String} name : Function name to clean up + * @returns String + */ + function cleanFunctionName(name) { + for (const reg of [ + objectProperty, + arrayProperty, + functionProperty, + annonymousProperty, + ]) { + const match = reg.exec(name); + if (match) { + return match[1]; + } + } + + return name; + } + + function getClassBody(constructorParams, textOnly = false, props) { + const { mode } = props; + + if (mode === MODE.TINY) { + return []; + } + + return [" {", ...getClassConstructor(textOnly, constructorParams), "}"]; + } + + function getClassConstructor(textOnly = false, parameterNames) { + if (parameterNames.length === 0) { + return []; + } + + if (textOnly) { + return [` constructor(${parameterNames.join(", ")}) `]; + } + return [" constructor(", ...getParams(parameterNames), ") "]; + } + + function getParams(parameterNames) { + return parameterNames.flatMap((param, index, arr) => { + return [ + span({ className: "param" }, param), + index === arr.length - 1 ? "" : span({ className: "delimiter" }, ", "), + ]; + }); + } + + // Registration + function supportsObject(grip, noGrip = false) { + return getGripType(grip, noGrip) === "Function"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(FunctionRep), + supportsObject, + cleanFunctionName, + // exported for testing purpose. + getFunctionName, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/grip-array.js b/devtools/client/shared/components/reps/reps/grip-array.js new file mode 100644 index 0000000000..e9c67039a4 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/grip-array.js @@ -0,0 +1,263 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const { + lengthBubble, + } = require("devtools/client/shared/components/reps/shared/grip-length-bubble"); + const { + interleave, + getGripType, + wrapRender, + ellipsisElement, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + const DEFAULT_TITLE = "Array"; + + /** + * Renders an array. The array is enclosed by left and right bracket + * and the max number of rendered items depends on the current mode. + */ + + GripArray.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + provider: PropTypes.object, + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + shouldRenderTooltip: PropTypes.bool, + }; + + function GripArray(props) { + const { object, mode = MODE.SHORT, shouldRenderTooltip } = props; + + let brackets; + const needSpace = function (space) { + return space ? { left: "[ ", right: " ]" } : { left: "[", right: "]" }; + }; + + const config = { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-array", + title: shouldRenderTooltip ? "Array" : null, + }; + + const title = getTitle(props, object); + + if (mode === MODE.TINY) { + const isEmpty = getLength(object) === 0; + + // Omit bracketed ellipsis for non-empty non-Array arraylikes (f.e: Sets). + if (!isEmpty && object.class !== "Array") { + return span(config, title); + } + + brackets = needSpace(false); + return span( + config, + title, + span( + { + className: "arrayLeftBracket", + }, + brackets.left + ), + isEmpty ? null : ellipsisElement, + span( + { + className: "arrayRightBracket", + }, + brackets.right + ) + ); + } + + if (mode === MODE.HEADER) { + return span(config, title); + } + + const max = maxLengthMap.get(mode); + const items = arrayIterator(props, object, max); + brackets = needSpace(!!items.length); + + return span( + config, + title, + span( + { + className: "arrayLeftBracket", + }, + brackets.left + ), + ...interleave(items, ", "), + span( + { + className: "arrayRightBracket", + }, + brackets.right + ), + span({ + className: "arrayProperties", + role: "group", + }) + ); + } + + function getLength(grip) { + if (!grip.preview) { + return 0; + } + + return grip.preview.length || grip.preview.childNodesLength || 0; + } + + function getTitle(props, object) { + const objectLength = getLength(object); + const isEmpty = objectLength === 0; + + let title = props.title || object.class || DEFAULT_TITLE; + + const length = lengthBubble({ + object, + mode: props.mode, + maxLengthMap, + getLength, + }); + + if (props.mode === MODE.TINY) { + if (isEmpty) { + if (object.class === DEFAULT_TITLE) { + return null; + } + + return span({ className: "objectTitle" }, `${title} `); + } + + let trailingSpace; + if (object.class === DEFAULT_TITLE) { + title = null; + trailingSpace = " "; + } + + return span({ className: "objectTitle" }, title, length, trailingSpace); + } + + if (props.mode === MODE.HEADER) { + return span({ className: "objectTitle" }, title, length); + } + + return span({ className: "objectTitle" }, title, length, " "); + } + + function getPreviewItems(grip) { + if (!grip.preview) { + return null; + } + + return grip.preview.items || grip.preview.childNodes || []; + } + + function arrayIterator(props, grip, max) { + const { Rep } = require("devtools/client/shared/components/reps/reps/rep"); + + let items = []; + const gripLength = getLength(grip); + + if (!gripLength) { + return items; + } + + const previewItems = getPreviewItems(grip); + const provider = props.provider; + + let emptySlots = 0; + let foldedEmptySlots = 0; + items = previewItems.reduce((res, itemGrip) => { + if (res.length >= max) { + return res; + } + + let object; + try { + if (!provider && itemGrip === null) { + emptySlots++; + return res; + } + + object = provider ? provider.getValue(itemGrip) : itemGrip; + } catch (exc) { + object = exc; + } + + if (emptySlots > 0) { + res.push(getEmptySlotsElement(emptySlots)); + foldedEmptySlots = foldedEmptySlots + emptySlots - 1; + emptySlots = 0; + } + + if (res.length < max) { + res.push( + Rep({ + ...props, + object, + mode: MODE.TINY, + // Do not propagate title to array items reps + title: undefined, + }) + ); + } + + return res; + }, []); + + // Handle trailing empty slots if there are some. + if (items.length < max && emptySlots > 0) { + items.push(getEmptySlotsElement(emptySlots)); + foldedEmptySlots = foldedEmptySlots + emptySlots - 1; + } + + const itemsShown = items.length + foldedEmptySlots; + if (gripLength > itemsShown) { + items.push(ellipsisElement); + } + + return items; + } + + function getEmptySlotsElement(number) { + // TODO: Use l10N - See https://github.com/firefox-devtools/reps/issues/141 + return `<${number} empty slot${number > 1 ? "s" : ""}>`; + } + + function supportsObject(grip, noGrip = false) { + return ( + grip?.preview && + (grip.preview.kind == "ArrayLike" || + getGripType(grip, noGrip) === "DocumentFragment") + ); + } + + const maxLengthMap = new Map(); + maxLengthMap.set(MODE.SHORT, 3); + maxLengthMap.set(MODE.LONG, 10); + + // Exports from this module + module.exports = { + rep: wrapRender(GripArray), + supportsObject, + maxLengthMap, + getLength, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/grip-entry.js b/devtools/client/shared/components/reps/reps/grip-entry.js new file mode 100644 index 0000000000..6c08ca5d70 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/grip-entry.js @@ -0,0 +1,78 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + // Utils + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const PropRep = require("devtools/client/shared/components/reps/reps/prop-rep"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + /** + * Renders an entry of a Map, (Local|Session)Storage, Header or FormData entry. + */ + GripEntry.propTypes = { + object: PropTypes.object, + mode: PropTypes.oneOf(Object.values(MODE)), + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + }; + + function GripEntry(props) { + const { object } = props; + + let { key, value } = object.preview; + if (key && key.getGrip) { + key = key.getGrip(); + } + if (value && value.getGrip) { + value = value.getGrip(); + } + + return span( + { + className: "objectBox objectBox-map-entry", + }, + PropRep({ + ...props, + name: key, + object: value, + equal: " \u2192 ", + title: null, + suppressQuotes: false, + }) + ); + } + + function supportsObject(grip, noGrip = false) { + if (noGrip === true) { + return false; + } + return ( + grip && + (grip.type === "formDataEntry" || + grip.type === "highlightRegistryEntry" || + grip.type === "mapEntry" || + grip.type === "storageEntry" || + grip.type === "urlSearchParamsEntry") && + grip.preview + ); + } + + // Exports from this module + module.exports = { + rep: wrapRender(GripEntry), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/grip-map.js b/devtools/client/shared/components/reps/reps/grip-map.js new file mode 100644 index 0000000000..dcb7c50972 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/grip-map.js @@ -0,0 +1,234 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const { + lengthBubble, + } = require("devtools/client/shared/components/reps/shared/grip-length-bubble"); + const { + interleave, + wrapRender, + ellipsisElement, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const PropRep = require("devtools/client/shared/components/reps/reps/prop-rep"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + /** + * Renders an map. A map is represented by a list of its + * entries enclosed in curly brackets. + */ + + GripMap.propTypes = { + object: PropTypes.object, + mode: PropTypes.oneOf(Object.values(MODE)), + isInterestingEntry: PropTypes.func, + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + title: PropTypes.string, + shouldRenderTooltip: PropTypes.bool, + }; + + function GripMap(props) { + const { mode, object, shouldRenderTooltip } = props; + + const config = { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-object", + title: shouldRenderTooltip ? getTooltip(object, props) : null, + }; + + const title = getTitle(props, object); + const isEmpty = getLength(object) === 0; + + if (isEmpty || mode === MODE.TINY || mode === MODE.HEADER) { + return span(config, title); + } + + const propsArray = safeEntriesIterator( + props, + object, + maxLengthMap.get(mode) + ); + + return span( + config, + title, + span( + { + className: "objectLeftBrace", + }, + " { " + ), + ...interleave(propsArray, ", "), + span( + { + className: "objectRightBrace", + }, + " }" + ) + ); + } + + function getTitle(props, object) { + const title = + props.title || (object && object.class ? object.class : "Map"); + return span( + { + className: "objectTitle", + }, + title, + lengthBubble({ + object, + mode: props.mode, + maxLengthMap, + getLength, + showZeroLength: true, + }) + ); + } + + function getTooltip(object, props) { + const tooltip = + props.title || (object && object.class ? object.class : "Map"); + return `${tooltip}(${getLength(object)})`; + } + + function safeEntriesIterator(props, object, max) { + max = typeof max === "undefined" ? 3 : max; + try { + return entriesIterator(props, object, max); + } catch (err) { + console.error(err); + } + return []; + } + + function entriesIterator(props, object, max) { + // Entry filter. Show only interesting entries to the user. + const isInterestingEntry = + props.isInterestingEntry || + ((type, value) => { + return ( + type == "boolean" || + type == "number" || + (type == "string" && !!value.length) + ); + }); + + const mapEntries = + object.preview && object.preview.entries ? object.preview.entries : []; + + let indexes = getEntriesIndexes(mapEntries, max, isInterestingEntry); + if (indexes.length < max && indexes.length < mapEntries.length) { + // There are not enough entries yet, so we add uninteresting entries. + indexes = indexes.concat( + getEntriesIndexes( + mapEntries, + max - indexes.length, + (t, value, name) => { + return !isInterestingEntry(t, value, name); + } + ) + ); + } + + const entries = getEntries(props, mapEntries, indexes); + if (entries.length < getLength(object)) { + // There are some undisplayed entries. Then display "…". + entries.push(ellipsisElement); + } + + return entries; + } + + /** + * Get entries ordered by index. + * + * @param {Object} props Component props. + * @param {Array} entries Entries array. + * @param {Array} indexes Indexes of entries. + * @return {Array} Array of PropRep. + */ + function getEntries(props, entries, indexes) { + const { onDOMNodeMouseOver, onDOMNodeMouseOut, onInspectIconClick } = props; + + // Make indexes ordered by ascending. + indexes.sort(function (a, b) { + return a - b; + }); + + return indexes.map((index, i) => { + const [key, entryValue] = entries[index]; + const value = + entryValue.value !== undefined ? entryValue.value : entryValue; + + return PropRep({ + name: key && key.getGrip ? key.getGrip() : key, + equal: " \u2192 ", + object: value && value.getGrip ? value.getGrip() : value, + mode: MODE.TINY, + onDOMNodeMouseOver, + onDOMNodeMouseOut, + onInspectIconClick, + }); + }); + } + + /** + * Get the indexes of entries in the map. + * + * @param {Array} entries Entries array. + * @param {Number} max The maximum length of indexes array. + * @param {Function} filter Filter the entry you want. + * @return {Array} Indexes of filtered entries in the map. + */ + function getEntriesIndexes(entries, max, filter) { + return entries.reduce((indexes, [key, entry], i) => { + if (indexes.length < max) { + const value = entry && entry.value !== undefined ? entry.value : entry; + // Type is specified in grip's "class" field and for primitive + // values use typeof. + const type = ( + value && value.class ? value.class : typeof value + ).toLowerCase(); + + if (filter(type, value, key)) { + indexes.push(i); + } + } + + return indexes; + }, []); + } + + function getLength(grip) { + return grip.preview.size || 0; + } + + function supportsObject(grip) { + return grip?.preview?.kind == "MapLike"; + } + + const maxLengthMap = new Map(); + maxLengthMap.set(MODE.SHORT, 3); + maxLengthMap.set(MODE.LONG, 10); + + // Exports from this module + module.exports = { + rep: wrapRender(GripMap), + supportsObject, + maxLengthMap, + getLength, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/grip.js b/devtools/client/shared/components/reps/reps/grip.js new file mode 100644 index 0000000000..68f356858a --- /dev/null +++ b/devtools/client/shared/components/reps/reps/grip.js @@ -0,0 +1,401 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Dependencies + const { + interleave, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const PropRep = require("devtools/client/shared/components/reps/reps/prop-rep"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + /** + * Renders generic grip. Grip is client representation + * of remote JS object and is used as an input object + * for this rep component. + */ + + GripRep.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + isInterestingProp: PropTypes.func, + title: PropTypes.string, + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + noGrip: PropTypes.bool, + shouldRenderTooltip: PropTypes.bool, + }; + + const DEFAULT_TITLE = "Object"; + + function GripRep(props) { + const { mode = MODE.SHORT, object, shouldRenderTooltip } = props; + + const config = { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-object", + }; + + if (mode === MODE.TINY) { + const propertiesLength = getPropertiesLength(object); + + const tinyModeItems = []; + if (getTitle(props, object) !== DEFAULT_TITLE) { + tinyModeItems.push(getTitleElement(props, object)); + } else { + tinyModeItems.push( + span( + { + className: "objectLeftBrace", + }, + "{" + ), + propertiesLength > 0 + ? span( + { + key: "more", + className: "more-ellipsis", + }, + "…" + ) + : null, + span( + { + className: "objectRightBrace", + }, + "}" + ) + ); + } + + config.title = shouldRenderTooltip ? getTitle(props, object) : null; + + return span(config, ...tinyModeItems); + } + + if (mode === MODE.HEADER) { + config.title = shouldRenderTooltip ? getTitle(props, object) : null; + return span(config, getTitleElement(props, object)); + } + + const propsArray = safePropIterator(props, object, maxLengthMap.get(mode)); + + config.title = shouldRenderTooltip ? getTitle(props, object) : null; + + return span( + config, + getTitleElement(props, object), + span( + { + className: "objectLeftBrace", + }, + " { " + ), + ...interleave(propsArray, ", "), + span( + { + className: "objectRightBrace", + }, + " }" + ) + ); + } + + function getTitleElement(props, object) { + return span( + { + className: "objectTitle", + }, + getTitle(props, object) + ); + } + + function getTitle(props, object) { + return props.title || object.class || DEFAULT_TITLE; + } + + function getPropertiesLength(object) { + let propertiesLength = + object.preview && object.preview.ownPropertiesLength + ? object.preview.ownPropertiesLength + : object.ownPropertyLength; + + if (object.preview && object.preview.safeGetterValues) { + propertiesLength += Object.keys(object.preview.safeGetterValues).length; + } + + if (object.preview && object.preview.ownSymbols) { + propertiesLength += object.preview.ownSymbolsLength; + } + + if (object.preview && object.preview.privateProperties) { + propertiesLength += object.preview.privatePropertiesLength; + } + + return propertiesLength; + } + + function safePropIterator(props, object, max) { + max = typeof max === "undefined" ? maxLengthMap.get(MODE.SHORT) : max; + try { + return propIterator(props, object, max); + } catch (err) { + console.error(err); + } + return []; + } + + function propIterator(props, object, max) { + if ( + object.preview && + Object.keys(object.preview).includes("wrappedValue") + ) { + const { + Rep, + } = require("devtools/client/shared/components/reps/reps/rep"); + + return [ + Rep({ + object: object.preview.wrappedValue, + mode: props.mode || MODE.TINY, + defaultRep: Grip, + }), + ]; + } + + // Property filter. Show only interesting properties to the user. + const isInterestingProp = + props.isInterestingProp || + ((type, value) => { + return ( + type == "boolean" || + type == "number" || + (type == "string" && !!value.length) + ); + }); + + let properties = object.preview ? object.preview.ownProperties || {} : {}; + + const propertiesLength = getPropertiesLength(object); + + if (object.preview && object.preview.safeGetterValues) { + properties = { ...properties, ...object.preview.safeGetterValues }; + } + + let indexes = getPropIndexes(properties, max, isInterestingProp); + if (indexes.length < max && indexes.length < propertiesLength) { + // There are not enough props yet. + // Then add uninteresting props to display them. + indexes = indexes.concat( + getPropIndexes(properties, max - indexes.length, (t, value, name) => { + return !isInterestingProp(t, value, name); + }) + ); + } + + // The server synthesizes some property names for a Proxy, like + // <target> and <handler>; we don't want to quote these because, + // as synthetic properties, they appear more natural when + // unquoted. Analogous for a Promise. + const suppressQuotes = ["Proxy", "Promise"].includes(object.class); + const propsArray = getProps(props, properties, indexes, suppressQuotes); + + // Show private properties + if (object.preview && object.preview.privateProperties) { + const { privateProperties } = object.preview; + const length = max - indexes.length; + + const privateProps = privateProperties.slice(0, length).map(item => { + const value = item.descriptor.value; + const grip = value && value.getGrip ? value.getGrip() : value; + + return PropRep({ + ...props, + keyClassName: "private", + mode: MODE.TINY, + name: item.name, + object: grip, + equal: ": ", + defaultRep: Grip, + title: null, + suppressQuotes: true, + }); + }); + + propsArray.push(...privateProps); + } + + // Show symbols. + if (object.preview && object.preview.ownSymbols) { + const { ownSymbols } = object.preview; + const length = max - indexes.length; + + const symbolsProps = ownSymbols.slice(0, length).map(symbolItem => { + const symbolValue = symbolItem.descriptor.value; + const symbolGrip = + symbolValue && symbolValue.getGrip + ? symbolValue.getGrip() + : symbolValue; + + return PropRep({ + ...props, + mode: MODE.TINY, + name: symbolItem, + object: symbolGrip, + equal: ": ", + defaultRep: Grip, + title: null, + suppressQuotes, + }); + }); + + propsArray.push(...symbolsProps); + } + + if ( + Object.keys(properties).length > max || + propertiesLength > max || + // When the object has non-enumerable properties, we don't have them in the + // packet, but we might want to show there's something in the object. + propertiesLength > propsArray.length + ) { + // There are some undisplayed props. Then display "more...". + propsArray.push( + span( + { + key: "more", + className: "more-ellipsis", + }, + "…" + ) + ); + } + + return propsArray; + } + + /** + * Get props ordered by index. + * + * @param {Object} componentProps Grip Component props. + * @param {Object} properties Properties of the object the Grip describes. + * @param {Array} indexes Indexes of properties. + * @param {Boolean} suppressQuotes true if we should suppress quotes + * on property names. + * @return {Array} Props. + */ + function getProps(componentProps, properties, indexes, suppressQuotes) { + // Make indexes ordered by ascending. + indexes.sort(function (a, b) { + return a - b; + }); + + const propertiesKeys = Object.keys(properties); + return indexes.map(i => { + const name = propertiesKeys[i]; + const value = getPropValue(properties[name]); + + return PropRep({ + ...componentProps, + mode: MODE.TINY, + name, + object: value, + equal: ": ", + defaultRep: Grip, + title: null, + suppressQuotes, + }); + }); + } + + /** + * Get the indexes of props in the object. + * + * @param {Object} properties Props object. + * @param {Number} max The maximum length of indexes array. + * @param {Function} filter Filter the props you want. + * @return {Array} Indexes of interesting props in the object. + */ + function getPropIndexes(properties, max, filter) { + const indexes = []; + + try { + let i = 0; + for (const name in properties) { + if (indexes.length >= max) { + return indexes; + } + + // Type is specified in grip's "class" field and for primitive + // values use typeof. + const value = getPropValue(properties[name]); + let type = value.class || typeof value; + type = type.toLowerCase(); + + if (filter(type, value, name)) { + indexes.push(i); + } + i++; + } + } catch (err) { + console.error(err); + } + return indexes; + } + + /** + * Get the actual value of a property. + * + * @param {Object} property + * @return {Object} Value of the property. + */ + function getPropValue(property) { + let value = property; + if (typeof property === "object") { + const keys = Object.keys(property); + if (keys.includes("value")) { + value = property.value; + } else if (keys.includes("getterValue")) { + value = property.getterValue; + } + } + return value; + } + + // Registration + function supportsObject(object, noGrip = false) { + if (object?.class === "DeadObject") { + return true; + } + + return object?.preview + ? typeof object.preview.ownProperties !== "undefined" + : typeof object?.ownPropertyLength !== "undefined"; + } + + const maxLengthMap = new Map(); + maxLengthMap.set(MODE.SHORT, 3); + maxLengthMap.set(MODE.LONG, 10); + + // Grip is used in propIterator and has to be defined here. + const Grip = { + rep: wrapRender(GripRep), + supportsObject, + maxLengthMap, + }; + + // Exports from this module + module.exports = Grip; +}); diff --git a/devtools/client/shared/components/reps/reps/infinity.js b/devtools/client/shared/components/reps/reps/infinity.js new file mode 100644 index 0000000000..2a36698611 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/infinity.js @@ -0,0 +1,52 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a Infinity object + */ + + InfinityRep.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function InfinityRep(props) { + const { object, shouldRenderTooltip } = props; + + const config = getElementConfig(shouldRenderTooltip, object); + + return span(config, object.type); + } + + function getElementConfig(shouldRenderTooltip, object) { + return { + className: "objectBox objectBox-number", + title: shouldRenderTooltip ? object.type : null, + }; + } + + function supportsObject(object, noGrip = false) { + const type = getGripType(object, noGrip); + return type == "Infinity" || type == "-Infinity"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(InfinityRep), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/moz.build b/devtools/client/shared/components/reps/reps/moz.build new file mode 100644 index 0000000000..30b7e72a73 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/moz.build @@ -0,0 +1,45 @@ +# -*- 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( + "accessible.js", + "accessor.js", + "array.js", + "attribute.js", + "big-int.js", + "comment-node.js", + "constants.js", + "custom-formatter.js", + "date-time.js", + "document-type.js", + "document.js", + "element-node.js", + "error.js", + "event.js", + "function.js", + "grip-array.js", + "grip-entry.js", + "grip-map.js", + "grip.js", + "infinity.js", + "nan.js", + "null.js", + "number.js", + "object-with-text.js", + "object-with-url.js", + "object.js", + "promise.js", + "prop-rep.js", + "regexp.js", + "rep-utils.js", + "rep.js", + "string.js", + "stylesheet.js", + "symbol.js", + "text-node.js", + "undefined.js", + "window.js", +) diff --git a/devtools/client/shared/components/reps/reps/nan.js b/devtools/client/shared/components/reps/reps/nan.js new file mode 100644 index 0000000000..d8b022177c --- /dev/null +++ b/devtools/client/shared/components/reps/reps/nan.js @@ -0,0 +1,51 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a NaN object + */ + + NaNRep.PropTypes = { + shouldRenderTooltip: PropTypes.bool, + }; + + function NaNRep(props) { + const shouldRenderTooltip = props.shouldRenderTooltip; + + const config = getElementConfig(shouldRenderTooltip); + + return span(config, "NaN"); + } + + function getElementConfig(shouldRenderTooltip) { + return { + className: "objectBox objectBox-nan", + title: shouldRenderTooltip ? "NaN" : null, + }; + } + + function supportsObject(object, noGrip = false) { + return getGripType(object, noGrip) == "NaN"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(NaNRep), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/null.js b/devtools/client/shared/components/reps/reps/null.js new file mode 100644 index 0000000000..53dca6b8f3 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/null.js @@ -0,0 +1,59 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders null value + */ + + Null.PropTypes = { + shouldRenderTooltip: PropTypes.bool, + }; + + function Null(props) { + const shouldRenderTooltip = props.shouldRenderTooltip; + + const config = getElementConfig(shouldRenderTooltip); + + return span(config, "null"); + } + + function getElementConfig(shouldRenderTooltip) { + return { + className: "objectBox objectBox-null", + title: shouldRenderTooltip ? "null" : null, + }; + } + + function supportsObject(object, noGrip = false) { + if (noGrip === true) { + return object === null; + } + + if (object && object.type && object.type == "null") { + return true; + } + + return object == null; + } + + // Exports from this module + + module.exports = { + rep: wrapRender(Null), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/number.js b/devtools/client/shared/components/reps/reps/number.js new file mode 100644 index 0000000000..455cb971e7 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/number.js @@ -0,0 +1,63 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a number + */ + + Number.propTypes = { + object: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.number, + PropTypes.bool, + ]).isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function Number(props) { + const value = stringify(props.object); + const config = getElementConfig(props.shouldRenderTooltip, value); + + return span(config, value); + } + + function stringify(object) { + const isNegativeZero = + Object.is(object, -0) || (object.type && object.type == "-0"); + + return isNegativeZero ? "-0" : String(object); + } + + function getElementConfig(shouldRenderTooltip, value) { + return { + className: "objectBox objectBox-number", + title: shouldRenderTooltip ? value : null, + }; + } + + const SUPPORTED_TYPES = new Set(["boolean", "number", "-0"]); + function supportsObject(object, noGrip = false) { + return SUPPORTED_TYPES.has(getGripType(object, noGrip)); + } + + // Exports from this module + + module.exports = { + rep: wrapRender(Number), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/object-with-text.js b/devtools/client/shared/components/reps/reps/object-with-text.js new file mode 100644 index 0000000000..5dc3f27ae8 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/object-with-text.js @@ -0,0 +1,70 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + const String = + require("devtools/client/shared/components/reps/reps/string").rep; + + /** + * Renders a grip object with textual data. + */ + + ObjectWithText.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function ObjectWithText(props) { + const grip = props.object; + const config = getElementConfig(props); + + return span(config, `${getType(grip)} `, getDescription(grip)); + } + + function getElementConfig(opts) { + const shouldRenderTooltip = opts.shouldRenderTooltip; + const grip = opts.object; + + return { + "data-link-actor-id": grip.actor, + className: `objectTitle objectBox objectBox-${getType(grip)}`, + title: shouldRenderTooltip + ? `${getType(grip)} "${grip.preview.text}"` + : null, + }; + } + + function getType(grip) { + return grip.class; + } + + function getDescription(grip) { + return String({ + object: grip.preview.text, + }); + } + + // Registration + function supportsObject(grip) { + return grip?.preview?.kind == "ObjectWithText"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(ObjectWithText), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/object-with-url.js b/devtools/client/shared/components/reps/reps/object-with-url.js new file mode 100644 index 0000000000..0d0660cacf --- /dev/null +++ b/devtools/client/shared/components/reps/reps/object-with-url.js @@ -0,0 +1,73 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getURLDisplayString, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a grip object with URL data. + */ + + ObjectWithURL.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function ObjectWithURL(props) { + const grip = props.object; + const config = getElementConfig(props); + + return span( + config, + getTitle(grip), + span({ className: "objectPropValue" }, getDescription(grip)) + ); + } + + function getElementConfig(opts) { + const grip = opts.object; + const shouldRenderTooltip = opts.shouldRenderTooltip; + const tooltip = `${getType(grip)} ${getDescription(grip)}`; + + return { + "data-link-actor-id": grip.actor, + className: `objectBox objectBox-${getType(grip)}`, + title: shouldRenderTooltip ? tooltip : null, + }; + } + + function getTitle(grip) { + return span({ className: "objectTitle" }, `${getType(grip)} `); + } + + function getType(grip) { + return grip.class; + } + + function getDescription(grip) { + return getURLDisplayString(grip.preview.url); + } + + // Registration + function supportsObject(grip) { + return grip?.preview?.kind == "ObjectWithURL"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(ObjectWithURL), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/object.js b/devtools/client/shared/components/reps/reps/object.js new file mode 100644 index 0000000000..bdd98cfd04 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/object.js @@ -0,0 +1,207 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const { + wrapRender, + ellipsisElement, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const PropRep = require("devtools/client/shared/components/reps/reps/prop-rep"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + const DEFAULT_TITLE = "Object"; + + /** + * Renders an object. An object is represented by a list of its + * properties enclosed in curly brackets. + */ + + ObjectRep.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + title: PropTypes.string, + shouldRenderTooltip: PropTypes.bool, + }; + + function ObjectRep(props) { + const object = props.object; + const { shouldRenderTooltip = true } = props; + + if (props.mode === MODE.TINY) { + const tinyModeItems = []; + if (getTitle(props) !== DEFAULT_TITLE) { + tinyModeItems.push(getTitleElement(props)); + } else { + tinyModeItems.push( + span( + { + className: "objectLeftBrace", + }, + "{" + ), + Object.keys(object).length ? ellipsisElement : null, + span( + { + className: "objectRightBrace", + }, + "}" + ) + ); + } + + return span( + { + className: "objectBox objectBox-object", + title: shouldRenderTooltip ? getTitle(props) : null, + }, + ...tinyModeItems + ); + } + + const propsArray = safePropIterator(props, object); + + return span( + { + className: "objectBox objectBox-object", + title: shouldRenderTooltip ? getTitle(props) : null, + }, + getTitleElement(props), + span( + { + className: "objectLeftBrace", + }, + " { " + ), + ...propsArray, + span( + { + className: "objectRightBrace", + }, + " }" + ) + ); + } + + function getTitleElement(props) { + return span({ className: "objectTitle" }, getTitle(props)); + } + + function getTitle(props) { + return props.title || DEFAULT_TITLE; + } + + function safePropIterator(props, object, max) { + max = typeof max === "undefined" ? 3 : max; + try { + return propIterator(props, object, max); + } catch (err) { + console.error(err); + } + return []; + } + + function propIterator(props, object, max) { + // Work around https://bugzilla.mozilla.org/show_bug.cgi?id=945377 + if (Object.prototype.toString.call(object) === "[object Generator]") { + object = Object.getPrototypeOf(object); + } + + const elements = []; + const unimportantProperties = []; + let propertiesNumber = 0; + const propertiesNames = Object.keys(object); + + const pushPropRep = (name, value) => { + elements.push( + PropRep({ + ...props, + key: name, + mode: MODE.TINY, + name, + object: value, + equal: ": ", + }) + ); + propertiesNumber++; + + if (propertiesNumber < propertiesNames.length) { + elements.push(", "); + } + }; + + try { + for (const name of propertiesNames) { + if (propertiesNumber >= max) { + break; + } + + let value; + try { + value = object[name]; + } catch (exc) { + continue; + } + + // Object members with non-empty values are preferred since it gives the + // user a better overview of the object. + if (isInterestingProp(value)) { + pushPropRep(name, value); + } else { + // If the property is not important, put its name on an array for later + // use. + unimportantProperties.push(name); + } + } + } catch (err) { + console.error(err); + } + + if (propertiesNumber < max) { + for (const name of unimportantProperties) { + if (propertiesNumber >= max) { + break; + } + + let value; + try { + value = object[name]; + } catch (exc) { + continue; + } + + pushPropRep(name, value); + } + } + + if (propertiesNumber < propertiesNames.length) { + elements.push(ellipsisElement); + } + + return elements; + } + + function isInterestingProp(value) { + const type = typeof value; + return type == "boolean" || type == "number" || (type == "string" && value); + } + + function supportsObject(object, noGrip = false) { + return noGrip; + } + + // Exports from this module + module.exports = { + rep: wrapRender(ObjectRep), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/promise.js b/devtools/client/shared/components/reps/reps/promise.js new file mode 100644 index 0000000000..186f4201eb --- /dev/null +++ b/devtools/client/shared/components/reps/reps/promise.js @@ -0,0 +1,105 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Dependencies + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + const Grip = require("devtools/client/shared/components/reps/reps/grip"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + /** + * Renders a DOM Promise object. + */ + + PromiseRep.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + shouldRenderTooltip: PropTypes.bool, + }; + + function PromiseRep(props) { + const object = props.object; + + // @backward-compat { version 85 } On older servers, the preview of a promise was + // useless and didn't include the internal promise state, which was directly exposed + // in the grip. + if (object.promiseState) { + const { state, value, reason } = object.promiseState; + const ownProperties = Object.create(null); + ownProperties["<state>"] = { value: state }; + let ownPropertiesLength = 1; + if (state == "fulfilled") { + ownProperties["<value>"] = { value }; + ++ownPropertiesLength; + } else if (state == "rejected") { + ownProperties["<reason>"] = { value: reason }; + ++ownPropertiesLength; + } + object.preview = { + kind: "Object", + ownProperties, + ownPropertiesLength, + }; + } + + if (props.mode !== MODE.TINY && props.mode !== MODE.HEADER) { + return Grip.rep(props); + } + + const shouldRenderTooltip = props.shouldRenderTooltip; + const config = { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-object", + title: shouldRenderTooltip ? "Promise" : null, + }; + + if (props.mode === MODE.HEADER) { + return span(config, getTitle(object)); + } + + const { Rep } = require("devtools/client/shared/components/reps/reps/rep"); + + return span( + config, + getTitle(object), + span({ className: "objectLeftBrace" }, " { "), + Rep({ object: object.preview.ownProperties["<state>"].value }), + span({ className: "objectRightBrace" }, " }") + ); + } + + function getTitle(object) { + return span({ className: "objectTitle" }, object.class); + } + + // Registration + function supportsObject(object, noGrip = false) { + if (!Grip.supportsObject(object, noGrip)) { + return false; + } + return getGripType(object, noGrip) == "Promise"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(PromiseRep), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/prop-rep.js b/devtools/client/shared/components/reps/reps/prop-rep.js new file mode 100644 index 0000000000..271aea1e21 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/prop-rep.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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const { + appendRTLClassNameIfNeeded, + maybeEscapePropertyName, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + /** + * Property for Obj (local JS objects), Grip (remote JS objects) + * and GripMap (remote JS maps and weakmaps) reps. + * It's used to render object properties. + */ + PropRep.propTypes = { + // Additional class to set on the key element + keyClassName: PropTypes.string, + // Property name. + name: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, + // Equal character rendered between property name and value. + equal: PropTypes.string, + mode: PropTypes.oneOf(Object.values(MODE)), + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + // Normally a PropRep will quote a property name that isn't valid + // when unquoted; but this flag can be used to suppress the + // quoting. + suppressQuotes: PropTypes.bool, + shouldRenderTooltip: PropTypes.bool, + }; + + /** + * Function that given a name, a delimiter and an object returns an array + * of React elements representing an object property (e.g. `name: value`) + * + * @param {Object} props + * @return {Array} Array of React elements. + */ + + function PropRep(props) { + const Grip = require("devtools/client/shared/components/reps/reps/grip"); + const { Rep } = require("devtools/client/shared/components/reps/reps/rep"); + + let { + equal, + keyClassName, + mode, + name, + shouldRenderTooltip, + suppressQuotes, + } = props; + + const className = `nodeName${keyClassName ? " " + keyClassName : ""}`; + + let key; + // The key can be a simple string, for plain objects, + // or another object for maps and weakmaps. + if (typeof name === "string") { + if (!suppressQuotes) { + name = maybeEscapePropertyName(name); + } + key = span( + { + className: appendRTLClassNameIfNeeded(className, name), + title: shouldRenderTooltip ? name : null, + }, + name + ); + } else { + key = Rep({ + ...props, + className, + object: name, + mode: mode || MODE.TINY, + defaultRep: Grip, + }); + } + + return [ + key, + span( + { + className: "objectEqual", + }, + equal + ), + Rep({ ...props }), + ]; + } + + // Exports from this module + module.exports = wrapRender(PropRep); +}); diff --git a/devtools/client/shared/components/reps/reps/regexp.js b/devtools/client/shared/components/reps/reps/regexp.js new file mode 100644 index 0000000000..fc753e0fca --- /dev/null +++ b/devtools/client/shared/components/reps/reps/regexp.js @@ -0,0 +1,66 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getGripType, + wrapRender, + ELLIPSIS, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a grip object with regular expression. + */ + + RegExp.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function RegExp(props) { + const { object } = props; + const config = getElementConfig(props); + + return span(config, getSource(object)); + } + + function getElementConfig(opts) { + const { object, shouldRenderTooltip } = opts; + const text = getSource(object); + + return { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-regexp regexpSource", + title: shouldRenderTooltip ? text : null, + }; + } + + function getSource(grip) { + const { displayString } = grip; + if (displayString?.type === "longString") { + return `${displayString.initial}${ELLIPSIS}`; + } + + return displayString; + } + + // Registration + function supportsObject(object, noGrip = false) { + return getGripType(object, noGrip) == "RegExp"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(RegExp), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/rep-utils.js b/devtools/client/shared/components/reps/reps/rep-utils.js new file mode 100644 index 0000000000..6d148bb06b --- /dev/null +++ b/devtools/client/shared/components/reps/reps/rep-utils.js @@ -0,0 +1,596 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const validProtocols = /(http|https|ftp|data|resource|chrome):/i; + + // URL Regex, common idioms: + // + // Lead-in (URL): + // ( Capture because we need to know if there was a lead-in + // character so we can include it as part of the text + // preceding the match. We lack look-behind matching. + // ^| The URL can start at the beginning of the string. + // [\s(,;'"`“] Or whitespace or some punctuation that does not imply + // a context which would preclude a URL. + // ) + // + // We do not need a trailing look-ahead because our regex's will terminate + // because they run out of characters they can eat. + + // What we do not attempt to have the regexp do: + // - Avoid trailing '.' and ')' characters. We let our greedy match absorb + // these, but have a separate regex for extra characters to leave off at the + // end. + // + // The Regex (apart from lead-in/lead-out): + // ( Begin capture of the URL + // (?: (potential detect beginnings) + // https?:\/\/| Start with "http" or "https" + // www\d{0,3}[.][a-z0-9.\-]{2,249}| + // Start with "www", up to 3 numbers, then "." then + // something that looks domain-namey. We differ from the + // next case in that we do not constrain the top-level + // domain as tightly and do not require a trailing path + // indicator of "/". This is IDN root compatible. + // [a-z0-9.\-]{2,250}[.][a-z]{2,4}\/ + // Detect a non-www domain, but requiring a trailing "/" + // to indicate a path. This only detects IDN domains + // with a non-IDN root. This is reasonable in cases where + // there is no explicit http/https start us out, but + // unreasonable where there is. Our real fix is the bug + // to port the Thunderbird/gecko linkification logic. + // + // Domain names can be up to 253 characters long, and are + // limited to a-zA-Z0-9 and '-'. The roots don't have + // hyphens unless they are IDN roots. Root zones can be + // found here: http://www.iana.org/domains/root/db + // ) + // [-\w.!~*'();,/?:@&=+$#%]* + // path onwards. We allow the set of characters that + // encodeURI does not escape plus the result of escaping + // (so also '%') + // ) + // eslint-disable-next-line max-len + const urlRegex = + /(^|[\s(,;'"`“])((?:https?:\/(\/)?|www\d{0,3}[.][a-z0-9.\-]{2,249}|[a-z0-9.\-]{2,250}[.][a-z]{2,4}\/)[-\w.!~*'();,/?:@&=+$#%]*)/im; + + // Set of terminators that are likely to have been part of the context rather + // than part of the URL and so should be uneaten. This is '(', ',', ';', plus + // quotes and question end-ing punctuation and the potential permutations with + // parentheses (english-specific). + const uneatLastUrlCharsRegex = /(?:[),;.!?`'"]|[.!?]\)|\)[.!?])$/; + + const ELLIPSIS = "\u2026"; + const dom = require("devtools/client/shared/vendor/react-dom-factories"); + const { span } = dom; + + function escapeNewLines(value) { + return value.replace(/\r/gm, "\\r").replace(/\n/gm, "\\n"); + } + + // Map from character code to the corresponding escape sequence. \0 + // isn't here because it would require special treatment in some + // situations. \b, \f, and \v aren't here because they aren't very + // common. \' isn't here because there's no need, we only + // double-quote strings. + const escapeMap = { + // Tab. + 9: "\\t", + // Newline. + 0xa: "\\n", + // Carriage return. + 0xd: "\\r", + // Quote. + 0x22: '\\"', + // Backslash. + 0x5c: "\\\\", + }; + + // All characters we might possibly want to escape, excluding quotes. + // Note that we over-match here, because it's difficult to, say, match + // an unpaired surrogate with a regexp. The details are worked out by + // the replacement function; see |escapeString|. + const commonEscapes = + // Backslash. + "\\\\" + + // Controls. + "\x00-\x1f" + + // More controls. + "\x7f-\x9f" + + // BOM + "\ufeff" + + // Specials, except for the replacement character. + "\ufff0-\ufffc\ufffe\uffff" + + // Surrogates. + "\ud800-\udfff" + + // Mathematical invisibles. + "\u2061-\u2064" + + // Line and paragraph separators. + "\u2028-\u2029" + + // Private use area. + "\ue000-\uf8ff"; + const escapeRegexp = new RegExp(`[${commonEscapes}]`, "g"); + const escapeRegexpIncludingDoubleQuote = new RegExp( + `[${commonEscapes}"]`, + "g" + ); + + /** + * Escape a string so that the result is viewable and valid JS. + * Control characters, other invisibles, invalid characters, and backslash + * are escaped. The resulting string is quoted with either double quotes, + * single quotes, or backticks. The preference is for a quote that doesn't + * require escaping, falling back to double quotes if that's not possible + * (and then escaping them in the string). + * + * @param {String} str + * the input + * @param {Boolean} escapeWhitespace + * if true, TAB, CR, and NL characters will be escaped + * @return {String} the escaped string + */ + function escapeString(str, escapeWhitespace) { + let quote = '"'; + let regexp = escapeRegexp; + if (str.includes('"')) { + if (!str.includes("'")) { + quote = "'"; + } else if (!str.includes("`") && !str.includes("${")) { + quote = "`"; + } else { + regexp = escapeRegexpIncludingDoubleQuote; + } + } + return `${quote}${str.replace(regexp, (match, offset) => { + const c = match.charCodeAt(0); + if (c in escapeMap) { + if (!escapeWhitespace && (c === 9 || c === 0xa || c === 0xd)) { + return match[0]; + } + return escapeMap[c]; + } + if (c >= 0xd800 && c <= 0xdfff) { + // Find the full code point containing the surrogate, with a + // special case for a trailing surrogate at the start of the + // string. + if (c >= 0xdc00 && offset > 0) { + --offset; + } + const codePoint = str.codePointAt(offset); + if (codePoint >= 0xd800 && codePoint <= 0xdfff) { + // Unpaired surrogate. + return `\\u${codePoint.toString(16)}`; + } else if (codePoint >= 0xf0000 && codePoint <= 0x10fffd) { + // Private use area. Because we visit each pair of a such a + // character, return the empty string for one half and the + // real result for the other, to avoid duplication. + if (c <= 0xdbff) { + return `\\u{${codePoint.toString(16)}}`; + } + return ""; + } + // Other surrogate characters are passed through. + return match; + } + return `\\u${`0000${c.toString(16)}`.substr(-4)}`; + })}${quote}`; + } + + /** + * Escape a property name, if needed. "Escaping" in this context + * means surrounding the property name with quotes. + * + * @param {String} + * name the property name + * @return {String} either the input, or the input surrounded by + * quotes, properly quoted in JS syntax. + */ + function maybeEscapePropertyName(name) { + // Quote the property name if it needs quoting. This particular + // test is an approximation; see + // https://mathiasbynens.be/notes/javascript-properties. However, + // the full solution requires a fair amount of Unicode data, and so + // let's defer that until either it's important, or the \p regexp + // syntax lands, see + // https://github.com/tc39/proposal-regexp-unicode-property-escapes. + if (!/^\w+$/.test(name)) { + name = escapeString(name); + } + return name; + } + + function cropMultipleLines(text, limit) { + return escapeNewLines(cropString(text, limit)); + } + + function rawCropString(text, limit, alternativeText = ELLIPSIS) { + // Crop the string only if a limit is actually specified. + if (!limit || limit <= 0) { + return text; + } + + // Set the limit at least to the length of the alternative text + // plus one character of the original text. + if (limit <= alternativeText.length) { + limit = alternativeText.length + 1; + } + + const halfLimit = (limit - alternativeText.length) / 2; + + if (text.length > limit) { + return ( + text.substr(0, Math.ceil(halfLimit)) + + alternativeText + + text.substr(text.length - Math.floor(halfLimit)) + ); + } + + return text; + } + + function cropString(text, limit, alternativeText) { + return rawCropString(sanitizeString(`${text}`), limit, alternativeText); + } + + function sanitizeString(text) { + // Replace all non-printable characters, except of + // (horizontal) tab (HT: \x09) and newline (LF: \x0A, CR: \x0D), + // with unicode replacement character (u+fffd). + // eslint-disable-next-line no-control-regex + const re = new RegExp("[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]", "g"); + return text.replace(re, "\ufffd"); + } + + function parseURLParams(url) { + url = new URL(url); + return parseURLEncodedText(url.searchParams); + } + + function parseURLEncodedText(text) { + const params = []; + + // In case the text is empty just return the empty parameters + if (text == "") { + return params; + } + + const searchParams = new URLSearchParams(text); + const entries = [...searchParams.entries()]; + return entries.map(entry => { + return { + name: entry[0], + value: entry[1], + }; + }); + } + + function getFileName(url) { + const split = splitURLBase(url); + return split.name; + } + + function splitURLBase(url) { + if (!isDataURL(url)) { + return splitURLTrue(url); + } + return {}; + } + + function getURLDisplayString(url) { + return cropString(url); + } + + function isDataURL(url) { + return url && url.substr(0, 5) == "data:"; + } + + function splitURLTrue(url) { + const reSplitFile = /(.*?):\/{2,3}([^\/]*)(.*?)([^\/]*?)($|\?.*)/; + const m = reSplitFile.exec(url); + + if (!m) { + return { + name: url, + path: url, + }; + } else if (m[4] == "" && m[5] == "") { + return { + protocol: m[1], + domain: m[2], + path: m[3], + name: m[3] != "/" ? m[3] : m[2], + }; + } + + return { + protocol: m[1], + domain: m[2], + path: m[2] + m[3], + name: m[4] + m[5], + }; + } + + /** + * Wrap the provided render() method of a rep in a try/catch block that will + * render a fallback rep if the render fails. + */ + function wrapRender(renderMethod) { + const wrappedFunction = function (props) { + try { + return renderMethod.call(this, props); + } catch (e) { + console.error(e); + return span( + { + className: "objectBox objectBox-failure", + title: + "This object could not be rendered, " + + "please file a bug on bugzilla.mozilla.org", + }, + /* Labels have to be hardcoded for reps, see Bug 1317038. */ + "Invalid object" + ); + } + }; + wrappedFunction.propTypes = renderMethod.propTypes; + return wrappedFunction; + } + + /** + * Get preview items from a Grip. + * + * @param {Object} Grip from which we want the preview items + * @return {Array} Array of the preview items of the grip, or an empty array + * if the grip does not have preview items + */ + function getGripPreviewItems(grip) { + if (!grip) { + return []; + } + + // Array Grip + if (grip.preview && grip.preview.items) { + return grip.preview.items; + } + + // Node Grip + if (grip.preview && grip.preview.childNodes) { + return grip.preview.childNodes; + } + + // Set or Map Grip + if (grip.preview && grip.preview.entries) { + return grip.preview.entries.reduce((res, entry) => res.concat(entry), []); + } + + // Event Grip + if (grip.preview && grip.preview.target) { + const keys = Object.keys(grip.preview.properties); + const values = Object.values(grip.preview.properties); + return [grip.preview.target, ...keys, ...values]; + } + + // RegEx Grip + if (grip.displayString) { + return [grip.displayString]; + } + + // Generic Grip + if (grip.preview && grip.preview.ownProperties) { + let propertiesValues = Object.values(grip.preview.ownProperties).map( + property => property.value || property + ); + + const propertyKeys = Object.keys(grip.preview.ownProperties); + propertiesValues = propertiesValues.concat(propertyKeys); + + // ArrayBuffer Grip + if (grip.preview.safeGetterValues) { + propertiesValues = propertiesValues.concat( + Object.values(grip.preview.safeGetterValues).map( + property => property.getterValue || property + ) + ); + } + + return propertiesValues; + } + + return []; + } + + /** + * Get the type of an object. + * + * @param {Object} Grip from which we want the type. + * @param {boolean} noGrip true if the object is not a grip. + * @return {boolean} + */ + function getGripType(object, noGrip) { + if (noGrip || Object(object) !== object) { + return typeof object; + } + if (object.type === "object") { + return object.class; + } + return object.type; + } + + /** + * Determines whether a grip is a string containing a URL. + * + * @param string grip + * The grip, which may contain a URL. + * @return boolean + * Whether the grip is a string containing a URL. + */ + function containsURL(grip) { + // An URL can't be shorter than 5 char (e.g. "ftp:"). + if (typeof grip !== "string" || grip.length < 5) { + return false; + } + + return validProtocols.test(grip); + } + + /** + * Determines whether a string token is a valid URL. + * + * @param string token + * The token. + * @return boolean + * Whether the token is a URL. + */ + function isURL(token) { + try { + if (!validProtocols.test(token)) { + return false; + } + new URL(token); + return true; + } catch (e) { + return false; + } + } + + /** + * Returns new array in which `char` are interleaved between the original items. + * + * @param {Array} items + * @param {String} char + * @returns Array + */ + function interleave(items, char) { + return items.reduce((res, item, index) => { + if (index !== items.length - 1) { + return res.concat(item, char); + } + return res.concat(item); + }, []); + } + + const ellipsisElement = span( + { + key: "more", + className: "more-ellipsis", + title: `more${ELLIPSIS}`, + }, + ELLIPSIS + ); + + /** + * Removes any unallowed CSS properties from a string of CSS declarations + * + * @param {String} userProvidedStyle CSS declarations + * @param {Function} createElement Method to create a dummy element the styles get applied to + * @returns {Object} Filtered CSS properties as JavaScript object in camelCase notation + */ + function cleanupStyle(userProvidedStyle, createElement) { + // Regular expression that matches the allowed CSS property names. + const allowedStylesRegex = new RegExp( + "^(?:-moz-)?(?:align|background|border|box|clear|color|cursor|display|" + + "float|font|justify|line|margin|padding|position|text|transition" + + "|outline|vertical-align|white-space|word|writing|" + + "(?:min-|max-)?width|(?:min-|max-)?height)" + ); + + const mozElementRegex = /\b((?:-moz-)?element)[\s('"]+/gi; + + // Regex to retrieve usages of `url(*)` in property value + const cssUrlRegex = /url\([\'\"]?([^\)]*)/g; + + // Use a dummy element to parse the style string. + const dummy = createElement("div"); + dummy.style = userProvidedStyle; + + // Return a style object as expected by React DOM components, e.g. + // {color: "red"} + // without forbidden properties and values. + return Array.from(dummy.style) + .filter(name => { + if (!allowedStylesRegex.test(name)) { + return false; + } + + if (mozElementRegex.test(name)) { + return false; + } + + if (name === "position") { + return ["static", "relative"].includes( + dummy.style.getPropertyValue(name) + ); + } + // There can be multiple call to `url()` (e.g.` background: url("path/to/image"), url("data:image/png,…");`); + // filter out the property if the url function is called with anything that is not + // a data URL. + return Array.from(dummy.style[name].matchAll(cssUrlRegex)) + .map(match => match[1]) + .every(potentialUrl => potentialUrl.startsWith("data:")); + }) + .reduce((object, name) => { + // React requires CSS properties to be provided in JavaScript form, i.e. camelCased. + const jsName = name.replace(/-([a-z])/g, (_, char) => + char.toUpperCase() + ); + return Object.assign( + { + [jsName]: dummy.style.getPropertyValue(name), + }, + object + ); + }, {}); + } + + /** + * Append has-rtl-char to className if passed string has RTL chars. + * has-rtl-char is used in reps.css to set `unicode-bidi: isolate` on the element. + * It's important to only apply it when needed as this CSS property can have an + * important impact on performance (See Bug 1879806) + * + * @param {String} className: The className want to set on an element + * @param {String} strToCheck: The string for which we want to check if it has RTL chars + * @returns {String} + */ + function appendRTLClassNameIfNeeded(className = "", strToCheck) { + if ( + // The JSONViewer, which uses some Reps component, doesn't have access to Services. + typeof Services == "undefined" || + !Services?.intl?.stringHasRTLChars(strToCheck) + ) { + return className; + } + return `${className} has-rtl-char`; + } + + module.exports = { + interleave, + isURL, + cropString, + containsURL, + rawCropString, + appendRTLClassNameIfNeeded, + sanitizeString, + escapeString, + wrapRender, + cropMultipleLines, + parseURLParams, + parseURLEncodedText, + getFileName, + getURLDisplayString, + maybeEscapePropertyName, + getGripPreviewItems, + getGripType, + ellipsisElement, + ELLIPSIS, + uneatLastUrlCharsRegex, + urlRegex, + cleanupStyle, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/rep.js b/devtools/client/shared/components/reps/reps/rep.js new file mode 100644 index 0000000000..10d719c42c --- /dev/null +++ b/devtools/client/shared/components/reps/reps/rep.js @@ -0,0 +1,205 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Load all existing rep templates + const Undefined = require("devtools/client/shared/components/reps/reps/undefined"); + const Null = require("devtools/client/shared/components/reps/reps/null"); + const StringRep = require("devtools/client/shared/components/reps/reps/string"); + const Number = require("devtools/client/shared/components/reps/reps/number"); + const ArrayRep = require("devtools/client/shared/components/reps/reps/array"); + const Obj = require("devtools/client/shared/components/reps/reps/object"); + const SymbolRep = require("devtools/client/shared/components/reps/reps/symbol"); + const InfinityRep = require("devtools/client/shared/components/reps/reps/infinity"); + const NaNRep = require("devtools/client/shared/components/reps/reps/nan"); + const Accessor = require("devtools/client/shared/components/reps/reps/accessor"); + + // DOM types (grips) + const Accessible = require("devtools/client/shared/components/reps/reps/accessible"); + const Attribute = require("devtools/client/shared/components/reps/reps/attribute"); + const BigInt = require("devtools/client/shared/components/reps/reps/big-int"); + const DateTime = require("devtools/client/shared/components/reps/reps/date-time"); + const Document = require("devtools/client/shared/components/reps/reps/document"); + const DocumentType = require("devtools/client/shared/components/reps/reps/document-type"); + const Event = require("devtools/client/shared/components/reps/reps/event"); + const Func = require("devtools/client/shared/components/reps/reps/function"); + const PromiseRep = require("devtools/client/shared/components/reps/reps/promise"); + const RegExp = require("devtools/client/shared/components/reps/reps/regexp"); + const StyleSheet = require("devtools/client/shared/components/reps/reps/stylesheet"); + const CommentNode = require("devtools/client/shared/components/reps/reps/comment-node"); + const ElementNode = require("devtools/client/shared/components/reps/reps/element-node"); + const TextNode = require("devtools/client/shared/components/reps/reps/text-node"); + const ErrorRep = require("devtools/client/shared/components/reps/reps/error"); + const Window = require("devtools/client/shared/components/reps/reps/window"); + const ObjectWithText = require("devtools/client/shared/components/reps/reps/object-with-text"); + const ObjectWithURL = require("devtools/client/shared/components/reps/reps/object-with-url"); + const GripArray = require("devtools/client/shared/components/reps/reps/grip-array"); + const GripEntry = require("devtools/client/shared/components/reps/reps/grip-entry"); + const GripMap = require("devtools/client/shared/components/reps/reps/grip-map"); + const Grip = require("devtools/client/shared/components/reps/reps/grip"); + + // List of all registered template. + // XXX there should be a way for extensions to register a new + // or modify an existing rep. + const reps = [ + RegExp, + StyleSheet, + Event, + DateTime, + CommentNode, + Accessible, + ElementNode, + TextNode, + Attribute, + Func, + PromiseRep, + Document, + DocumentType, + Window, + ObjectWithText, + ObjectWithURL, + ErrorRep, + GripArray, + GripMap, + GripEntry, + Grip, + Undefined, + Null, + StringRep, + Number, + BigInt, + SymbolRep, + InfinityRep, + NaNRep, + Accessor, + ]; + + // Reps for rendering of native object reference (e.g. used from the JSONViewer, Netmonitor, …) + const noGripReps = [StringRep, Number, ArrayRep, Undefined, Null, Obj]; + + /** + * Generic rep that is used for rendering native JS types or an object. + * The right template used for rendering is picked automatically according + * to the current value type. The value must be passed in as the 'object' + * property. + */ + const Rep = function (props) { + const { object, defaultRep } = props; + const rep = getRep( + object, + defaultRep, + props.noGrip, + props.mayUseCustomFormatter + ); + return rep(props); + }; + + const exportedReps = { + Accessible, + Accessor, + ArrayRep, + Attribute, + BigInt, + CommentNode, + DateTime, + Document, + DocumentType, + ElementNode, + ErrorRep, + Event, + Func, + Grip, + GripArray, + GripMap, + GripEntry, + InfinityRep, + NaNRep, + Null, + Number, + Obj, + ObjectWithText, + ObjectWithURL, + PromiseRep, + RegExp, + Rep, + StringRep, + StyleSheet, + SymbolRep, + TextNode, + Undefined, + Window, + }; + + // Custom Formatters + // Services.prefs isn't available in jsonviewer. It doesn't matter as we don't want to use + // custom formatters there + if (typeof Services == "object" && Services?.prefs) { + const useCustomFormatters = Services.prefs.getBoolPref( + "devtools.custom-formatters.enabled", + false + ); + + if (useCustomFormatters) { + const CustomFormatter = require("devtools/client/shared/components/reps/reps/custom-formatter"); + reps.unshift(CustomFormatter); + exportedReps.CustomFormatter = CustomFormatter; + } + } + + // Helpers + + /** + * Return a rep object that is responsible for rendering given + * object. + * + * @param object {Object} Object to be rendered in the UI. This + * can be generic JS object as well as a grip (handle to a remote + * debuggee object). + * + * @param defaultRep {React.Component} The default template + * that should be used to render given object if none is found. + * + * @param noGrip {Boolean} If true, will only check reps not made for remote + * objects. + * + * @param mayUseCustomFormatter {Boolean} If true, custom formatters are + * allowed to be used as rep. + */ + function getRep( + object, + defaultRep = Grip, + noGrip = false, + mayUseCustomFormatter = false + ) { + const repsList = noGrip ? noGripReps : reps; + for (const rep of repsList) { + if (rep === exportedReps.CustomFormatter && !mayUseCustomFormatter) { + continue; + } + + try { + // supportsObject could return weight (not only true/false + // but a number), which would allow to priorities templates and + // support better extensibility. + if (rep.supportsObject(object, noGrip)) { + return rep.rep; + } + } catch (err) { + console.error(err); + } + } + + return defaultRep.rep; + } + + module.exports = { + Rep, + REPS: exportedReps, + // Exporting for tests + getRep, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/string.js b/devtools/client/shared/components/reps/reps/string.js new file mode 100644 index 0000000000..3149f4a51b --- /dev/null +++ b/devtools/client/shared/components/reps/reps/string.js @@ -0,0 +1,410 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const { + a, + span, + } = require("devtools/client/shared/vendor/react-dom-factories"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + const { + appendRTLClassNameIfNeeded, + containsURL, + escapeString, + getGripType, + rawCropString, + sanitizeString, + wrapRender, + ELLIPSIS, + uneatLastUrlCharsRegex, + urlRegex, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a string. String value is enclosed within quotes. + */ + + StringRep.propTypes = { + useQuotes: PropTypes.bool, + escapeWhitespace: PropTypes.bool, + style: PropTypes.object, + cropLimit: PropTypes.number.isRequired, + urlCropLimit: PropTypes.number, + member: PropTypes.object, + object: PropTypes.object.isRequired, + openLink: PropTypes.func, + className: PropTypes.string, + title: PropTypes.string, + isInContentPage: PropTypes.bool, + shouldRenderTooltip: PropTypes.bool, + }; + + function StringRep(props) { + const { + className, + style, + cropLimit, + urlCropLimit, + object, + useQuotes = true, + escapeWhitespace = true, + member, + openLink, + title, + isInContentPage, + transformEmptyString = false, + shouldRenderTooltip, + } = props; + + let text = object; + const config = getElementConfig({ + className, + style, + actor: object.actor, + title, + }); + + if (text == "" && transformEmptyString && !useQuotes) { + return span( + { + ...config, + title: "<empty string>", + className: `${config.className} objectBox-empty-string`, + }, + "<empty string>" + ); + } + + const isLong = isLongString(object); + const isOpen = member && member.open; + const shouldCrop = !isOpen && cropLimit && text.length > cropLimit; + + if (isLong) { + text = maybeCropLongString( + { + shouldCrop, + cropLimit, + }, + text + ); + + const { fullText } = object; + if (isOpen && fullText) { + text = fullText; + } + } + + text = formatText( + { + useQuotes, + escapeWhitespace, + }, + text + ); + + if (shouldRenderTooltip) { + config.title = text; + } + + if (!isLong) { + if (containsURL(text)) { + return span( + config, + getLinkifiedElements({ + text, + cropLimit: shouldCrop ? cropLimit : null, + urlCropLimit, + openLink, + isInContentPage, + }) + ); + } + + // Cropping of longString has been handled before formatting. + text = maybeCropString( + { + isLong, + shouldCrop, + cropLimit, + }, + text + ); + } + + config.className = appendRTLClassNameIfNeeded(config.className, text); + + return span(config, text); + } + + function maybeCropLongString(opts, object) { + const { shouldCrop, cropLimit } = opts; + + const grip = object && object.getGrip ? object.getGrip() : object; + const { initial, length } = grip; + + let text = shouldCrop ? initial.substring(0, cropLimit) : initial; + + if (text.length < length) { + text += ELLIPSIS; + } + + return text; + } + + function formatText(opts, text) { + const { useQuotes, escapeWhitespace } = opts; + + return useQuotes + ? escapeString(text, escapeWhitespace) + : sanitizeString(text); + } + + function getElementConfig(opts) { + const { className, style, actor, title } = opts; + + const config = {}; + + if (actor) { + config["data-link-actor-id"] = actor; + } + + if (title) { + config.title = title; + } + + const classNames = ["objectBox", "objectBox-string"]; + if (className) { + classNames.push(className); + } + config.className = classNames.join(" "); + + if (style) { + config.style = style; + } + + return config; + } + + function maybeCropString(opts, text) { + const { shouldCrop, cropLimit } = opts; + + return shouldCrop ? rawCropString(text, cropLimit) : text; + } + + /** + * Get an array of the elements representing the string, cropped if needed, + * with actual links. + * + * @param {Object} An options object of the following shape: + * - text {String}: The actual string to linkify. + * - cropLimit {Integer}: The limit to apply on the whole text. + * - urlCropLimit {Integer}: The limit to apply on each URL. + * - openLink {Function} openLink: Function handling the link + * opening. + * - isInContentPage {Boolean}: pass true if the reps is + * rendered in the content page + * (e.g. in JSONViewer). + * @returns {Array<String|ReactElement>} + */ + function getLinkifiedElements({ + text, + cropLimit, + urlCropLimit, + openLink, + isInContentPage, + }) { + const halfLimit = Math.ceil((cropLimit - ELLIPSIS.length) / 2); + const startCropIndex = cropLimit ? halfLimit : null; + const endCropIndex = cropLimit ? text.length - halfLimit : null; + + const items = []; + let currentIndex = 0; + let contentStart; + while (true) { + const url = urlRegex.exec(text); + // Pick the regexp with the earlier content; index will always be zero. + if (!url) { + break; + } + contentStart = url.index + url[1].length; + if (contentStart > 0) { + const nonUrlText = text.substring(0, contentStart); + items.push( + getCroppedString( + nonUrlText, + currentIndex, + startCropIndex, + endCropIndex + ) + ); + } + + // There are some final characters for a URL that are much more likely + // to have been part of the enclosing text rather than the end of the + // URL. + let useUrl = url[2]; + const uneat = uneatLastUrlCharsRegex.exec(useUrl); + if (uneat) { + useUrl = useUrl.substring(0, uneat.index); + } + + currentIndex = currentIndex + contentStart; + const linkText = getCroppedString( + useUrl, + currentIndex, + startCropIndex, + endCropIndex + ); + + if (linkText) { + const linkItems = []; + const shouldCrop = urlCropLimit && useUrl.length > urlCropLimit; + if (shouldCrop) { + const urlCropHalf = Math.ceil((urlCropLimit - ELLIPSIS.length) / 2); + // We cut the string into 3 elements and we'll visually hide the second one + // in CSS. This way people can still copy the full link. + linkItems.push( + span( + { className: "cropped-url-start" }, + useUrl.substring(0, urlCropHalf) + ), + span( + { className: "cropped-url-middle" }, + useUrl.substring(urlCropHalf, useUrl.length - urlCropHalf) + ), + span( + { className: "cropped-url-end" }, + useUrl.substring(useUrl.length - urlCropHalf) + ) + ); + } else { + linkItems.push(linkText); + } + + items.push( + a( + { + key: `${useUrl}-${currentIndex}`, + className: "url" + (shouldCrop ? " cropped-url" : ""), + title: useUrl, + draggable: false, + // Because we don't want the link to be open in the current + // panel's frame, we only render the href attribute if `openLink` + // exists (so we can preventDefault) or if the reps will be + // displayed in content page (e.g. in the JSONViewer). + href: openLink || isInContentPage ? useUrl : null, + target: "_blank", + rel: "noopener noreferrer", + onClick: openLink + ? e => { + e.preventDefault(); + openLink(useUrl, e); + } + : null, + }, + linkItems + ) + ); + } + + currentIndex = currentIndex + useUrl.length; + text = text.substring(url.index + url[1].length + useUrl.length); + } + + // Clean up any non-URL text at the end of the source string, + // i.e. not handled in the loop. + if (text.length) { + if (currentIndex < endCropIndex) { + text = getCroppedString( + text, + currentIndex, + startCropIndex, + endCropIndex + ); + } + items.push(text); + } + + return items; + } + + /** + * Returns a cropped substring given an offset, start and end crop indices in a + * parent string. + * + * @param {String} text: The substring to crop. + * @param {Integer} offset: The offset corresponding to the index at which + * the substring is in the parent string. + * @param {Integer|null} startCropIndex: the index where the start of the crop + * should happen in the parent string. + * @param {Integer|null} endCropIndex: the index where the end of the crop + * should happen in the parent string + * @returns {String|null} The cropped substring, or null if the text is + * completly cropped. + */ + function getCroppedString(text, offset = 0, startCropIndex, endCropIndex) { + if (!startCropIndex) { + return text; + } + + const start = offset; + const end = offset + text.length; + + const shouldBeVisible = !(start >= startCropIndex && end <= endCropIndex); + if (!shouldBeVisible) { + return null; + } + + const shouldCropEnd = start < startCropIndex && end > startCropIndex; + const shouldCropStart = start < endCropIndex && end > endCropIndex; + if (shouldCropEnd) { + const cutIndex = startCropIndex - start; + return ( + text.substring(0, cutIndex) + + ELLIPSIS + + (shouldCropStart ? text.substring(endCropIndex - start) : "") + ); + } + + if (shouldCropStart) { + // The string should be cropped at the beginning. + const cutIndex = endCropIndex - start; + return text.substring(cutIndex); + } + + return text; + } + + function isLongString(object) { + const grip = object && object.getGrip ? object.getGrip() : object; + return grip && grip.type === "longString"; + } + + function supportsObject(object, noGrip = false) { + // Accept the object if the grip-type (or type for noGrip objects) is "string" + if (getGripType(object, noGrip) == "string") { + return true; + } + + // Also accept longString objects if we're expecting grip + if (!noGrip) { + return isLongString(object); + } + + return false; + } + + // Exports from this module + + module.exports = { + rep: wrapRender(StringRep), + supportsObject, + isLongString, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/stylesheet.js b/devtools/client/shared/components/reps/reps/stylesheet.js new file mode 100644 index 0000000000..591442ebbf --- /dev/null +++ b/devtools/client/shared/components/reps/reps/stylesheet.js @@ -0,0 +1,78 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getGripType, + getURLDisplayString, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a grip representing CSSStyleSheet + */ + + StyleSheet.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function StyleSheet(props) { + const grip = props.object; + const shouldRenderTooltip = props.shouldRenderTooltip; + const location = getLocation(grip); + const config = getElementConfig({ grip, shouldRenderTooltip, location }); + + return span( + config, + getTitle(grip), + span({ className: "objectPropValue" }, location) + ); + } + + function getElementConfig(opts) { + const { grip, shouldRenderTooltip, location } = opts; + + return { + "data-link-actor-id": grip.actor, + className: "objectBox objectBox-object", + title: shouldRenderTooltip + ? `${getGripType(grip, false)} ${location}` + : null, + }; + } + + function getTitle(grip) { + return span( + { className: "objectBoxTitle" }, + `${getGripType(grip, false)} ` + ); + } + + function getLocation(grip) { + // Embedded stylesheets don't have URL and so, no preview. + const url = grip.preview ? grip.preview.url : ""; + return url ? getURLDisplayString(url) : ""; + } + + // Registration + function supportsObject(object, noGrip = false) { + return getGripType(object, noGrip) == "CSSStyleSheet"; + } + + // Exports from this module + + module.exports = { + rep: wrapRender(StyleSheet), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/symbol.js b/devtools/client/shared/components/reps/reps/symbol.js new file mode 100644 index 0000000000..89c76cdbb2 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/symbol.js @@ -0,0 +1,82 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + const { + rep: StringRep, + } = require("devtools/client/shared/components/reps/reps/string"); + + const MAX_STRING_LENGTH = 50; + + /** + * Renders a symbol. + */ + + SymbolRep.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function SymbolRep(props) { + const { + className = "objectBox objectBox-symbol", + object, + shouldRenderTooltip, + } = props; + const { name } = object; + + let symbolText = name || ""; + if (name && name !== "Symbol.iterator" && name !== "Symbol.asyncIterator") { + symbolText = StringRep({ + object: symbolText, + shouldCrop: true, + cropLimit: MAX_STRING_LENGTH, + useQuotes: true, + }); + } + + const config = getElementConfig( + { + shouldRenderTooltip, + className, + name, + }, + object + ); + + return span(config, "Symbol(", symbolText, ")"); + } + + function getElementConfig(opts, object) { + const { shouldRenderTooltip, className, name } = opts; + + return { + "data-link-actor-id": object.actor, + className, + title: shouldRenderTooltip ? `Symbol(${name})` : null, + }; + } + + function supportsObject(object, noGrip = false) { + return getGripType(object, noGrip) == "symbol"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(SymbolRep), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/text-node.js b/devtools/client/shared/components/reps/reps/text-node.js new file mode 100644 index 0000000000..ae9a7bb109 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/text-node.js @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const { + button, + span, + } = require("devtools/client/shared/vendor/react-dom-factories"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + // Reps + const { + appendRTLClassNameIfNeeded, + cropString, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + const { + rep: StringRep, + isLongString, + } = require("devtools/client/shared/components/reps/reps/string"); + + /** + * Renders DOM #text node. + */ + + TextNode.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + shouldRenderTooltip: PropTypes.bool, + }; + + function TextNode(props) { + const { object: grip, mode = MODE.SHORT } = props; + + const isInTree = grip.preview && grip.preview.isConnected === true; + const config = getElementConfig({ ...props, isInTree }); + const inspectIcon = getInspectIcon({ ...props, isInTree }); + + if (mode === MODE.TINY || mode === MODE.HEADER) { + return span(config, getTitle(grip), inspectIcon); + } + + return span( + config, + getTitle(grip), + " ", + StringRep({ + className: "nodeValue", + object: grip.preview.textContent, + }), + inspectIcon ? inspectIcon : null + ); + } + + function getElementConfig(opts) { + const { + object, + isInTree, + onDOMNodeMouseOver, + onDOMNodeMouseOut, + shouldRenderTooltip, + } = opts; + + const text = getTextContent(object); + const config = { + "data-link-actor-id": object.actor, + "data-link-content-dom-reference": JSON.stringify( + object.contentDomReference + ), + className: appendRTLClassNameIfNeeded( + "objectBox objectBox-textNode", + text + ), + title: shouldRenderTooltip ? `#text "${text}"` : null, + }; + + if (isInTree) { + if (onDOMNodeMouseOver) { + Object.assign(config, { + onMouseOver: _ => onDOMNodeMouseOver(object), + }); + } + + if (onDOMNodeMouseOut) { + Object.assign(config, { + onMouseOut: _ => onDOMNodeMouseOut(object), + }); + } + } + + return config; + } + + function getTextContent(grip) { + const text = grip.preview.textContent; + return cropString(isLongString(text) ? text.initial : text); + } + + function getInspectIcon(opts) { + const { object, isInTree, onInspectIconClick } = opts; + + if (!isInTree || !onInspectIconClick) { + return null; + } + + return button({ + className: "open-inspector", + draggable: false, + // TODO: Localize this with "openNodeInInspector" when Bug 1317038 lands + title: "Click to select the node in the inspector", + onClick: e => onInspectIconClick(object, e), + }); + } + + function getTitle(grip) { + const title = "#text"; + return span({}, title); + } + + // Registration + function supportsObject(grip, noGrip = false) { + return grip?.preview && grip?.class == "Text"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(TextNode), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/undefined.js b/devtools/client/shared/components/reps/reps/undefined.js new file mode 100644 index 0000000000..7e0ac4c786 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/undefined.js @@ -0,0 +1,59 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders undefined value + */ + + Undefined.propTypes = { + shouldRenderTooltip: PropTypes.bool, + }; + + function Undefined(props) { + const shouldRenderTooltip = props.shouldRenderTooltip; + + const config = getElementConfig(shouldRenderTooltip); + + return span(config, "undefined"); + } + + function getElementConfig(shouldRenderTooltip) { + return { + className: "objectBox objectBox-undefined", + title: shouldRenderTooltip ? "undefined" : null, + }; + } + + function supportsObject(object, noGrip = false) { + if (noGrip === true) { + return object === undefined; + } + + return ( + (object && object.type && object.type == "undefined") || + getGripType(object, noGrip) == "undefined" + ); + } + + // Exports from this module + + module.exports = { + rep: wrapRender(Undefined), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/window.js b/devtools/client/shared/components/reps/reps/window.js new file mode 100644 index 0000000000..2c420c2b30 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/window.js @@ -0,0 +1,102 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getGripType, + getURLDisplayString, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + /** + * Renders a grip representing a window. + */ + + WindowRep.propTypes = { + mode: PropTypes.oneOf(Object.values(MODE)), + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function WindowRep(props) { + const { mode, object } = props; + + if (mode === MODE.TINY) { + const tinyTitle = getTitle(object); + const title = getTitle(object, true); + const location = getLocation(object); + const config = getElementConfig({ ...props, title, location }); + + return span( + config, + span({ className: tinyTitle.className }, tinyTitle.content) + ); + } + + const title = getTitle(object, true); + const location = getLocation(object); + const config = getElementConfig({ ...props, title, location }); + + return span( + config, + span({ className: title.className }, title.content), + span({ className: "location" }, location) + ); + } + + function getElementConfig(opts) { + const { object, shouldRenderTooltip, title, location } = opts; + let tooltip; + + if (location) { + tooltip = `${title.content}${location}`; + } else { + tooltip = `${title.content}`; + } + + return { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-Window", + title: shouldRenderTooltip ? tooltip : null, + }; + } + + function getTitle(object, trailingSpace) { + let title = object.displayClass || object.class || "Window"; + if (trailingSpace === true) { + title = `${title} `; + } + return { + className: "objectTitle", + content: title, + }; + } + + function getLocation(object) { + return getURLDisplayString(object.preview.url); + } + + // Registration + function supportsObject(object, noGrip = false) { + return object?.preview && getGripType(object, noGrip) == "Window"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(WindowRep), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/shared/dom-node-constants.js b/devtools/client/shared/components/reps/shared/dom-node-constants.js new file mode 100644 index 0000000000..ecc1861d65 --- /dev/null +++ b/devtools/client/shared/components/reps/shared/dom-node-constants.js @@ -0,0 +1,31 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + module.exports = { + ELEMENT_NODE: 1, + ATTRIBUTE_NODE: 2, + TEXT_NODE: 3, + CDATA_SECTION_NODE: 4, + ENTITY_REFERENCE_NODE: 5, + ENTITY_NODE: 6, + PROCESSING_INSTRUCTION_NODE: 7, + COMMENT_NODE: 8, + DOCUMENT_NODE: 9, + DOCUMENT_TYPE_NODE: 10, + DOCUMENT_FRAGMENT_NODE: 11, + NOTATION_NODE: 12, + + // DocumentPosition + DOCUMENT_POSITION_DISCONNECTED: 0x01, + DOCUMENT_POSITION_PRECEDING: 0x02, + DOCUMENT_POSITION_FOLLOWING: 0x04, + DOCUMENT_POSITION_CONTAINS: 0x08, + DOCUMENT_POSITION_CONTAINED_BY: 0x10, + DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC: 0x20, + }; +}); diff --git a/devtools/client/shared/components/reps/shared/grip-length-bubble.js b/devtools/client/shared/components/reps/shared/grip-length-bubble.js new file mode 100644 index 0000000000..8d0dcb8bb8 --- /dev/null +++ b/devtools/client/shared/components/reps/shared/grip-length-bubble.js @@ -0,0 +1,64 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + const { + ModePropType, + } = require("devtools/client/shared/components/reps/reps/array"); + + const dom = require("devtools/client/shared/vendor/react-dom-factories"); + const { span } = dom; + + GripLengthBubble.propTypes = { + object: PropTypes.object.isRequired, + maxLengthMap: PropTypes.instanceOf(Map).isRequired, + getLength: PropTypes.func.isRequired, + mode: ModePropType, + visibilityThreshold: PropTypes.number, + }; + + function GripLengthBubble(props) { + const { + object, + mode = MODE.SHORT, + visibilityThreshold = 2, + maxLengthMap, + getLength, + showZeroLength = false, + } = props; + + const length = getLength(object); + const isEmpty = length === 0; + const isObvious = + [MODE.SHORT, MODE.LONG].includes(mode) && + length > 0 && + length <= maxLengthMap.get(mode) && + length <= visibilityThreshold; + if ((isEmpty && !showZeroLength) || isObvious) { + return ""; + } + + return span( + { + className: "objectLengthBubble", + }, + `(${length})` + ); + } + + module.exports = { + lengthBubble: wrapRender(GripLengthBubble), + }; +}); diff --git a/devtools/client/shared/components/reps/shared/moz.build b/devtools/client/shared/components/reps/shared/moz.build new file mode 100644 index 0000000000..6704491b97 --- /dev/null +++ b/devtools/client/shared/components/reps/shared/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( + "dom-node-constants.js", + "grip-length-bubble.js", +) diff --git a/devtools/client/shared/components/splitter/Draggable.js b/devtools/client/shared/components/splitter/Draggable.js new file mode 100644 index 0000000000..3d18e49c34 --- /dev/null +++ b/devtools/client/shared/components/splitter/Draggable.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 { + createRef, + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +class Draggable extends Component { + static get propTypes() { + return { + onMove: PropTypes.func.isRequired, + onDoubleClick: PropTypes.func, + onStart: PropTypes.func, + onStop: PropTypes.func, + style: PropTypes.object, + title: PropTypes.string, + className: PropTypes.string, + }; + } + + constructor(props) { + super(props); + + this.draggableEl = createRef(); + + this.startDragging = this.startDragging.bind(this); + this.stopDragging = this.stopDragging.bind(this); + this.onDoubleClick = this.onDoubleClick.bind(this); + this.onMove = this.onMove.bind(this); + + this.mouseX = 0; + this.mouseY = 0; + } + startDragging(ev) { + const xDiff = Math.abs(this.mouseX - ev.clientX); + const yDiff = Math.abs(this.mouseY - ev.clientY); + + // This allows for double-click. + if (this.props.onDoubleClick && xDiff + yDiff <= 1) { + return; + } + this.mouseX = ev.clientX; + this.mouseY = ev.clientY; + + if (this.isDragging) { + return; + } + this.isDragging = true; + ev.preventDefault(); + + this.draggableEl.current.addEventListener("mousemove", this.onMove); + this.draggableEl.current.setPointerCapture(ev.pointerId); + + this.props.onStart && this.props.onStart(); + } + + onDoubleClick() { + if (this.props.onDoubleClick) { + this.props.onDoubleClick(); + } + } + + onMove(ev) { + if (!this.isDragging) { + return; + } + + ev.preventDefault(); + // Use viewport coordinates so, moving mouse over iframes + // doesn't mangle (relative) coordinates. + this.props.onMove(ev.clientX, ev.clientY); + } + + stopDragging(ev) { + if (!this.isDragging) { + return; + } + this.isDragging = false; + ev.preventDefault(); + + this.draggableEl.current.removeEventListener("mousemove", this.onMove); + this.draggableEl.current.releasePointerCapture(ev.pointerId); + this.props.onStop && this.props.onStop(); + } + + render() { + return dom.div({ + ref: this.draggableEl, + role: "presentation", + style: this.props.style, + title: this.props.title, + className: this.props.className, + onMouseDown: this.startDragging, + onMouseUp: this.stopDragging, + onDoubleClick: this.onDoubleClick, + }); + } +} + +module.exports = Draggable; diff --git a/devtools/client/shared/components/splitter/GridElementResizer.css b/devtools/client/shared/components/splitter/GridElementResizer.css new file mode 100644 index 0000000000..dfa69592e9 --- /dev/null +++ b/devtools/client/shared/components/splitter/GridElementResizer.css @@ -0,0 +1,32 @@ +/* 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/. */ + +.grid-element-width-resizer { + /* The space we'll have on each side of the "splitter border" */ + --inline-inset: 3px; + /* We use the --inline-inset value that we multiply by 2 and add 1px to center the splitter */ + width: calc(1px + (2 * var(--inline-inset))); + position: relative; + cursor: ew-resize; + z-index: 10; +} + +.grid-element-width-resizer.start { + justify-self: start; + inset-inline-start: calc(-1 * var(--inline-inset)); +} + +.grid-element-width-resizer.end { + justify-self: end; + inset-inline-start: var(--inline-inset); +} + +.dragging, +.dragging * { + /* When resizing, we keep the "resize" cursor on every element we might hover */ + cursor: ew-resize !important; + /* This prevents to trigger some :hover style and is better for performance + * when resizing */ + pointer-events: none !important; +} diff --git a/devtools/client/shared/components/splitter/GridElementWidthResizer.js b/devtools/client/shared/components/splitter/GridElementWidthResizer.js new file mode 100644 index 0000000000..c6ab6f3e14 --- /dev/null +++ b/devtools/client/shared/components/splitter/GridElementWidthResizer.js @@ -0,0 +1,138 @@ +/* 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 { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const Draggable = createFactory( + require("resource://devtools/client/shared/components/splitter/Draggable.js") +); + +class GridElementWidthResizer extends Component { + static get propTypes() { + return { + getControlledElementNode: PropTypes.func.isRequired, + enabled: PropTypes.bool, + position: PropTypes.string.isRequired, + className: PropTypes.string, + onResizeEnd: PropTypes.func, + }; + } + + constructor(props) { + super(props); + + this.onStartMove = this.onStartMove.bind(this); + this.onStopMove = this.onStopMove.bind(this); + this.onMove = this.onMove.bind(this); + this.state = { + dragging: false, + isRTLElement: false, + defaultCursor: null, + defaultWidth: null, + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.enabled === true && this.props.enabled === false) { + this.onStopMove(); + const controlledElementNode = this.props.getControlledElementNode(); + controlledElementNode.style.width = this.state.defaultWidth; + } + } + + // Dragging Events + + /** + * Set 'resizing' cursor on entire document during splitter dragging. + * This avoids cursor-flickering that happens when the mouse leaves + * the splitter bar area (happens frequently). + */ + onStartMove() { + const controlledElementNode = this.props.getControlledElementNode(); + if (!controlledElementNode) { + return; + } + + const doc = controlledElementNode.ownerDocument; + const defaultCursor = doc.documentElement.style.cursor; + const defaultWidth = doc.documentElement.style.width; + doc.documentElement.style.cursor = "ew-resize"; + doc.firstElementChild.classList.add("dragging"); + + this.setState({ + dragging: true, + isRTLElement: + controlledElementNode.ownerDocument.defaultView.getComputedStyle( + controlledElementNode + ).direction === "rtl", + defaultCursor, + defaultWidth, + }); + } + + onStopMove() { + const controlledElementNode = this.props.getControlledElementNode(); + if (!this.state.dragging || !controlledElementNode) { + return; + } + const doc = controlledElementNode.ownerDocument; + doc.documentElement.style.cursor = this.state.defaultCursor; + doc.firstElementChild.classList.remove("dragging"); + + this.setState({ + dragging: false, + }); + + if (this.props.onResizeEnd) { + const { width } = controlledElementNode.getBoundingClientRect(); + this.props.onResizeEnd(width); + } + } + + /** + * Adjust size of the controlled panel. + */ + onMove(x) { + const controlledElementNode = this.props.getControlledElementNode(); + if (!this.state.dragging || !controlledElementNode) { + return; + } + const nodeBounds = controlledElementNode.getBoundingClientRect(); + const { isRTLElement } = this.state; + const { position } = this.props; + + const size = + (isRTLElement && position === "end") || + (!isRTLElement && position === "start") + ? nodeBounds.width + (nodeBounds.left - x) + : x - nodeBounds.left; + + controlledElementNode.style.width = `${size}px`; + } + + render() { + if (!this.props.enabled) { + return null; + } + + const classNames = ["grid-element-width-resizer", this.props.position]; + if (this.props.className) { + classNames.push(this.props.className); + } + + return Draggable({ + className: classNames.join(" "), + onStart: this.onStartMove, + onStop: this.onStopMove, + onMove: this.onMove, + }); + } +} + +module.exports = GridElementWidthResizer; diff --git a/devtools/client/shared/components/splitter/SplitBox.css b/devtools/client/shared/components/splitter/SplitBox.css new file mode 100644 index 0000000000..6028619e7b --- /dev/null +++ b/devtools/client/shared/components/splitter/SplitBox.css @@ -0,0 +1,93 @@ +/* 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/. */ + +.split-box { + display: flex; + flex: 1; + min-width: 0; + height: 100%; + width: 100%; +} + +.split-box.vert { + flex-direction: row; +} + +.split-box.horz { + flex-direction: column; +} + +.split-box > .uncontrolled { + display: flex; + flex: 1; + min-width: 0; + overflow: auto; +} + +.split-box > .controlled { + display: flex; + overflow: auto; +} + +.split-box > .splitter { + background-image: none; + border: 0; + border-style: solid; + border-color: transparent; + background-color: var(--theme-splitter-color); + background-clip: content-box; + position: relative; + + box-sizing: border-box; + + /* Positive z-index positions the splitter on top of its siblings and makes + it clickable on both sides. */ + z-index: 1; +} + +.split-box.vert > .splitter { + min-width: var(--devtools-vertical-splitter-min-width); + + border-inline-start-width: var(--devtools-splitter-inline-start-width); + border-inline-end-width: var(--devtools-splitter-inline-end-width); + + margin-inline-start: calc(-1 * var(--devtools-splitter-inline-start-width) - 1px); + margin-inline-end: calc(-1 * var(--devtools-splitter-inline-end-width)); + + cursor: ew-resize; +} + +.split-box.horz > .splitter { + /* Emphasize the horizontal splitter width and color */ + min-height: var(--devtools-emphasized-horizontal-splitter-min-height); + + background-color: var(--theme-emphasized-splitter-color); + + border-top-width: var(--devtools-splitter-top-width); + border-bottom-width: var(--devtools-splitter-bottom-width); + + margin-top: calc(-1 * var(--devtools-splitter-top-width) - 1px); + margin-bottom: calc(-1 * var(--devtools-splitter-bottom-width)); + + cursor: ns-resize; +} + +/* Emphasized splitter has the hover style. */ +.split-box.horz > .splitter:hover { + background-color: var(--theme-emphasized-splitter-color-hover); +} + +.split-box.disabled { + pointer-events: none; +} + +/** + * Make sure splitter panels are not processing any mouse + * events. This is good for performance during splitter + * bar dragging. + */ +.split-box.dragging > .controlled, +.split-box.dragging > .uncontrolled { + pointer-events: none; +} diff --git a/devtools/client/shared/components/splitter/SplitBox.js b/devtools/client/shared/components/splitter/SplitBox.js new file mode 100644 index 0000000000..2bf0fdb74d --- /dev/null +++ b/devtools/client/shared/components/splitter/SplitBox.js @@ -0,0 +1,351 @@ +/* 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 { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const Draggable = createFactory( + require("resource://devtools/client/shared/components/splitter/Draggable.js") +); + +/** + * This component represents a Splitter. The splitter supports vertical + * as well as horizontal mode. + */ +class SplitBox extends Component { + static get propTypes() { + return { + // Custom class name. You can use more names separated by a space. + className: PropTypes.string, + // Initial size of controlled panel. + initialSize: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + // Initial width of controlled panel. + initialWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + // Initial height of controlled panel. + initialHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + // Left/top panel + startPanel: PropTypes.any, + // Left/top panel collapse state. + startPanelCollapsed: PropTypes.bool, + // Min panel size. + minSize: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + // Max panel size. + maxSize: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + // Right/bottom panel + endPanel: PropTypes.any, + // Right/bottom panel collapse state. + endPanelCollapsed: PropTypes.bool, + // True if the right/bottom panel should be controlled. + endPanelControl: PropTypes.bool, + // Size of the splitter handle bar. + splitterSize: PropTypes.number, + // True if the splitter bar is vertical (default is vertical). + vert: PropTypes.bool, + // Style object. + style: PropTypes.object, + // Call when controlled panel was resized. + onControlledPanelResized: PropTypes.func, + // Optional callback when splitbox resize stops + onResizeEnd: PropTypes.func, + // Retrieve DOM reference to the start panel element + onSelectContainerElement: PropTypes.any, + }; + } + + static get defaultProps() { + return { + splitterSize: 5, + vert: true, + endPanelControl: false, + }; + } + + constructor(props) { + super(props); + + /** + * The state stores whether or not the end panel should be controlled, the current + * orientation (vertical or horizontal), the splitter size, and the current size + * (width/height). All these values can change during the component's life time. + */ + this.state = { + // True if the right/bottom panel should be controlled. + endPanelControl: props.endPanelControl, + // True if the splitter bar is vertical (default is vertical). + vert: props.vert, + // Size of the splitter handle bar. + splitterSize: props.splitterSize, + // Width of controlled panel. + width: props.initialWidth || props.initialSize, + // Height of controlled panel. + height: props.initialHeight || props.initialSize, + }; + + this.onStartMove = this.onStartMove.bind(this); + this.onStopMove = this.onStopMove.bind(this); + this.onMove = this.onMove.bind(this); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { endPanelControl, splitterSize, vert } = nextProps; + + if (endPanelControl != this.props.endPanelControl) { + this.setState({ endPanelControl }); + } + + if (splitterSize != this.props.splitterSize) { + this.setState({ splitterSize }); + } + + if (vert !== this.props.vert) { + this.setState({ vert }); + } + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + nextState.width != this.state.width || + nextState.endPanelControl != this.props.endPanelControl || + nextState.height != this.state.height || + nextState.vert != this.state.vert || + nextState.splitterSize != this.state.splitterSize || + nextProps.startPanel != this.props.startPanel || + nextProps.endPanel != this.props.endPanel || + nextProps.minSize != this.props.minSize || + nextProps.maxSize != this.props.maxSize + ); + } + + componentDidUpdate(prevProps, prevState) { + if ( + this.props.onControlledPanelResized && + (prevState.width !== this.state.width || + prevState.height !== this.state.height) + ) { + this.props.onControlledPanelResized(this.state.width, this.state.height); + } + } + + // Dragging Events + + /** + * Set 'resizing' cursor on entire document during splitter dragging. + * This avoids cursor-flickering that happens when the mouse leaves + * the splitter bar area (happens frequently). + */ + onStartMove() { + const doc = this.splitBox.ownerDocument; + const defaultCursor = doc.documentElement.style.cursor; + doc.documentElement.style.cursor = this.state.vert + ? "ew-resize" + : "ns-resize"; + + this.splitBox.classList.add("dragging"); + + this.setState({ + defaultCursor, + }); + } + + onStopMove() { + const doc = this.splitBox.ownerDocument; + doc.documentElement.style.cursor = this.state.defaultCursor; + + this.splitBox.classList.remove("dragging"); + + if (this.props.onResizeEnd) { + this.props.onResizeEnd( + this.state.vert ? this.state.width : this.state.height + ); + } + } + + /** + * Adjust size of the controlled panel. Depending on the current + * orientation we either remember the width or height of + * the splitter box. + */ + onMove(x, y) { + const nodeBounds = this.splitBox.getBoundingClientRect(); + + let size; + let { endPanelControl, vert } = this.state; + + if (vert) { + // Use the document owning the SplitBox to detect rtl. The global document might be + // the one bound to the toolbox shared BrowserRequire, which is irrelevant here. + const doc = this.splitBox.ownerDocument; + + // Switch the control flag in case of RTL. Note that RTL + // has impact on vertical splitter only. + if (doc.dir === "rtl") { + endPanelControl = !endPanelControl; + } + + size = endPanelControl + ? nodeBounds.left + nodeBounds.width - x + : x - nodeBounds.left; + + this.setState({ + width: this.getConstrainedSizeInPx(size, nodeBounds.width), + }); + } else { + size = endPanelControl + ? nodeBounds.top + nodeBounds.height - y + : y - nodeBounds.top; + + this.setState({ + height: this.getConstrainedSizeInPx(size, nodeBounds.height), + }); + } + } + + /** + * Calculates the constrained size taking into account the minimum width or + * height passed via this.props.minSize. + * + * @param {Number} requestedSize + * The requested size + * @param {Number} splitBoxWidthOrHeight + * The width or height of the splitBox + * + * @return {Number} + * The constrained size + */ + getConstrainedSizeInPx(requestedSize, splitBoxWidthOrHeight) { + let minSize = this.props.minSize + ""; + + if (minSize.endsWith("%")) { + minSize = (parseFloat(minSize) / 100) * splitBoxWidthOrHeight; + } else if (minSize.endsWith("px")) { + minSize = parseFloat(minSize); + } + return Math.max(requestedSize, minSize); + } + + // Rendering + + // eslint-disable-next-line complexity + render() { + const { endPanelControl, splitterSize, vert } = this.state; + const { + startPanel, + startPanelCollapsed, + endPanel, + endPanelCollapsed, + minSize, + maxSize, + onSelectContainerElement, + } = this.props; + + const style = Object.assign( + { + // Set the size of the controlled panel (height or width depending on the + // current state). This can be used to help with styling of dependent + // panels. + "--split-box-controlled-panel-size": `${ + vert ? this.state.width : this.state.height + }`, + }, + this.props.style + ); + + // Calculate class names list. + let classNames = ["split-box"]; + classNames.push(vert ? "vert" : "horz"); + if (this.props.className) { + classNames = classNames.concat(this.props.className.split(" ")); + } + + let leftPanelStyle; + let rightPanelStyle; + + // Set proper size for panels depending on the current state. + if (vert) { + leftPanelStyle = { + maxWidth: endPanelControl ? null : maxSize, + minWidth: endPanelControl ? null : minSize, + width: endPanelControl ? null : this.state.width, + }; + rightPanelStyle = { + maxWidth: endPanelControl ? maxSize : null, + minWidth: endPanelControl ? minSize : null, + width: endPanelControl ? this.state.width : null, + }; + } else { + leftPanelStyle = { + maxHeight: endPanelControl ? null : maxSize, + minHeight: endPanelControl ? null : minSize, + height: endPanelControl ? null : this.state.height, + }; + rightPanelStyle = { + maxHeight: endPanelControl ? maxSize : null, + minHeight: endPanelControl ? minSize : null, + height: endPanelControl ? this.state.height : null, + }; + } + + // Calculate splitter size + const splitterStyle = { + flex: "0 0 " + splitterSize + "px", + }; + + return dom.div( + { + className: classNames.join(" "), + ref: div => { + this.splitBox = div; + }, + style, + }, + startPanel && !startPanelCollapsed + ? dom.div( + { + className: endPanelControl ? "uncontrolled" : "controlled", + style: leftPanelStyle, + role: "presentation", + ref: div => { + this.startPanelContainer = div; + if (onSelectContainerElement) { + onSelectContainerElement(div); + } + }, + }, + startPanel + ) + : null, + splitterSize > 0 + ? Draggable({ + className: "splitter", + style: splitterStyle, + onStart: this.onStartMove, + onStop: this.onStopMove, + onMove: this.onMove, + }) + : null, + endPanel && !endPanelCollapsed + ? dom.div( + { + className: endPanelControl ? "controlled" : "uncontrolled", + style: rightPanelStyle, + role: "presentation", + ref: div => { + this.endPanelContainer = div; + }, + }, + endPanel + ) + : null + ); + } +} + +module.exports = SplitBox; diff --git a/devtools/client/shared/components/splitter/moz.build b/devtools/client/shared/components/splitter/moz.build new file mode 100644 index 0000000000..4abe762b34 --- /dev/null +++ b/devtools/client/shared/components/splitter/moz.build @@ -0,0 +1,11 @@ +# -*- 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( + "Draggable.js", + "GridElementWidthResizer.js", + "SplitBox.js", +) diff --git a/devtools/client/shared/components/tabs/TabBar.js b/devtools/client/shared/components/tabs/TabBar.js new file mode 100644 index 0000000000..730e8c7802 --- /dev/null +++ b/devtools/client/shared/components/tabs/TabBar.js @@ -0,0 +1,378 @@ +/* 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-env browser */ + +"use strict"; + +const { + Component, + createFactory, + createRef, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const Sidebar = createFactory( + require("resource://devtools/client/shared/components/Sidebar.js") +); + +loader.lazyRequireGetter( + this, + "Menu", + "resource://devtools/client/framework/menu.js" +); +loader.lazyRequireGetter( + this, + "MenuItem", + "resource://devtools/client/framework/menu-item.js" +); + +// Shortcuts +const { div } = dom; + +/** + * Renders Tabbar component. + */ +class Tabbar extends Component { + static get propTypes() { + return { + children: PropTypes.array, + menuDocument: PropTypes.object, + onSelect: PropTypes.func, + showAllTabsMenu: PropTypes.bool, + allTabsMenuButtonTooltip: PropTypes.string, + activeTabId: PropTypes.string, + renderOnlySelected: PropTypes.bool, + sidebarToggleButton: PropTypes.shape({ + // Set to true if collapsed. + collapsed: PropTypes.bool.isRequired, + // Tooltip text used when the button indicates expanded state. + collapsePaneTitle: PropTypes.string.isRequired, + // Tooltip text used when the button indicates collapsed state. + expandPaneTitle: PropTypes.string.isRequired, + // Click callback + onClick: PropTypes.func.isRequired, + // align toggle button to right + alignRight: PropTypes.bool, + // if set to true toggle-button rotate 90 + canVerticalSplit: PropTypes.bool, + }), + }; + } + + static get defaultProps() { + return { + menuDocument: window.parent.document, + showAllTabsMenu: false, + }; + } + + constructor(props, context) { + super(props, context); + const { activeTabId, children = [] } = props; + const tabs = this.createTabs(children); + const activeTab = tabs.findIndex((tab, index) => tab.id === activeTabId); + + this.state = { + activeTab: activeTab === -1 ? 0 : activeTab, + tabs, + }; + + // Array of queued tabs to add to the Tabbar. + this.queuedTabs = []; + + this.createTabs = this.createTabs.bind(this); + this.addTab = this.addTab.bind(this); + this.addAllQueuedTabs = this.addAllQueuedTabs.bind(this); + this.queueTab = this.queueTab.bind(this); + this.toggleTab = this.toggleTab.bind(this); + this.removeTab = this.removeTab.bind(this); + this.select = this.select.bind(this); + this.getTabIndex = this.getTabIndex.bind(this); + this.getTabId = this.getTabId.bind(this); + this.getCurrentTabId = this.getCurrentTabId.bind(this); + this.onTabChanged = this.onTabChanged.bind(this); + this.onAllTabsMenuClick = this.onAllTabsMenuClick.bind(this); + this.renderTab = this.renderTab.bind(this); + this.tabbarRef = createRef(); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { activeTabId, children = [] } = nextProps; + const tabs = this.createTabs(children); + const activeTab = tabs.findIndex((tab, index) => tab.id === activeTabId); + + if ( + activeTab !== this.state.activeTab || + children !== this.props.children + ) { + this.setState({ + activeTab: activeTab === -1 ? 0 : activeTab, + tabs, + }); + } + } + + createTabs(children) { + return children + .filter(panel => panel) + .map((panel, index) => + Object.assign({}, children[index], { + id: panel.props.id || index, + panel, + title: panel.props.title, + }) + ); + } + + // Public API + + addTab(id, title, selected = false, panel, url, index = -1) { + const tabs = this.state.tabs.slice(); + + if (index >= 0) { + tabs.splice(index, 0, { id, title, panel, url }); + } else { + tabs.push({ id, title, panel, url }); + } + + const newState = Object.assign({}, this.state, { + tabs, + }); + + if (selected) { + newState.activeTab = index >= 0 ? index : tabs.length - 1; + } + + this.setState(newState, () => { + if (this.props.onSelect && selected) { + this.props.onSelect(id); + } + }); + } + + addAllQueuedTabs() { + if (!this.queuedTabs.length) { + return; + } + + const tabs = this.state.tabs.slice(); + + // Preselect the first sidebar tab if none was explicitly selected. + let activeTab = 0; + let activeId = this.queuedTabs[0].id; + + for (const { id, index, panel, selected, title, url } of this.queuedTabs) { + if (index >= 0) { + tabs.splice(index, 0, { id, title, panel, url }); + } else { + tabs.push({ id, title, panel, url }); + } + + if (selected) { + activeId = id; + activeTab = index >= 0 ? index : tabs.length - 1; + } + } + + const newState = Object.assign({}, this.state, { + activeTab, + tabs, + }); + + this.setState(newState, () => { + if (this.props.onSelect) { + this.props.onSelect(activeId); + } + }); + + this.queuedTabs = []; + } + + /** + * Queues a tab to be added. This is more performant than calling addTab for every + * single tab to be added since we will limit the number of renders happening when + * a new state is set. Once all the tabs to be added have been queued, call + * addAllQueuedTabs() to populate the TabBar with all the queued tabs. + */ + queueTab(id, title, selected = false, panel, url, index = -1) { + this.queuedTabs.push({ + id, + index, + panel, + selected, + title, + url, + }); + } + + toggleTab(tabId, isVisible) { + const index = this.getTabIndex(tabId); + if (index < 0) { + return; + } + + const tabs = this.state.tabs.slice(); + tabs[index] = Object.assign({}, tabs[index], { + isVisible, + }); + + this.setState( + Object.assign({}, this.state, { + tabs, + }) + ); + } + + removeTab(tabId) { + const index = this.getTabIndex(tabId); + if (index < 0) { + return; + } + + const tabs = this.state.tabs.slice(); + tabs.splice(index, 1); + + let activeTab = this.state.activeTab - 1; + activeTab = activeTab === -1 ? 0 : activeTab; + + this.setState( + Object.assign({}, this.state, { + activeTab, + tabs, + }), + () => { + // Select the next active tab and force the select event handler to initialize + // the panel if needed. + if (tabs.length && this.props.onSelect) { + this.props.onSelect(this.getTabId(activeTab)); + } + } + ); + } + + select(tabId) { + const docRef = this.tabbarRef.current.ownerDocument; + + const index = this.getTabIndex(tabId); + if (index < 0) { + return; + } + + const newState = Object.assign({}, this.state, { + activeTab: index, + }); + + const tabDomElement = docRef.querySelector(`[data-tab-index="${index}"]`); + + if (tabDomElement) { + tabDomElement.scrollIntoView(); + } + + this.setState(newState, () => { + if (this.props.onSelect) { + this.props.onSelect(tabId); + } + }); + } + + // Helpers + + getTabIndex(tabId) { + let tabIndex = -1; + this.state.tabs.forEach((tab, index) => { + if (tab.id === tabId) { + tabIndex = index; + } + }); + return tabIndex; + } + + getTabId(index) { + return this.state.tabs[index].id; + } + + getCurrentTabId() { + return this.state.tabs[this.state.activeTab].id; + } + + // Event Handlers + + onTabChanged(index) { + this.setState( + { + activeTab: index, + }, + () => { + if (this.props.onSelect) { + this.props.onSelect(this.state.tabs[index].id); + } + } + ); + } + + onAllTabsMenuClick(event) { + const menu = new Menu(); + const target = event.target; + + // Generate list of menu items from the list of tabs. + this.state.tabs.forEach(tab => { + menu.append( + new MenuItem({ + label: tab.title, + type: "checkbox", + checked: this.getCurrentTabId() === tab.id, + click: () => this.select(tab.id), + }) + ); + }); + + // Show a drop down menu with frames. + menu.popupAtTarget(target); + + return menu; + } + + // Rendering + + renderTab(tab) { + if (typeof tab.panel === "function") { + return tab.panel({ + key: tab.id, + title: tab.title, + id: tab.id, + url: tab.url, + }); + } + + return tab.panel; + } + + render() { + const tabs = this.state.tabs.map(tab => this.renderTab(tab)); + + return div( + { + className: "devtools-sidebar-tabs", + ref: this.tabbarRef, + }, + Sidebar( + { + onAllTabsMenuClick: this.onAllTabsMenuClick, + renderOnlySelected: this.props.renderOnlySelected, + showAllTabsMenu: this.props.showAllTabsMenu, + allTabsMenuButtonTooltip: this.props.allTabsMenuButtonTooltip, + sidebarToggleButton: this.props.sidebarToggleButton, + activeTab: this.state.activeTab, + onAfterChange: this.onTabChanged, + }, + tabs + ) + ); + } +} + +module.exports = Tabbar; diff --git a/devtools/client/shared/components/tabs/Tabs.css b/devtools/client/shared/components/tabs/Tabs.css new file mode 100644 index 0000000000..d483925a75 --- /dev/null +++ b/devtools/client/shared/components/tabs/Tabs.css @@ -0,0 +1,128 @@ +/* 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/. */ + +/* Tabs General Styles */ + +.tabs { + --tab-height: var(--theme-toolbar-height); + height: 100%; + background: var(--theme-sidebar-background); + display: flex; + flex-direction: column; +} + +.tabs.tabs-tall { + --tab-height: var(--theme-toolbar-tall-height); +} + +/* Hides the tab strip in the TabBar */ +div[hidetabs=true] .tabs .tabs-navigation { + display: none; +} + +.tabs .tabs-navigation { + box-sizing: border-box; + display: flex; + /* Reserve 1px for the border */ + height: calc(var(--tab-height) + 1px); + position: relative; + border-bottom: 1px solid var(--theme-splitter-color); + background: var(--theme-tab-toolbar-background); +} + +.tabs .tabs-menu { + list-style: none; + padding: 0; + margin: 0; + margin-inline-end: 15px; + flex-grow: 1; + /* Adjust outline so it's not clipped */ + outline-offset: -2px; +} + +/* The tab takes entire horizontal space and individual tabs + should stretch accordingly. Use flexbox for the behavior. + Use also `overflow: hidden` so, 'overflow' and 'underflow' + events are fired (it's utilized by the all-tabs-menu). */ +.tabs .tabs-navigation .tabs-menu { + overflow: hidden; + display: flex; + overflow-x: scroll; + scrollbar-width: none; +} + +.tabs .tabs-menu-item { + display: inline-block; + position: relative; + margin: 0; + padding: 0; + color: var(--theme-toolbar-color); +} + +.tabs .tabs-menu-item.is-active { + color: var(--theme-toolbar-selected-color); +} + +.tabs .tabs-menu-item:hover { + background-color: var(--theme-toolbar-hover); +} + +.tabs .tabs-menu-item:hover:active:not(.is-active) { + background-color: var(--theme-toolbar-hover-active); +} + +.tabs .tabs-menu-item a { + --text-height: 16px; + --devtools-tab-border-width: 1px; + display: flex; + justify-content: center; + /* Vertically center text, calculate space remaining by taking the full height and removing + the block borders and text. Divide by 2 to distribute above and below. */ + padding: calc((var(--tab-height) - var(--text-height) - (var(--devtools-tab-border-width) * 2)) / 2) 10px; + border: var(--devtools-tab-border-width) solid transparent; + font-size: 12px; + line-height: var(--text-height); + text-decoration: none; + white-space: nowrap; + cursor: default; + user-select: none; + text-align: center; +} + +.tabs .tabs-navigation .tabs-menu-item > a { + outline-offset: -2px; +} + +.tabs .tabs-menu-item .tab-badge { + color: var(--theme-highlight-blue); + font-size: 80%; + font-style: italic; + /* Tabs have a 15px padding start/end, so we set the margins here in order to center the + badge after the tab title, with a 5px effective margin. */ + margin-inline-start: 5px; + margin-inline-end: -10px; +} + +.tabs .tabs-menu-item.is-active .tab-badge { + /* Use the same color as the tab-item when active */ + color: inherit; +} + +/* To avoid "select all" commands from selecting content in hidden tabs */ +.tabs .hidden, +.tabs .hidden * { + user-select: none !important; +} + +/* Make sure panel content takes entire vertical space. */ +.tabs .panels { + flex: 1; + overflow: hidden; +} + +.tabs .tab-panel { + height: 100%; + overflow-x: hidden; + overflow-y: auto; +} diff --git a/devtools/client/shared/components/tabs/Tabs.js b/devtools/client/shared/components/tabs/Tabs.js new file mode 100644 index 0000000000..a265032f9e --- /dev/null +++ b/devtools/client/shared/components/tabs/Tabs.js @@ -0,0 +1,468 @@ +/* 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"; + +define(function (require, exports, module) { + const { + Component, + createRef, + } = require("devtools/client/shared/vendor/react"); + const dom = require("devtools/client/shared/vendor/react-dom-factories"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + /** + * Renders simple 'tab' widget. + * + * Based on ReactSimpleTabs component + * https://github.com/pedronauck/react-simpletabs + * + * Component markup (+CSS) example: + * + * <div class='tabs'> + * <nav class='tabs-navigation'> + * <ul class='tabs-menu'> + * <li class='tabs-menu-item is-active'>Tab #1</li> + * <li class='tabs-menu-item'>Tab #2</li> + * </ul> + * </nav> + * <div class='panels'> + * The content of active panel here + * </div> + * <div> + */ + class Tabs extends Component { + static get propTypes() { + return { + className: PropTypes.oneOfType([ + PropTypes.array, + PropTypes.string, + PropTypes.object, + ]), + activeTab: PropTypes.number, + onMount: PropTypes.func, + onBeforeChange: PropTypes.func, + onAfterChange: PropTypes.func, + children: PropTypes.oneOfType([PropTypes.array, PropTypes.element]) + .isRequired, + showAllTabsMenu: PropTypes.bool, + allTabsMenuButtonTooltip: PropTypes.string, + onAllTabsMenuClick: PropTypes.func, + tall: PropTypes.bool, + + // To render a sidebar toggle button before the tab menu provide a function that + // returns a React component for the button. + renderSidebarToggle: PropTypes.func, + // Set true will only render selected panel on DOM. It's complete + // opposite of the created array, and it's useful if panels content + // is unpredictable and update frequently. + renderOnlySelected: PropTypes.bool, + }; + } + + static get defaultProps() { + return { + activeTab: 0, + showAllTabsMenu: false, + renderOnlySelected: false, + }; + } + + constructor(props) { + super(props); + + this.state = { + activeTab: props.activeTab, + + // This array is used to store an object containing information on whether a tab + // at a specified index has already been created (e.g. selected at least once) and + // the tab id. An example of the object structure is the following: + // [{ isCreated: true, tabId: "ruleview" }, { isCreated: false, tabId: "foo" }]. + // If the tab at the specified index has already been created, it's rendered even + // if not currently selected. This is because in some cases we don't want + // to re-create tab content when it's being unselected/selected. + // E.g. in case of an iframe being used as a tab-content we want the iframe to + // stay in the DOM. + created: [], + + // True if tabs can't fit into available horizontal space. + overflow: false, + }; + + this.tabsEl = createRef(); + + this.onOverflow = this.onOverflow.bind(this); + this.onUnderflow = this.onUnderflow.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onClickTab = this.onClickTab.bind(this); + this.setActive = this.setActive.bind(this); + this.renderMenuItems = this.renderMenuItems.bind(this); + this.renderPanels = this.renderPanels.bind(this); + } + + componentDidMount() { + const node = this.tabsEl.current; + node.addEventListener("keydown", this.onKeyDown); + + // Register overflow listeners to manage visibility + // of all-tabs-menu. This menu is displayed when there + // is not enough h-space to render all tabs. + // It allows the user to select a tab even if it's hidden. + if (this.props.showAllTabsMenu) { + node.addEventListener("overflow", this.onOverflow); + node.addEventListener("underflow", this.onUnderflow); + } + + const index = this.state.activeTab; + if (this.props.onMount) { + this.props.onMount(index); + } + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + let { children, activeTab } = nextProps; + const panels = children.filter(panel => panel); + let created = [...this.state.created]; + + // If the children props has changed due to an addition or removal of a tab, + // update the state's created array with the latest tab ids and whether or not + // the tab is already created. + if (this.state.created.length != panels.length) { + created = panels.map(panel => { + // Get whether or not the tab has already been created from the previous state. + const createdEntry = this.state.created.find(entry => { + return entry && entry.tabId === panel.props.id; + }); + const isCreated = !!createdEntry && createdEntry.isCreated; + const tabId = panel.props.id; + + return { + isCreated, + tabId, + }; + }); + } + + // Check type of 'activeTab' props to see if it's valid (it's 0-based index). + if (typeof activeTab === "number") { + // Reset to index 0 if index overflows the range of panel array + activeTab = activeTab < panels.length && activeTab >= 0 ? activeTab : 0; + + created[activeTab] = Object.assign({}, created[activeTab], { + isCreated: true, + }); + + this.setState({ + activeTab, + }); + } + + this.setState({ + created, + }); + } + + componentWillUnmount() { + const node = this.tabsEl.current; + node.removeEventListener("keydown", this.onKeyDown); + + if (this.props.showAllTabsMenu) { + node.removeEventListener("overflow", this.onOverflow); + node.removeEventListener("underflow", this.onUnderflow); + } + } + + // DOM Events + + onOverflow(event) { + if (event.target.classList.contains("tabs-menu")) { + this.setState({ + overflow: true, + }); + } + } + + onUnderflow(event) { + if (event.target.classList.contains("tabs-menu")) { + this.setState({ + overflow: false, + }); + } + } + + onKeyDown(event) { + // Bail out if the focus isn't on a tab. + if (!event.target.closest(".tabs-menu-item")) { + return; + } + + let activeTab = this.state.activeTab; + const tabCount = this.props.children.length; + + const ltr = event.target.ownerDocument.dir == "ltr"; + const nextOrLastTab = Math.min(tabCount - 1, activeTab + 1); + const previousOrFirstTab = Math.max(0, activeTab - 1); + + switch (event.code) { + case "ArrowRight": + if (ltr) { + activeTab = nextOrLastTab; + } else { + activeTab = previousOrFirstTab; + } + break; + case "ArrowLeft": + if (ltr) { + activeTab = previousOrFirstTab; + } else { + activeTab = nextOrLastTab; + } + break; + } + + if (this.state.activeTab != activeTab) { + this.setActive(activeTab); + } + } + + onClickTab(index, event) { + this.setActive(index); + + if (event) { + event.preventDefault(); + } + } + + onMouseDown(event) { + // Prevents click-dragging the tab headers + if (event) { + event.preventDefault(); + } + } + + // API + + setActive(index) { + const onAfterChange = this.props.onAfterChange; + const onBeforeChange = this.props.onBeforeChange; + + if (onBeforeChange) { + const cancel = onBeforeChange(index); + if (cancel) { + return; + } + } + + const created = [...this.state.created]; + created[index] = Object.assign({}, created[index], { + isCreated: true, + }); + + const newState = Object.assign({}, this.state, { + created, + activeTab: index, + }); + + this.setState(newState, () => { + // Properly set focus on selected tab. + const selectedTab = this.tabsEl.current.querySelector(".is-active > a"); + if (selectedTab) { + selectedTab.focus(); + } + + if (onAfterChange) { + onAfterChange(index); + } + }); + } + + // Rendering + + renderMenuItems() { + if (!this.props.children) { + throw new Error("There must be at least one Tab"); + } + + if (!Array.isArray(this.props.children)) { + this.props.children = [this.props.children]; + } + + const tabs = this.props.children + .map(tab => (typeof tab === "function" ? tab() : tab)) + .filter(tab => tab) + .map((tab, index) => { + const { + id, + className: tabClassName, + title, + badge, + showBadge, + } = tab.props; + + const ref = "tab-menu-" + index; + const isTabSelected = this.state.activeTab === index; + + const className = [ + "tabs-menu-item", + tabClassName, + isTabSelected ? "is-active" : "", + ].join(" "); + + // Set tabindex to -1 (except the selected tab) so, it's focusable, + // but not reachable via sequential tab-key navigation. + // Changing selected tab (and so, moving focus) is done through + // left and right arrow keys. + // See also `onKeyDown()` event handler. + return dom.li( + { + className, + key: index, + ref, + role: "presentation", + }, + dom.span({ className: "devtools-tab-line" }), + dom.a( + { + id: id ? id + "-tab" : "tab-" + index, + tabIndex: isTabSelected ? 0 : -1, + title, + "aria-controls": id ? id + "-panel" : "panel-" + index, + "aria-selected": isTabSelected, + role: "tab", + onClick: this.onClickTab.bind(this, index), + onMouseDown: this.onMouseDown.bind(this), + "data-tab-index": index, + }, + title, + badge && !isTabSelected && showBadge() + ? dom.span({ className: "tab-badge" }, badge) + : null + ) + ); + }); + + // Display the menu only if there is not enough horizontal + // space for all tabs (and overflow happened). + const allTabsMenu = this.state.overflow + ? dom.button({ + className: "all-tabs-menu", + title: this.props.allTabsMenuButtonTooltip, + onClick: this.props.onAllTabsMenuClick, + }) + : null; + + // Get the sidebar toggle button if a renderSidebarToggle function is provided. + const sidebarToggle = this.props.renderSidebarToggle + ? this.props.renderSidebarToggle() + : null; + + return dom.nav( + { className: "tabs-navigation" }, + sidebarToggle, + dom.ul({ className: "tabs-menu", role: "tablist" }, tabs), + allTabsMenu + ); + } + + renderPanels() { + let { children, renderOnlySelected } = this.props; + + if (!children) { + throw new Error("There must be at least one Tab"); + } + + if (!Array.isArray(children)) { + children = [children]; + } + + const selectedIndex = this.state.activeTab; + + const panels = children + .map(tab => (typeof tab === "function" ? tab() : tab)) + .filter(tab => tab) + .map((tab, index) => { + const selected = selectedIndex === index; + if (renderOnlySelected && !selected) { + return null; + } + + const id = tab.props.id; + const isCreated = + this.state.created[index] && this.state.created[index].isCreated; + + // Use 'visibility:hidden' + 'height:0' for hiding content of non-selected + // tab. It's faster than 'display:none' because it avoids triggering frame + // destruction and reconstruction. 'width' is not changed to avoid relayout. + const style = { + visibility: selected ? "visible" : "hidden", + height: selected ? "100%" : "0", + }; + + // Allows lazy loading panels by creating them only if they are selected, + // then store a copy of the lazy created panel in `tab.panel`. + if (typeof tab.panel == "function" && selected) { + tab.panel = tab.panel(tab); + } + const panel = tab.panel || tab; + + return dom.div( + { + id: id ? id + "-panel" : "panel-" + index, + key: id, + style, + className: selected ? "tab-panel-box" : "tab-panel-box hidden", + role: "tabpanel", + "aria-labelledby": id ? id + "-tab" : "tab-" + index, + }, + selected || isCreated ? panel : null + ); + }); + + return dom.div({ className: "panels" }, panels); + } + + render() { + return dom.div( + { + className: [ + "tabs", + ...(this.props.tall ? ["tabs-tall"] : []), + this.props.className, + ].join(" "), + ref: this.tabsEl, + }, + this.renderMenuItems(), + this.renderPanels() + ); + } + } + + /** + * Renders simple tab 'panel'. + */ + class Panel extends Component { + static get propTypes() { + return { + id: PropTypes.string.isRequired, + className: PropTypes.string, + title: PropTypes.string.isRequired, + children: PropTypes.oneOfType([PropTypes.array, PropTypes.element]) + .isRequired, + }; + } + + render() { + const { className } = this.props; + return dom.div( + { className: `tab-panel ${className || ""}` }, + this.props.children + ); + } + } + + // Exports from this module + exports.TabPanel = Panel; + exports.Tabs = Tabs; +}); diff --git a/devtools/client/shared/components/tabs/moz.build b/devtools/client/shared/components/tabs/moz.build new file mode 100644 index 0000000000..15ede75b9d --- /dev/null +++ b/devtools/client/shared/components/tabs/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( + "TabBar.js", + "Tabs.js", +) diff --git a/devtools/client/shared/components/test/browser/browser.toml b/devtools/client/shared/components/test/browser/browser.toml new file mode 100644 index 0000000000..46412aae90 --- /dev/null +++ b/devtools/client/shared/components/test/browser/browser.toml @@ -0,0 +1,14 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", +] + +["browser_notification_box_basic.js"] + +["browser_reps_stubs.js"] +skip-if = [ + "!fission", # we need specific iframe targets for some evaluation +] diff --git a/devtools/client/shared/components/test/browser/browser_notification_box_basic.js b/devtools/client/shared/components/test/browser/browser_notification_box_basic.js new file mode 100644 index 0000000000..0103c677d2 --- /dev/null +++ b/devtools/client/shared/components/test/browser/browser_notification_box_basic.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +const TEST_URI = "data:text/html;charset=utf-8,Test page"; + +/** + * Basic test that checks existence of the Notification box. + */ +add_task(async function () { + info("Test Notification box basic started"); + + const toolbox = await openNewTabAndToolbox(TEST_URI, "webconsole"); + + // Append a notification + const notificationBox = toolbox.getNotificationBox(); + notificationBox.appendNotification( + "Info message", + "id1", + null, + notificationBox.PRIORITY_INFO_HIGH + ); + + // Verify existence of one notification. + const parentNode = toolbox.doc.getElementById("toolbox-notificationbox"); + const nodes = parentNode.querySelectorAll(".notification"); + is(nodes.length, 1, "There must be one notification"); +}); diff --git a/devtools/client/shared/components/test/browser/browser_reps_stubs.js b/devtools/client/shared/components/test/browser/browser_reps_stubs.js new file mode 100644 index 0000000000..29b56ab151 --- /dev/null +++ b/devtools/client/shared/components/test/browser/browser_reps_stubs.js @@ -0,0 +1,406 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +const TEST_URI = "data:text/html;charset=utf-8,stub generation"; +/** + * A Map keyed by filename, and for which the value is also a Map, with the key being the + * label for the stub, and the value the expression to evaluate to get the stub. + */ +const EXPRESSIONS_BY_FILE = { + "attribute.js": new Map([ + [ + "Attribute", + `{ + const a = document.createAttribute("class") + a.value = "autocomplete-suggestions"; + a; + }`, + ], + ]), + "comment-node.js": new Map([ + [ + "Comment", + `{ + document.createComment("test\\nand test\\nand test\\nand test\\nand test\\nand test\\nand test") + }`, + ], + ]), + "date-time.js": new Map([ + ["DateTime", `new Date(1459372644859)`], + ["InvalidDateTime", `new Date("invalid")`], + ]), + "infinity.js": new Map([ + ["Infinity", `Infinity`], + ["NegativeInfinity", `-Infinity`], + ]), + "nan.js": new Map([["NaN", `2 * document`]]), + "null.js": new Map([["Null", `null`]]), + "number.js": new Map([ + ["Int", `2 + 3`], + ["True", `true`], + ["False", `false`], + ["NegZeroGrip", `1 / -Infinity`], + ]), + "stylesheet.js": new Map([ + [ + "StyleSheet", + { + expression: ` + (async function() { + const link = document.createElement("link"); + link.setAttribute("rel", "stylesheet"); + link.type = "text/css"; + link.href = "https://example.com/styles.css"; + const onStylesheetHandled = new Promise(res => { + // The file does not exist so we'll get an error event, but it will + // still be put in document.styleSheets with its src, which is what we want. + link.addEventListener("error", () => res(), { once: true}); + }) + document.head.appendChild(link); + await onStylesheetHandled; + return document.styleSheets[0]; + })() + `, + async: true, + }, + ], + ]), + "symbol.js": new Map([ + ["Symbol", `Symbol("foo")`], + ["SymbolWithoutIdentifier", `Symbol()`], + ["SymbolWithLongString", `Symbol("aa".repeat(10000))`], + ]), + "text-node.js": new Map([ + [ + "testRendering", + `let tn = document.createTextNode("hello world"); + document.body.append(tn); + tn;`, + ], + ["testRenderingDisconnected", `document.createTextNode("hello world")`], + ["testRenderingWithEOL", `document.createTextNode("hello\\nworld")`], + ["testRenderingWithDoubleQuote", `document.createTextNode('hello"world')`], + [ + "testRenderingWithLongString", + `document.createTextNode("a\\n" + ("a").repeat(20000))`, + ], + ]), + "undefined.js": new Map([["Undefined", `undefined`]]), + "window.js": new Map([ + ["Window", `window`], + [ + "CrossOriginIframeContentWindow", + { + expression: ` + (async function() { + const iframe = document.createElement("iframe"); + const onLoaded = new Promise(resolve => + iframe.addEventListener("load", resolve, {once: true}) + ); + iframe.src = "http://example.org/document-builder.sjs?html=example.org"; + document.body.append(iframe); + await onLoaded; + return iframe.contentWindow; + })() + `, + async: true, + }, + ], + [ + "CrossOriginIframeTopWindow", + { + expression: `window.top`, + iframeUrlForExecution: + "https://example.net/document-builder.sjs?html=example.net", + }, + ], + ]), + // XXX: File a bug blocking Bug 1671400 for enabling automatic generation for one of + // the following file. + // "accessible.js", + // "accessor.js", + // "big-int.js", + // "document-type.js", + // "document.js", + // "element-node.js", + // "error.js", + // "event.js", + // "failure.js", + // "function.js", + // "grip-array.js", + // "grip-entry.js", + // "grip-map.js", + // "grip.js", + // "long-string.js", + // "object-with-text.js", + // "object-with-url.js", + // "promise.js", + // "regexp.js", +}; + +add_task(async function () { + const isStubsUpdate = Services.env.get(STUBS_UPDATE_ENV) == "true"; + + const tab = await addTab(TEST_URI); + const { + CommandsFactory, + } = require("devtools/shared/commands/commands-factory"); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + let failed = false; + for (const stubFile of Object.keys(EXPRESSIONS_BY_FILE)) { + info(`${isStubsUpdate ? "Update" : "Check"} ${stubFile}`); + + const generatedStubs = await generateStubs(commands, stubFile); + if (isStubsUpdate) { + await writeStubsToFile(stubFile, generatedStubs); + ok(true, `${stubFile} was updated`); + continue; + } + + const existingStubs = getStubFile(stubFile); + if (generatedStubs.size !== existingStubs.size) { + failed = true; + continue; + } + + for (const [key, packet] of generatedStubs) { + const packetStr = getSerializedPacket(packet, { + sortKeys: true, + replaceActorIds: true, + }); + const grip = getSerializedPacket(existingStubs.get(key), { + sortKeys: true, + replaceActorIds: true, + }); + is(packetStr, grip, `"${key}" packet has expected value`); + failed = failed || packetStr !== grip; + } + } + + if (failed) { + ok( + false, + "The reps stubs need to be updated by running `" + + `mach test ${getCurrentTestFilePath()} --headless --setenv STUBS_UPDATE=true` + + "`" + ); + } else { + ok(true, "Stubs are up to date"); + } + + await removeTab(tab); +}); + +async function generateStubs(commands, stubFile) { + const stubs = new Map(); + + for (const [key, options] of EXPRESSIONS_BY_FILE[stubFile]) { + const expression = + typeof options == "string" ? options : options.expression; + const executeOptions = {}; + if (options.async === true) { + executeOptions.mapped = { await: true }; + } + if (options.iframeUrlForExecution) { + const { promise: onIframeTargetCreated, resolve } = + Promise.withResolvers(); + const onTargetAvailable = ({ targetFront }) => { + if (targetFront.url === options.iframeUrlForExecution) { + resolve(targetFront); + } + }; + await commands.targetCommand.watchTargets({ + types: [commands.targetCommand.TYPES.FRAME], + onAvailable: onTargetAvailable, + }); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [options.iframeUrlForExecution], + url => { + const iframe = content.document.createElement("iframe"); + iframe.src = url; + content.document.body.append(iframe); + } + ); + + const targetFront = await onIframeTargetCreated; + executeOptions.selectedTargetFront = targetFront; + + await commands.targetCommand.unwatchTargets({ + types: [commands.targetCommand.TYPES.FRAME], + onAvailable: onTargetAvailable, + }); + } + const { result } = await commands.scriptCommand.execute( + expression, + executeOptions + ); + stubs.set(key, getCleanedPacket(stubFile, key, result)); + } + + return stubs; +} + +function getCleanedPacket(stubFile, key, packet) { + // Remove the targetFront property that has a cyclical reference and that we don't need + // in our node tests. + delete packet.targetFront; + + const existingStubs = getStubFile(stubFile); + if (!existingStubs) { + return packet; + } + + // Strip escaped characters. + const safeKey = key + .replace(/\\n/g, "\n") + .replace(/\\r/g, "\r") + .replace(/\\\"/g, `\"`) + .replace(/\\\'/g, `\'`); + if (!existingStubs.has(safeKey)) { + return packet; + } + + // If the stub already exist, we want to ignore irrelevant properties (generated id, timer, …) + // that might changed and "pollute" the diff resulting from this stub generation. + const existingPacket = existingStubs.get(safeKey); + + // copy existing contentDomReference + if ( + packet._grip?.contentDomReference?.id && + existingPacket._grip?.contentDomReference?.id + ) { + packet._grip.contentDomReference = existingPacket._grip.contentDomReference; + } + + // `window`'s properties count can vary from OS to OS, so we clean `ownPropertyLength`. + if ( + existingPacket && + packet._grip?.class === "Window" && + typeof packet._grip.ownPropertyLength == + typeof existingPacket._grip.ownPropertyLength + ) { + packet._grip.ownPropertyLength = existingPacket._grip.ownPropertyLength; + } + + return packet; +} + +// HELPER + +const CHROME_PREFIX = "chrome://mochitests/content/browser/"; +const STUBS_FOLDER = "devtools/client/shared/components/test/node/stubs/reps/"; +const STUBS_UPDATE_ENV = "STUBS_UPDATE"; + +/** + * Write stubs to a given file + * + * @param {String} fileName: The file to write the stubs in. + * @param {Map} packets: A Map of the packets. + */ +async function writeStubsToFile(fileName, packets) { + const mozRepo = Services.env.get("MOZ_DEVELOPER_REPO_DIR"); + const filePath = `${mozRepo}/${STUBS_FOLDER + fileName}`; + + const stubs = Array.from(packets.entries()).map(([key, packet]) => { + const stringifiedPacket = getSerializedPacket(packet); + return `stubs.set(\`${key}\`, ${stringifiedPacket});`; + }); + + const fileContent = `/* 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"; +/* + * THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND. RUN browser_reps_stubs.js with STUBS_UPDATE=true env TO UPDATE. + */ + +const stubs = new Map(); +${stubs.join("\n\n")} + +module.exports = stubs; +`; + + const textEncoder = new TextEncoder(); + await IOUtils.write(filePath, textEncoder.encode(fileContent)); +} + +function getStubFile(fileName) { + return require(CHROME_PREFIX + STUBS_FOLDER + fileName); +} + +function sortObjectKeys(obj) { + const isArray = Array.isArray(obj); + const isObject = Object.prototype.toString.call(obj) === "[object Object]"; + const isFront = obj?._grip; + + if (isObject && !isFront) { + // Reorder keys for objects, but skip fronts to avoid infinite recursion. + const sortedKeys = Object.keys(obj).sort((k1, k2) => k1.localeCompare(k2)); + const withSortedKeys = {}; + sortedKeys.forEach(k => { + withSortedKeys[k] = k !== "stacktrace" ? sortObjectKeys(obj[k]) : obj[k]; + }); + return withSortedKeys; + } else if (isArray) { + return obj.map(item => sortObjectKeys(item)); + } + return obj; +} + +/** + * @param {Object} packet + * The packet to serialize. + * @param {Object} options + * @param {Boolean} options.sortKeys + * Pass true to sort all keys alphabetically in the packet before serialization. + * For instance stub comparison should not fail if the order of properties changed. + * @param {Boolean} options.replaceActorIds + * Pass true to replace actorIDs with a fake one so it's easier to compare stubs + * that includes grips. + */ +function getSerializedPacket( + packet, + { sortKeys = false, replaceActorIds = false } = {} +) { + if (sortKeys) { + packet = sortObjectKeys(packet); + } + + const actorIdPlaceholder = "XXX"; + + return JSON.stringify( + packet, + function (key, value) { + // The message can have fronts that we need to serialize + if (value && value._grip) { + return { + _grip: value._grip, + actorID: replaceActorIds ? actorIdPlaceholder : value.actorID, + }; + } + + if ( + replaceActorIds && + (key === "actor" || key === "actorID" || key === "sourceId") && + typeof value === "string" + ) { + return actorIdPlaceholder; + } + + return value; + }, + 2 + ); +} diff --git a/devtools/client/shared/components/test/chrome/accordion.snapshots.js b/devtools/client/shared/components/test/chrome/accordion.snapshots.js new file mode 100644 index 0000000000..0f649b52d6 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/accordion.snapshots.js @@ -0,0 +1,176 @@ +/* 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"; + +window._snapshots = { + "Accordion basic render.": { + type: "ul", + props: { + className: "accordion", + tabIndex: -1, + }, + children: [ + { + type: "li", + props: { + id: "accordion-item-1", + className: "accordion-item", + "aria-labelledby": "accordion-item-1-header", + }, + children: [ + { + type: "h2", + props: { + id: "accordion-item-1-header", + className: "accordion-header", + tabIndex: 0, + "aria-expanded": false, + "aria-label": "Test Accordion Item 1", + onKeyDown: "event => this.onHeaderKeyDown(event, item)", + onClick: "event => this.onHeaderClick(event, item)", + }, + children: [ + { + type: "span", + props: { + className: "theme-twisty", + role: "presentation", + }, + children: null, + }, + { + type: "span", + props: { className: "accordion-header-label" }, + children: ["Test Accordion Item 1"], + }, + ], + }, + { + type: "div", + props: { + className: "accordion-content", + hidden: true, + role: "presentation", + }, + children: null, + }, + ], + }, + { + type: "li", + props: { + id: "accordion-item-2", + className: "accordion-item", + "aria-labelledby": "accordion-item-2-header", + }, + children: [ + { + type: "h2", + props: { + id: "accordion-item-2-header", + className: "accordion-header", + tabIndex: 0, + "aria-expanded": false, + "aria-label": "Test Accordion Item 2", + onKeyDown: "event => this.onHeaderKeyDown(event, item)", + onClick: "event => this.onHeaderClick(event, item)", + }, + children: [ + { + type: "span", + props: { + className: "theme-twisty", + role: "presentation", + }, + children: null, + }, + { + type: "span", + props: { className: "accordion-header-label" }, + children: ["Test Accordion Item 2"], + }, + { + type: "span", + props: { + className: "accordion-header-buttons", + role: "presentation", + }, + children: [ + { + type: "button", + props: {}, + children: null, + }, + ], + }, + ], + }, + { + type: "div", + props: { + className: "accordion-content", + hidden: true, + role: "presentation", + }, + children: null, + }, + ], + }, + { + type: "li", + props: { + id: "accordion-item-3", + className: "accordion-item accordion-open", + "aria-labelledby": "accordion-item-3-header", + }, + children: [ + { + type: "h2", + props: { + id: "accordion-item-3-header", + className: "accordion-header", + tabIndex: 0, + "aria-expanded": true, + "aria-label": "Test Accordion Item 3", + onKeyDown: "event => this.onHeaderKeyDown(event, item)", + onClick: "event => this.onHeaderClick(event, item)", + }, + children: [ + { + type: "span", + props: { + className: "theme-twisty open", + role: "presentation", + }, + children: null, + }, + { + type: "span", + props: { + className: "accordion-header-label", + }, + children: ["Test Accordion Item 3"], + }, + ], + }, + { + type: "div", + props: { + className: "accordion-content", + hidden: false, + role: "presentation", + }, + children: [ + { + type: "div", + props: {}, + children: null, + }, + ], + }, + ], + }, + ], + }, +}; diff --git a/devtools/client/shared/components/test/chrome/chrome.toml b/devtools/client/shared/components/test/chrome/chrome.toml new file mode 100644 index 0000000000..ff9a5f1883 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/chrome.toml @@ -0,0 +1,87 @@ +[DEFAULT] +support-files = [ + "head.js", + "accordion.snapshots.js", +] + +["test_GridElementWidthResizer.html"] + +["test_GridElementWidthResizer_RTL.html"] + +["test_HSplitBox_01.html"] + +["test_accordion.html"] + +["test_frame_01.html"] + +["test_frame_02.html"] + +["test_list.html"] + +["test_list_keyboard.html"] + +["test_notification_box_01.html"] + +["test_notification_box_02.html"] + +["test_notification_box_03.html"] + +["test_notification_box_04.html"] + +["test_notification_box_05.html"] + +["test_searchbox-with-autocomplete.html"] + +["test_searchbox.html"] + +["test_sidebar_toggle.html"] + +["test_smart-trace-grouping.html"] + +["test_smart-trace-source-maps.html"] + +["test_smart-trace.html"] + +["test_stack-trace-source-maps.html"] + +["test_stack-trace.html"] + +["test_tabs_accessibility.html"] + +["test_tabs_menu.html"] + +["test_tree-view_01.html"] + +["test_tree-view_02.html"] + +["test_tree_01.html"] + +["test_tree_02.html"] + +["test_tree_03.html"] + +["test_tree_04.html"] + +["test_tree_05.html"] + +["test_tree_06.html"] + +["test_tree_07.html"] + +["test_tree_08.html"] + +["test_tree_09.html"] + +["test_tree_10.html"] + +["test_tree_11.html"] + +["test_tree_12.html"] + +["test_tree_13.html"] + +["test_tree_14.html"] + +["test_tree_15.html"] + +["test_tree_16.html"] diff --git a/devtools/client/shared/components/test/chrome/head.js b/devtools/client/shared/components/test/chrome/head.js new file mode 100644 index 0000000000..7abe54942f --- /dev/null +++ b/devtools/client/shared/components/test/chrome/head.js @@ -0,0 +1,379 @@ +/* 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"}] */ + +/* global _snapshots */ + +"use strict"; + +var { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +var { Assert } = ChromeUtils.importESModule( + "resource://testing-common/Assert.sys.mjs" +); +var { gDevTools } = require("resource://devtools/client/framework/devtools.js"); +var { BrowserLoader } = ChromeUtils.import( + "resource://devtools/shared/loader/browser-loader.js" +); +var { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +var { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); +var DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +var { require: browserRequire } = BrowserLoader({ + baseURI: "resource://devtools/client/shared/", + window, +}); + +const React = browserRequire("devtools/client/shared/vendor/react"); +const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); +const dom = browserRequire("devtools/client/shared/vendor/react-dom-factories"); +const TestUtils = browserRequire( + "devtools/client/shared/vendor/react-dom-test-utils" +); + +const ShallowRenderer = browserRequire( + "devtools/client/shared/vendor/react-test-renderer-shallow" +); +const TestRenderer = browserRequire( + "devtools/client/shared/vendor/react-test-renderer" +); + +var EXAMPLE_URL = "https://example.com/browser/browser/devtools/shared/test/"; + +SimpleTest.registerCleanupFunction(() => { + window._snapshots = null; +}); + +function forceRender(comp) { + return setState(comp, {}).then(() => setState(comp, {})); +} + +// All tests are asynchronous. +SimpleTest.waitForExplicitFinish(); + +function onNextAnimationFrame(fn) { + return () => requestAnimationFrame(() => requestAnimationFrame(fn)); +} + +function setState(component, newState) { + return new Promise(resolve => { + component.setState(newState, onNextAnimationFrame(resolve)); + }); +} + +function dumpn(msg) { + dump(`SHARED-COMPONENTS-TEST: ${msg}\n`); +} + +/** + * Tree View + */ + +const TEST_TREE_VIEW = { + A: { label: "A", value: "A" }, + B: { label: "B", value: "B" }, + C: { label: "C", value: "C" }, + D: { label: "D", value: "D" }, + E: { label: "E", value: "E" }, + F: { label: "F", value: "F" }, + G: { label: "G", value: "G" }, + H: { label: "H", value: "H" }, + I: { label: "I", value: "I" }, + J: { label: "J", value: "J" }, + K: { label: "K", value: "K" }, + L: { label: "L", value: "L" }, +}; + +TEST_TREE_VIEW.children = { + A: [TEST_TREE_VIEW.B, TEST_TREE_VIEW.C, TEST_TREE_VIEW.D], + B: [TEST_TREE_VIEW.E, TEST_TREE_VIEW.F, TEST_TREE_VIEW.G], + C: [TEST_TREE_VIEW.H, TEST_TREE_VIEW.I], + D: [TEST_TREE_VIEW.J], + E: [TEST_TREE_VIEW.K, TEST_TREE_VIEW.L], + F: [], + G: [], + H: [], + I: [], + J: [], + K: [], + L: [], +}; + +const TEST_TREE_VIEW_INTERFACE = { + provider: { + getChildren: x => TEST_TREE_VIEW.children[x.label], + hasChildren: x => !!TEST_TREE_VIEW.children[x.label].length, + getLabel: x => x.label, + getValue: x => x.value, + getKey: x => x.label, + getType: () => "string", + }, + object: TEST_TREE_VIEW.A, + columns: [{ id: "default" }, { id: "value" }], +}; + +/** + * Tree + */ + +var TEST_TREE_INTERFACE = { + getParent: x => TEST_TREE.parent[x], + getChildren: x => TEST_TREE.children[x], + renderItem: (x, depth, focused) => + "-".repeat(depth) + x + ":" + focused + "\n", + getRoots: () => ["A", "M"], + getKey: x => "key-" + x, + itemHeight: 1, + onExpand: x => TEST_TREE.expanded.add(x), + onCollapse: x => TEST_TREE.expanded.delete(x), + isExpanded: x => TEST_TREE.expanded.has(x), +}; + +function isRenderedTree(actual, expectedDescription, msg) { + const expected = expectedDescription.map(x => x + "\n").join(""); + dumpn(`Expected tree:\n${expected}`); + dumpn(`Actual tree:\n${actual}`); + is(actual, expected, msg); +} + +function isAccessibleTree(tree, options = {}) { + const treeNode = tree.refs.tree; + is(treeNode.getAttribute("tabindex"), "0", "Tab index is set"); + is(treeNode.getAttribute("role"), "tree", "Tree semantics is present"); + if (options.hasActiveDescendant) { + ok( + treeNode.hasAttribute("aria-activedescendant"), + "Tree has an active descendant set" + ); + } + + const treeNodes = [...treeNode.querySelectorAll(".tree-node")]; + for (const node of treeNodes) { + ok(node.id, "TreeNode has an id"); + is(node.getAttribute("role"), "treeitem", "Tree item semantics is present"); + is( + parseInt(node.getAttribute("aria-level"), 10), + parseInt(node.getAttribute("data-depth"), 10) + 1, + "Aria level attribute is set correctly" + ); + } +} + +// Encoding of the following tree/forest: +// +// A +// |-- B +// | |-- E +// | | |-- K +// | | `-- L +// | |-- F +// | `-- G +// |-- C +// | |-- H +// | `-- I +// `-- D +// `-- J +// M +// `-- N +// `-- O +var TEST_TREE = { + children: { + A: ["B", "C", "D"], + B: ["E", "F", "G"], + C: ["H", "I"], + D: ["J"], + E: ["K", "L"], + F: [], + G: [], + H: [], + I: [], + J: [], + K: [], + L: [], + M: ["N"], + N: ["O"], + O: [], + }, + parent: { + A: null, + B: "A", + C: "A", + D: "A", + E: "B", + F: "B", + G: "B", + H: "C", + I: "C", + J: "D", + K: "E", + L: "E", + M: null, + N: "M", + O: "N", + }, + expanded: new Set(), +}; + +/** + * Frame + */ +function checkFrameString({ + el, + file, + line, + column, + source, + functionName, + shouldLink, + tooltip, + locationPrefix, +}) { + const $ = selector => el.querySelector(selector); + + const $func = $(".frame-link-function-display-name"); + const $source = $(".frame-link-source"); + const $locationPrefix = $(".frame-link-prefix"); + const $filename = $(".frame-link-filename"); + const $line = $(".frame-link-line"); + + is($filename.textContent, file, "Correct filename"); + is( + el.getAttribute("data-line"), + line ? `${line}` : null, + "Expected `data-line` found" + ); + is( + el.getAttribute("data-column"), + column ? `${column}` : null, + "Expected `data-column` found" + ); + is($source.getAttribute("title"), tooltip, "Correct tooltip"); + is($source.tagName, shouldLink ? "A" : "SPAN", "Correct linkable status"); + if (shouldLink) { + is($source.getAttribute("href"), source, "Correct source"); + } + + if (line != null) { + let lineText = `:${line}`; + if (column != null) { + lineText += `:${column}`; + } + + is($line.textContent, lineText, "Correct line number"); + } else { + ok(!$line, "Should not have an element for `line`"); + } + + if (functionName != null) { + is($func.textContent, functionName, "Correct function name"); + } else { + ok(!$func, "Should not have an element for `functionName`"); + } + + if (locationPrefix != null) { + is($locationPrefix.textContent, locationPrefix, "Correct prefix"); + } else { + ok(!$locationPrefix, "Should not have an element for `locationPrefix`"); + } +} + +function checkSmartFrameString({ el, location, functionName, tooltip }) { + const $ = selector => el.querySelector(selector); + + const $func = $(".title"); + const $location = $(".location"); + + is($location.textContent, location, "Correct filename"); + is(el.getAttribute("title"), tooltip, "Correct tooltip"); + if (functionName != null) { + is($func.textContent, functionName, "Correct function name"); + } else { + ok(!$func, "Should not have an element for `functionName`"); + } +} + +function renderComponent(component, props) { + const el = React.createElement(component, props, {}); + // By default, renderIntoDocument() won't work for stateless components, but + // it will work if the stateless component is wrapped in a stateful one. + // See https://github.com/facebook/react/issues/4839 + const wrappedEl = dom.span({}, [el]); + const renderedComponent = TestUtils.renderIntoDocument(wrappedEl); + return ReactDOM.findDOMNode(renderedComponent).children[0]; +} + +function shallowRenderComponent(component, props) { + const el = React.createElement(component, props); + const renderer = new ShallowRenderer(); + renderer.render(el, {}); + return renderer.getRenderOutput(); +} + +/** + * Creates a React Component for testing + * + * @param {string} factory - factory object of the component to be created + * @param {object} props - React props for the component + * @returns {object} - container Node, Object with React component + * and querySelector function with $ as name. + */ +async function createComponentTest(factory, props) { + const container = document.createElement("div"); + document.body.appendChild(container); + + const component = ReactDOM.render(factory(props), container); + await forceRender(component); + + return { + container, + component, + $: s => container.querySelector(s), + }; +} + +async function waitFor(condition = () => true, delay = 50) { + do { + const res = condition(); + if (res) { + return res; + } + await new Promise(resolve => setTimeout(resolve, delay)); + } while (true); +} + +/** + * Matches a component tree rendererd using TestRenderer to a given expected JSON + * snapshot. + * @param {String} name + * Name of the function derived from a test [step] name. + * @param {Object} el + * React element to be rendered using TestRenderer. + */ +function matchSnapshot(name, el) { + if (!_snapshots) { + is(false, "No snapshots were loaded into test."); + } + + const snapshot = _snapshots[name]; + if (snapshot === undefined) { + is(false, `Snapshot for "${name}" not found.`); + } + + const renderer = TestRenderer.create(el, {}); + const tree = renderer.toJSON(); + + is( + JSON.stringify(tree, (key, value) => + typeof value === "function" ? value.toString() : value + ), + JSON.stringify(snapshot), + name + ); +} diff --git a/devtools/client/shared/components/test/chrome/test_GridElementWidthResizer.html b/devtools/client/shared/components/test/chrome/test_GridElementWidthResizer.html new file mode 100644 index 0000000000..cf30255141 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_GridElementWidthResizer.html @@ -0,0 +1,209 @@ +<!-- 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> + <!-- Basic tests for the GridElementWidthResizer component. --> + <head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link href="chrome://mochikit/content/tests/SimpleTest/test.css" rel="stylesheet" type="text/css"/> + <link href="chrome://devtools/skin/splitters.css" rel="stylesheet" type="text/css"/> + <link href="chrome://devtools/content/shared/components/splitter/GridElementResizer.css" rel="stylesheet" type="text/css"/> + <style> + * { + box-sizing: border-box; + } + + html { + --theme-splitter-color: red; + } + + main { + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: 20px 20px 20px; + grid-gap: 10px; + } + + .a, + .b, + .c, + .d { + border: 1px solid green; + } + + header { + grid-column: 1 / -1; + } + .a { + grid-column: 1 / 2; + grid-row: 2 / -1; + min-width: 100px; + } + .b { + grid-column: 2 / 3; + grid-row: 2 / -1; + } + + .c { + grid-column: 3 / 4; + grid-row: 2 / 3; + } + + .d { + grid-column: 3 / 4; + grid-row: 3 / 4; + min-width: 150px; + } + + .resizer-a { + grid-column: 1 / 2; + grid-row: 2 / -1; + } + + .resizer-d { + grid-column: 3 / 4; + grid-row: 2 / -1; + } + </style> + </head> + <body> + <main></main> + <pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +const FUDGE_FACTOR = .5; +function aboutEq(a, b) { + dumpn(`Checking ${a} ~= ${b}`); + return Math.abs(a - b) < FUDGE_FACTOR; +} + +window.onload = async function () { + try { + const React = browserRequire("devtools/client/shared/vendor/react"); + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const dom = require("devtools/client/shared/vendor/react-dom-factories"); + + const GridElementWidthResizer = React.createFactory(browserRequire("devtools/client/shared/components/splitter/GridElementWidthResizer")); + ok(GridElementWidthResizer, "Should get GridElementWidthResizer"); + + const resizedA = []; + const resizedD = []; + + ReactDOM.render([ + dom.header({}, "header"), + dom.aside({className: "a"}, "A"), + GridElementWidthResizer({ + getControlledElementNode: () => a, + enabled: true, + position: "end", + className: "resizer-a", + onResizeEnd: width => resizedA.push(width), + }), + dom.section({className: "b"}, "B"), + GridElementWidthResizer({ + getControlledElementNode: () => window.document.querySelector(".b"), + enabled: false, + position: "start", + className: "resizer-disabled", + }), + dom.aside({className: "c"}, "C"), + dom.aside({className: "d"}, "D"), + GridElementWidthResizer({ + getControlledElementNode: () => d, + enabled: true, + position: "start", + className: "resizer-d", + onResizeEnd: width => resizedD.push(width), + }), + ], window.document.querySelector("main")); + + // wait for a bit as we may not have everything setup yet. + await new Promise(res => setTimeout(res, 10)); + + const a = window.document.querySelector(".a"); + const d = window.document.querySelector(".d"); + + // Test that we properly rendered our two resizers, and not the disabled one. + const resizers = window.document.querySelectorAll(".grid-element-width-resizer"); + is(resizers.length, 2, "The 2 enabled resizers are rendered"); + + const [resizerA, resizerD] = resizers; + + ok(resizerA.classList.contains("resizer-a") + && resizerA.classList.contains("end"), "resizerA has expected classes"); + ok(resizerD.classList.contains("resizer-d") + && resizerD.classList.contains("start"), "resizerD has expected classes"); + + const aBoundingRect = a.getBoundingClientRect(); + const aOriginalWidth = aBoundingRect.width; + + info("Resize element A"); + await resize(resizerA, aBoundingRect.right + 20); + + is(resizedA.length, 1, "onResizeEnd was called once"); + is(resizedD.length, 0, "resizerD was not impacted"); + let aWidth = a.getBoundingClientRect().width; + is(resizedA[0], aWidth, "onResizeEnd gives the width of the controlled element"); + ok(aboutEq(aWidth, aOriginalWidth + 20), + "controlled element was resized to the expected size"); + + info("Resize element A below its min width"); + await resize(resizerA, [aBoundingRect.left + 10]); + aWidth = a.getBoundingClientRect().width; + ok(aboutEq(aWidth, 100), "controlled element was resized to its min width"); + + info("Resize element D below its min width"); + const dBoundingRect = d.getBoundingClientRect(); + const dOriginalWidth = dBoundingRect.width; + + await resize(resizerD, dBoundingRect.left + 100); + is(resizedD.length, 1, "onResizeEnd was called once for d"); + is(resizedA.length, 2, "onResizeEnd wasn't called for a"); + let dWidth = d.getBoundingClientRect().width; + is(resizedD[0], dWidth, "onResizeEnd gives the width of the controlled element"); + ok(aboutEq(dWidth, 150), "controlled element wasn't resized below its min-width"); + + info("Resize element D"); + await resize(resizerD, dBoundingRect.left - 15); + dWidth = d.getBoundingClientRect().width; + is(resizedD[1], dWidth, "onResizeEnd gives the width of the controlled element"); + ok(aboutEq(dWidth, dOriginalWidth + 15), "element was resized"); + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + + async function resize(resizer, clientX) { + info("Mouse down to start dragging"); + synthesizeMouseAtCenter(resizer, { button: 0, type: "mousedown" }, window); + await SimpleTest.promiseWaitForCondition( + () => document.firstElementChild.classList.contains("dragging"), + "dragging class is never set on the root element" + ); + ok(true, "The dragging class is set on the root element"); + + const event = new MouseEvent("mousemove", { clientX }); + resizer.dispatchEvent(event); + + info("Mouse up to stop resizing"); + synthesizeMouseAtCenter(document.body, { button: 0, type: "mouseup" }, window); + + await SimpleTest.promiseWaitForCondition( + () => !document.firstElementChild.classList.contains("dragging"), + "dragging class is never removed from the root element" + ); + ok(true, "The dragging class is removed from the root element"); + } +}; +</script> +</pre> + </body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_GridElementWidthResizer_RTL.html b/devtools/client/shared/components/test/chrome/test_GridElementWidthResizer_RTL.html new file mode 100644 index 0000000000..3768ecf0a0 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_GridElementWidthResizer_RTL.html @@ -0,0 +1,210 @@ +<!-- 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> + <!-- Basic tests for the GridElementWidthResizer component. --> + <head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link href="chrome://mochikit/content/tests/SimpleTest/test.css" rel="stylesheet" type="text/css"/> + <link href="chrome://devtools/skin/splitters.css" rel="stylesheet" type="text/css"/> + <link href="chrome://devtools/content/shared/components/splitter/GridElementResizer.css" rel="stylesheet" type="text/css"/> + <style> + * { + box-sizing: border-box; + } + + html { + --theme-splitter-color: red; + } + + main { + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: 20px 20px 20px; + grid-gap: 10px; + direction: rtl; + } + + .a, + .b, + .c, + .d { + border: 1px solid green; + } + + header { + grid-column: 1 / -1; + } + .a { + grid-column: 1 / 2; + grid-row: 2 / -1; + min-width: 100px; + } + .b { + grid-column: 2 / 3; + grid-row: 2 / -1; + } + + .c { + grid-column: 3 / 4; + grid-row: 2 / 3; + } + + .d { + grid-column: 3 / 4; + grid-row: 3 / 4; + min-width: 150px; + } + + .resizer-a { + grid-column: 1 / 2; + grid-row: 2 / -1; + } + + .resizer-d { + grid-column: 3 / 4; + grid-row: 2 / -1; + } + </style> + </head> + <body> + <main></main> + <pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +const FUDGE_FACTOR = .5; +function aboutEq(a, b) { + dumpn(`Checking ${a} ~= ${b}`); + return Math.abs(a - b) < FUDGE_FACTOR; +} + +window.onload = async function () { + try { + const React = browserRequire("devtools/client/shared/vendor/react"); + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const dom = require("devtools/client/shared/vendor/react-dom-factories"); + + const GridElementWidthResizer = React.createFactory(browserRequire("devtools/client/shared/components/splitter/GridElementWidthResizer")); + ok(GridElementWidthResizer, "Should get GridElementWidthResizer"); + + const resizedA = []; + const resizedD = []; + + ReactDOM.render([ + dom.header({}, "header"), + dom.aside({className: "a"}, "A"), + GridElementWidthResizer({ + getControlledElementNode: () => a, + enabled: true, + position: "end", + className: "resizer-a", + onResizeEnd: width => resizedA.push(width), + }), + dom.section({className: "b"}, "B"), + GridElementWidthResizer({ + getControlledElementNode: () => window.document.querySelector(".b"), + enabled: false, + position: "start", + className: "resizer-disabled", + }), + dom.aside({className: "c"}, "C"), + dom.aside({className: "d"}, "D"), + GridElementWidthResizer({ + getControlledElementNode: () => d, + enabled: true, + position: "start", + className: "resizer-d", + onResizeEnd: width => resizedD.push(width), + }), + ], window.document.querySelector("main")); + + // wait for a bit as we may not have everything setup yet. + await new Promise(res => setTimeout(res, 10)); + + const a = window.document.querySelector(".a"); + const d = window.document.querySelector(".d"); + + // Test that we properly rendered our two resizers, and not the disabled one. + const resizers = window.document.querySelectorAll(".grid-element-width-resizer"); + is(resizers.length, 2, "The 2 enabled resizers are rendered"); + + const [resizerA, resizerD] = resizers; + + ok(resizerA.classList.contains("resizer-a") + && resizerA.classList.contains("end"), "resizerA has expected classes"); + ok(resizerD.classList.contains("resizer-d") + && resizerD.classList.contains("start"), "resizerD has expected classes"); + + const aBoundingRect = a.getBoundingClientRect(); + const aOriginalWidth = aBoundingRect.width; + + info("Resize element A"); + await resize(resizerA, aBoundingRect.left - 20); + + is(resizedA.length, 1, "onResizeEnd was called once"); + is(resizedD.length, 0, "resizerD was not impacted"); + let aWidth = a.getBoundingClientRect().width; + is(resizedA[0], aWidth, "onResizeEnd gives the width of the controlled element"); + ok(aboutEq(aWidth, aOriginalWidth + 20), + "controlled element was resized to the expected size"); + + info("Resize element A below its min width"); + await resize(resizerA, [aBoundingRect.right - 10]); + aWidth = a.getBoundingClientRect().width; + ok(aboutEq(aWidth, 100), "controlled element was resized to its min width"); + + info("Resize element D below its min width"); + const dBoundingRect = d.getBoundingClientRect(); + const dOriginalWidth = dBoundingRect.width; + + await resize(resizerD, dBoundingRect.right - 100); + is(resizedD.length, 1, "onResizeEnd was called once for d"); + is(resizedA.length, 2, "onResizeEnd wasn't called for a"); + let dWidth = d.getBoundingClientRect().width; + is(resizedD[0], dWidth, "onResizeEnd gives the width of the controlled element"); + ok(aboutEq(dWidth, 150), "controlled element wasn't resized below its min-width"); + + info("Resize element D"); + await resize(resizerD, dBoundingRect.right + 15); + dWidth = d.getBoundingClientRect().width; + is(resizedD[1], dWidth, "onResizeEnd gives the width of the controlled element"); + ok(aboutEq(dWidth, dOriginalWidth + 15), "element was resized"); + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + + async function resize(resizer, clientX) { + info("Mouse down to start dragging"); + synthesizeMouseAtCenter(resizer, { button: 0, type: "mousedown" }, window); + await SimpleTest.promiseWaitForCondition( + () => document.firstElementChild.classList.contains("dragging"), + "dragging class is never set on the root element" + ); + ok(true, "The dragging class is set on the root element"); + + const event = new MouseEvent("mousemove", { clientX }); + resizer.dispatchEvent(event); + + info("Mouse up to stop resizing"); + synthesizeMouseAtCenter(document.body, { button: 0, type: "mouseup" }, window); + + await SimpleTest.promiseWaitForCondition( + () => !document.firstElementChild.classList.contains("dragging"), + "dragging class is never removed from the root element" + ); + ok(true, "The dragging class is removed from the root element"); + } +}; +</script> +</pre> + </body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_HSplitBox_01.html b/devtools/client/shared/components/test/chrome/test_HSplitBox_01.html new file mode 100644 index 0000000000..09da5dac6b --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_HSplitBox_01.html @@ -0,0 +1,140 @@ +<!-- 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> +<!-- +Basic tests for the HSplitBox component. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <link rel="stylesheet" href="chrome://devtools/skin/splitters.css" type="text/css"/> + <link rel="stylesheet" href="chrome://devtools/skin/components-h-split-box.css" type="text/css"/> + <style> + html { + --theme-splitter-color: black; + } + </style> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +const FUDGE_FACTOR = .1; +function aboutEq(a, b) { + dumpn(`Checking ${a} ~= ${b}`); + return Math.abs(a - b) < FUDGE_FACTOR; +} + +window.onload = async function () { + try { + const React = browserRequire("devtools/client/shared/vendor/react"); + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + + const HSplitBox = React.createFactory(browserRequire("devtools/client/shared/components/HSplitBox")); + ok(HSplitBox, "Should get HSplitBox"); + + const newSizes = []; + + async function renderBox(props) { + const boxProps = Object.assign({ + start: "hello!", + end: "world!", + startWidth: .5, + onResize(newSize) { + newSizes.push(newSize); + } + }, props); + const el = ReactDOM.render(HSplitBox(boxProps), window.document.body); + // wait until the element is rendered. + await SimpleTest.promiseWaitForCondition( + () => document.querySelector(".devtools-side-splitter") + ); + return el; + } + + await renderBox(); + + // Test that we properly rendered our two panes. + + let panes = document.querySelectorAll(".h-split-box-pane"); + is(panes.length, 2, "Should get two panes"); + is(panes[0].style.flexGrow, "0.5", "Each pane should have .5 width"); + is(panes[1].style.flexGrow, "0.5", "Each pane should have .5 width"); + is(panes[0].textContent.trim(), "hello!", "First pane should be hello"); + is(panes[1].textContent.trim(), "world!", "Second pane should be world"); + + // Now change the left width and assert that the changes are reflected. + + await renderBox({ startWidth: .25 }); + panes = document.querySelectorAll(".h-split-box-pane"); + is(panes.length, 2, "Should still have two panes"); + is(panes[0].style.flexGrow, "0.25", "First pane's width should be .25"); + is(panes[1].style.flexGrow, "0.75", "Second pane's width should be .75"); + + // Mouse moves without having grabbed the splitter should have no effect. + + const container = document.querySelector(".h-split-box"); + ok(container, "Should get our container .h-split-box"); + + const { left, top, width } = container.getBoundingClientRect(); + const middle = left + width / 2; + const oneQuarter = left + width / 4; + const threeQuarters = left + 3 * width / 4; + + synthesizeMouse(container, middle, top, { type: "mousemove" }, window); + is(newSizes.length, 0, "Mouse moves without dragging the splitter should have no effect"); + + // Send a mouse down on the splitter, and then move the mouse a couple + // times. Now we should get resizes. + + const splitter = document.querySelector(".devtools-side-splitter"); + ok(splitter, "Should get our splitter"); + + synthesizeMouseAtCenter(splitter, { button: 0, type: "mousedown" }, window); + + function mouseMove(clientX) { + const event = new MouseEvent("mousemove", { clientX }); + document.defaultView.top.dispatchEvent(event); + } + + mouseMove(middle); + is(newSizes.length, 1, "Should get 1 resize"); + ok(aboutEq(newSizes[0], .5), "New size should be ~.5"); + + mouseMove(left); + is(newSizes.length, 2, "Should get 2 resizes"); + ok(aboutEq(newSizes[1], 0), "New size should be ~0"); + + mouseMove(oneQuarter); + is(newSizes.length, 3, "Sould get 3 resizes"); + ok(aboutEq(newSizes[2], .25), "New size should be ~.25"); + + mouseMove(threeQuarters); + is(newSizes.length, 4, "Should get 4 resizes"); + ok(aboutEq(newSizes[3], .75), "New size should be ~.75"); + + synthesizeMouseAtCenter(splitter, { button: 0, type: "mouseup" }, window); + + // Now that we have let go of the splitter, mouse moves should not result in resizes. + + synthesizeMouse(container, middle, top, { type: "mousemove" }, window); + is(newSizes.length, 4, "Should still have 4 resizes"); + + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_accordion.html b/devtools/client/shared/components/test/chrome/test_accordion.html new file mode 100644 index 0000000000..60d179be6f --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_accordion.html @@ -0,0 +1,141 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<!DOCTYPE HTML> +<html> +<!-- +Test that Accordion renders correctly. +--> +<head> + <meta charset="utf-8"> + <title>Accordion component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script type="application/javascript" src="resource://testing-common/sinon-7.2.7.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="stylesheet" href="chrome://devtools/skin/light-theme.css" type="text/css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script src="accordion.snapshots.js" type="application/javascript"></script> +<script type="application/javascript"> + +"use strict"; + +/* global sinon */ + +window.onload = async function() { + try { + const { button, div } = require("devtools/client/shared/vendor/react-dom-factories"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const { + Simulate, + renderIntoDocument, + findAllInRenderedTree, + } = browserRequire("devtools/client/shared/vendor/react-dom-test-utils"); + const Accordion = + browserRequire("devtools/client/shared/components/Accordion"); + + const testItems = [ + { + header: "Test Accordion Item 1", + id: "accordion-item-1", + component: div({}), + opened: false, + onToggle: sinon.spy(), + }, + { + header: "Test Accordion Item 2", + id: "accordion-item-2", + component: div({}), + buttons: button({}), + opened: false, + onToggle: sinon.spy(), + }, + { + header: "Test Accordion Item 3", + id: "accordion-item-3", + component: div({}), + opened: true, + onToggle: sinon.spy(), + }, + ]; + + // Accordion basic render + const accordion = React.createElement(Accordion, { items: testItems }); + + matchSnapshot("Accordion basic render.", accordion); + + const tree = renderIntoDocument(accordion); + const headers = findAllInRenderedTree(tree, c => c.className === "accordion-header"); + + Simulate.click(headers[0]); + ok(testItems[0].onToggle.calledWith(true), "Handle toggling with click."); + ok(testItems[1].onToggle.notCalled, + "onToggle wasn't called on element we didn't click on."); + + isDeeply( + tree.state, + { + everOpened: { + "accordion-item-1": true, + "accordion-item-2": false, + "accordion-item-3": true, + }, + opened: { + "accordion-item-1": true, + "accordion-item-2": false, + "accordion-item-3": true, + }, + }, + "State updated correctly" + ); + + Simulate.keyDown(headers[0], { key: "Enter" }); + ok(testItems[0].onToggle.calledWith(false), "Handle toggling with Enter key."); + isDeeply( + tree.state, + { + everOpened: { + "accordion-item-1": true, + "accordion-item-2": false, + "accordion-item-3": true, + }, + opened: { + "accordion-item-1": false, + "accordion-item-2": false, + "accordion-item-3": true, + }, + }, + "State updated correctly" + ); + + Simulate.keyDown(headers[1], { key: " " }); + ok(testItems[1].onToggle.calledWith(true), "Handle toggling with Space key."); + isDeeply( + tree.state, + { + everOpened: { + "accordion-item-1": true, + "accordion-item-2": true, + "accordion-item-3": true, + }, + opened: { + "accordion-item-1": false, + "accordion-item-2": true, + "accordion-item-3": true, + }, + }, + "State updated correctly" + ); + + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_frame_01.html b/devtools/client/shared/components/test/chrome/test_frame_01.html new file mode 100644 index 0000000000..f763b21395 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_frame_01.html @@ -0,0 +1,361 @@ +<!-- 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> + <!-- +Test the formatting of the file name, line and columns are correct in frame components, +with optional columns, unknown and non-URL sources. +--> + <head> + <meta charset="utf-8" /> + <title>Frame component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link + rel="stylesheet" + type="text/css" + href="chrome://mochikit/content/tests/SimpleTest/test.css" + /> + </head> + <body> + <pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const Frame = React.createFactory(browserRequire("devtools/client/shared/components/Frame")); + ok(Frame, "Should get Frame"); + + // Check when there's a column + await checkFrameComponent({ + frame: { + source: "https://myfile.com/mahscripts.js", + line: 55, + column: 10, + } + }, { + file: "mahscripts.js", + line: 55, + column: 10, + shouldLink: true, + tooltip: "View source in Debugger → https://myfile.com/mahscripts.js:55:10", + }); + + // Check when there's no column + await checkFrameComponent({ + frame: { + source: "https://myfile.com/mahscripts.js", + line: 55, + } + }, { + file: "mahscripts.js", + line: 55, + shouldLink: true, + tooltip: "View source in Debugger → https://myfile.com/mahscripts.js:55", + }); + + // Check when column === 0 + await checkFrameComponent({ + frame: { + source: "https://myfile.com/mahscripts.js", + line: 55, + column: 0, + } + }, { + file: "mahscripts.js", + line: 55, + shouldLink: true, + tooltip: "View source in Debugger → https://myfile.com/mahscripts.js:55", + }); + + // Check when there's an error in CSS (View source in Style Editor) + await checkFrameComponent({ + frame: { + source: "https://myfile.com/cafebabe.css", + line: 13, + }, + messageSource: "css", + }, { + file: "cafebabe.css", + line: 13, + shouldLink: true, + tooltip: "View source in Style Editor → https://myfile.com/cafebabe.css:13", + }); + + + // Check when there's no parseable URL source; + // should not link but should render line/columns + await checkFrameComponent({ + frame: { + source: "self-hosted", + line: 1, + } + }, { + file: "self-hosted", + line: "1", + shouldLink: false, + tooltip: "self-hosted:1", + }); + await checkFrameComponent({ + frame: { + source: "self-hosted", + line: 1, + column: 10, + } + }, { + file: "self-hosted", + line: "1", + column: "10", + shouldLink: false, + tooltip: "self-hosted:1:10", + }); + + // Check when there's no source; + // should not link but should render line/columns + await checkFrameComponent({ + frame: { + line: 1, + } + }, { + file: "(unknown)", + line: "1", + shouldLink: false, + tooltip: "(unknown):1", + }); + await checkFrameComponent({ + frame: { + line: 1, + column: 10, + } + }, { + file: "(unknown)", + line: "1", + column: "10", + shouldLink: false, + tooltip: "(unknown):1:10", + }); + + // Check when there's a column, but no line; + // no line/column info should render + await checkFrameComponent({ + frame: { + source: "https://myfile.com/mahscripts.js", + column: 55, + } + }, { + file: "mahscripts.js", + shouldLink: true, + tooltip: "View source in Debugger → https://myfile.com/mahscripts.js", + }); + + // Check when line is 0; this should be an invalid + // line option, so don't render line/column + await checkFrameComponent({ + frame: { + source: "https://myfile.com/mahscripts.js", + line: 0, + column: 55, + } + }, { + file: "mahscripts.js", + shouldLink: true, + tooltip: "View source in Debugger → https://myfile.com/mahscripts.js", + }); + + // Check that line and column can be strings + await checkFrameComponent({ + frame: { + source: "https://myfile.com/mahscripts.js", + line: "10", + column: "55", + } + }, { + file: "mahscripts.js", + line: 10, + column: 55, + shouldLink: true, + tooltip: "View source in Debugger → https://myfile.com/mahscripts.js:10:55", + }); + + // Check that line and column can be strings, + // and that the `0` rendering rules apply when they are strings as well + await checkFrameComponent({ + frame: { + source: "https://myfile.com/mahscripts.js", + line: "0", + column: "55", + } + }, { + file: "mahscripts.js", + shouldLink: true, + tooltip: "View source in Debugger → https://myfile.com/mahscripts.js", + }); + + // Check that the showFullSourceUrl option works correctly + await checkFrameComponent({ + frame: { + source: "https://myfile.com/mahscripts.js", + line: 0, + }, + showFullSourceUrl: true + }, { + file: "https://myfile.com/mahscripts.js", + shouldLink: true, + tooltip: "View source in Debugger → https://myfile.com/mahscripts.js", + }); + + // Check that the showFunctionName option works correctly + await checkFrameComponent({ + frame: { + functionDisplayName: "myfun", + source: "https://myfile.com/mahscripts.js", + line: 0, + } + }, { + functionName: null, + file: "mahscripts.js", + shouldLink: true, + tooltip: "View source in Debugger → https://myfile.com/mahscripts.js", + }); + + await checkFrameComponent({ + frame: { + functionDisplayName: "myfun", + source: "https://myfile.com/mahscripts.js", + line: 0, + }, + showFunctionName: true + }, { + functionName: "myfun", + file: "mahscripts.js", + shouldLink: true, + tooltip: "View source in Debugger → https://myfile.com/mahscripts.js", + }); + + // Check that anonymous function name is not displayed unless explicitly enabled + await checkFrameComponent({ + frame: { + source: "https://myfile.com/mahscripts.js", + line: 0, + }, + showFunctionName: true + }, { + functionName: null, + file: "mahscripts.js", + shouldLink: true, + tooltip: "View source in Debugger → https://myfile.com/mahscripts.js", + }); + + await checkFrameComponent({ + frame: { + source: "https://myfile.com/mahscripts.js", + line: 0, + }, + showFunctionName: true, + showAnonymousFunctionName: true + }, { + functionName: "<anonymous>", + file: "mahscripts.js", + shouldLink: true, + tooltip: "View source in Debugger → https://myfile.com/mahscripts.js", + }); + + // Check if file is rendered with "/" for root documents when showEmptyPathAsHost is false + await checkFrameComponent({ + frame: { + source: "https://www.cnn.com/", + line: "1", + }, + showEmptyPathAsHost: false, + }, { + file: "/", + line: "1", + shouldLink: true, + tooltip: "View source in Debugger → https://www.cnn.com/:1", + }); + + // Check if file is rendered with hostname for root documents when showEmptyPathAsHost is true + await checkFrameComponent({ + frame: { + source: "https://www.cnn.com/", + line: "1", + }, + showEmptyPathAsHost: true, + }, { + file: "www.cnn.com", + line: "1", + shouldLink: true, + tooltip: "View source in Debugger → https://www.cnn.com/:1", + }); + + const resolvedLocation = { + sourceId: "whatever", + line: 23, + sourceUrl: "https://bugzilla.mozilla.org/original.js", + }; + const mockSourceMapURLService = { + subscribeByLocation (loc, callback) { + // Resolve immediately. + callback({ + url: resolvedLocation.sourceUrl, + line: resolvedLocation.line, + column: undefined, + }); + return () => {}; + }, + }; + await checkFrameComponent({ + frame: { + line: 97, + source: "https://bugzilla.mozilla.org/bundle.js", + }, + sourceMapURLService: mockSourceMapURLService, + }, { + file: "original.js", + line: resolvedLocation.line, + shouldLink: true, + tooltip: "View source in Debugger → https://bugzilla.mozilla.org/original.js:23", + source: "https://bugzilla.mozilla.org/original.js", + }); + + // Check when a message comes from a logPoint, + // a prefix should render before source + await checkFrameComponent({ + frame: { + source: "https://myfile.com/mahscripts.js", + line: 55, + column: 10, + options: { logPoint: true }, + } + }, { + file: "mahscripts.js", + line: 55, + column: 10, + shouldLink: true, + tooltip: "View source in Debugger → https://myfile.com/mahscripts.js:55:10", + }); + + function checkFrameComponent(input, expected) { + const props = Object.assign({ onClick: () => {} }, input); + const frame = ReactDOM.render(Frame(props), window.document.body); + const el = ReactDOM.findDOMNode(frame); + const { source } = input.frame; + checkFrameString(Object.assign({ el, source }, expected)); + ReactDOM.unmountComponentAtNode(window.document.body); + } + + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> + </body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_frame_02.html b/devtools/client/shared/components/test/chrome/test_frame_02.html new file mode 100644 index 0000000000..dd4bf9c2b7 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_frame_02.html @@ -0,0 +1,103 @@ +<!-- 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> +<!-- +Test that the frame component reacts to source-map pref changse. +--> +<head> + <meta charset="utf-8"> + <title>Frame component source-map test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const Frame = React.createFactory(browserRequire("devtools/client/shared/components/Frame")); + + const resolvedLocation = { + sourceId: "whatever", + line: 23, + sourceUrl: "https://bugzilla.mozilla.org/original.js", + }; + const mockSourceMapURLService = { + _update () { + this._callback(Services.prefs.getBoolPref(PREF) + ? { + url: resolvedLocation.sourceUrl, + line: resolvedLocation.line, + column: undefined, + } + : null); + }, + subscribeByLocation (loc, callback) { + this._callback = callback; + // Resolve immediately. + this._update(); + + return () => {}; + }, + }; + + const props = { + onClick: () => {}, + frame: { + line: 97, + source: "https://bugzilla.mozilla.org/bundle.js", + }, + sourceMapURLService: mockSourceMapURLService, + }; + + const PREF = "devtools.source-map.client-service.enabled"; + Services.prefs.setBoolPref(PREF, false); + + const frame = ReactDOM.render(Frame(props), window.document.body); + const el = ReactDOM.findDOMNode(frame); + const { source } = props.frame; + + const expectedOriginal = { + file: "original.js", + line: resolvedLocation.line, + shouldLink: true, + tooltip: "View source in Debugger → https://bugzilla.mozilla.org/original.js:23", + source: "https://bugzilla.mozilla.org/original.js", + }; + const expectedGenerated = { + file: "bundle.js", + line: 97, + shouldLink: true, + tooltip: "View source in Debugger → https://bugzilla.mozilla.org/bundle.js:97", + source: "https://bugzilla.mozilla.org/bundle.js", + }; + + checkFrameString(Object.assign({ el, source }, expectedGenerated)); + + Services.prefs.setBoolPref(PREF, true); + mockSourceMapURLService._update(); + checkFrameString(Object.assign({ el, source }, expectedOriginal)); + + Services.prefs.setBoolPref(PREF, false); + mockSourceMapURLService._update(); + checkFrameString(Object.assign({ el, source }, expectedGenerated)); + + Services.prefs.clearUserPref(PREF); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_list.html b/devtools/client/shared/components/test/chrome/test_list.html new file mode 100644 index 0000000000..8be8907b5d --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_list.html @@ -0,0 +1,127 @@ +<!-- 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> +<!-- +Test that List renders correctly. +--> +<head> + <meta charset="utf-8"> + <title>List component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="stylesheet" href="chrome://devtools/skin/light-theme.css" type="text/css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script src="list.snapshots.js" type="application/javascript"></script> +<script type="application/javascript"> + +"use strict"; + +window.onload = async function() { + try { + const { div } = require("devtools/client/shared/vendor/react-dom-factories"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const { + Simulate, + renderIntoDocument, + findRenderedDOMComponentWithClass, + scryRenderedDOMComponentsWithTag, + scryRenderedComponentsWithType, + } = browserRequire("devtools/client/shared/vendor/react-dom-test-utils"); + const { List, ListItem } = + browserRequire("devtools/client/shared/components/List"); + + const testItems = [ + { + component: div({ className: "item-1" }, "Test List Item 1"), + className: "list-item-1", + key: "list-item-1", + }, + { + component: div({ className: "item-2" }, "Test List Item 2"), + className: "list-item-2", + key: "list-item-2", + }, + { + component: div({ className: "item-3" }, "Test List Item 3"), + className: "list-item-3", + key: "list-item-3", + }, + ]; + + const listReactEl = React.createElement(List, { + items: testItems, + labelledBy: "test-labelledby", + }); + + const list = renderIntoDocument(listReactEl); + const listEl = findRenderedDOMComponentWithClass(list, "list"); + const items = scryRenderedComponentsWithType(list, ListItem); + const itemEls = scryRenderedDOMComponentsWithTag(list, "li"); + + function testCurrent(index) { + is(list.state.current, index, "Correct current item."); + is(listEl.getAttribute("aria-activedescendant"), testItems[index].key, + "Correct active descendant."); + } + + is(items.length, 3, "Correct number of list item components in tree."); + is(itemEls.length, 3, "Correct number of list items is rendered."); + info("Testing initial tree properties."); + for (let index = 0; index < items.length; index++) { + const item = items[index]; + const itemEl = itemEls[index]; + const { active, current, item: itemProp } = item.props; + const content = itemEl.querySelector(".list-item-content"); + + is(active, false, "Correct active state."); + is(current, false, "Correct current state."); + is(itemProp, testItems[index], "Correct rendered item."); + is(item.contentRef.current, content, "Correct content ref."); + + is(itemEl.className, testItems[index].className, "Correct list item class."); + is(itemEl.id, testItems[index].key, "Correct list item it."); + is(content.getAttribute("role"), "presentation", "Correct content role."); + + is(content.innerHTML, + `<div class="item-${index + 1}">Test List Item ${index + 1}</div>`, + "Content rendered correctly."); + } + + is(list.state.current, null, "Current item is not set by default."); + is(list.state.active, null, "Active item is not set by default."); + is(list.listRef.current, listEl, "Correct list ref."); + + is(listEl.className, "list", "Correct list class."); + is(listEl.tabIndex, 0, "List is focusable."); + ok(!listEl.hasAttribute("aria-label"), "List has no label."); + is(listEl.getAttribute("aria-labelledby"), "test-labelledby", + "Correct list labelled by attribute."); + ok(!listEl.hasAttribute("aria-activedescendant"), + "No active descendant set by default."); + + Simulate.focus(listEl); + testCurrent(0); + + Simulate.click(itemEls[2]); + testCurrent(2); + + Simulate.blur(listEl); + testCurrent(2); + + Simulate.focus(listEl); + testCurrent(2); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_list_keyboard.html b/devtools/client/shared/components/test/chrome/test_list_keyboard.html new file mode 100644 index 0000000000..7558404ed2 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_list_keyboard.html @@ -0,0 +1,283 @@ +<!-- 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> +<!-- +Test that List component has working keyboard interactions. +--> +<head> + <meta charset="utf-8"> + <title>List component keyboard test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="stylesheet" href="chrome://devtools/skin/light-theme.css" type="text/css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +"use strict"; + +window.onload = function() { + try { + const { a, button, div } = + require("devtools/client/shared/vendor/react-dom-factories"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const { + Simulate, + findRenderedDOMComponentWithClass, + findRenderedDOMComponentWithTag, + scryRenderedDOMComponentsWithTag, + } = browserRequire("devtools/client/shared/vendor/react-dom-test-utils"); + const { List } = + browserRequire("devtools/client/shared/components/List"); + + const testItems = [ + { + component: div({}, "Test List Item 1"), + className: "list-item-1", + key: "list-item-1", + }, + { + component: div({}, + "Test List Item 2", + a({ href: "#" }, "Focusable 1"), + button({ }, "Focusable 2")), + className: "list-item-2", + key: "list-item-2", + }, + { + component: div({}, "Test List Item 3"), + className: "list-item-3", + key: "list-item-3", + }, + ]; + + const list = React.createElement(List, { + items: testItems, + labelledby: "test-labelledby", + }); + + const tree = ReactDOM.render(list, document.body); + const listEl = findRenderedDOMComponentWithClass(tree, "list"); + scryRenderedDOMComponentsWithTag(tree, "li"); + const defaultFocus = listEl.ownerDocument.body; + + function blurEl(el) { + // Simulate.blur does not seem to update the activeElement. + el.blur(); + } + + function focusEl(el) { + // Simulate.focus does not seem to update the activeElement. + el.focus(); + } + + const tests = [{ + name: "Test default List state. Keyboard focus is set to document body by default.", + state: { current: null, active: null }, + activeElement: defaultFocus, + }, { + name: "Current item must be set to the first list item on initial focus. " + + "Keyboard focus should be set on list's conatiner (<ul>).", + action: () => focusEl(listEl), + activeElement: listEl, + state: { current: 0 }, + }, { + name: "Current item should remain set even when the list is blured. " + + "Keyboard focus should be set back to document body.", + action: () => blurEl(listEl), + state: { current: 0 }, + activeElement: defaultFocus, + }, { + name: "Unset list's current state.", + action: () => tree.setState({ current: null }), + state: { current: null }, + }, { + name: "Current item must be re-set again to the first list item on initial " + + "focus. Keyboard focus should be set on list's conatiner (<ul>).", + action: () => focusEl(listEl), + activeElement: listEl, + state: { current: 0 }, + }, { + name: "Current item should be updated to next on ArrowDown.", + event: { type: "keyDown", el: listEl, options: { key: "ArrowDown" }}, + state: { current: 1 }, + }, { + name: "Current item should be updated to last on ArrowDown.", + event: { type: "keyDown", el: listEl, options: { key: "ArrowDown" }}, + state: { current: 2 }, + }, { + name: "Current item should remain on last on ArrowDown.", + event: { type: "keyDown", el: listEl, options: { key: "ArrowDown" }}, + state: { current: 2 }, + }, { + name: "Current item should be updated to previous on ArrowUp.", + event: { type: "keyDown", el: listEl, options: { key: "ArrowUp" }}, + state: { current: 1 }, + }, { + name: "Current item should be updated to first on ArrowUp.", + event: { type: "keyDown", el: listEl, options: { key: "ArrowUp" }}, + state: { current: 0 }, + }, { + name: "Current item should remain on first on ArrowUp.", + event: { type: "keyDown", el: listEl, options: { key: "ArrowUp" }}, + state: { current: 0 }, + }, { + name: "Current item should be updated to last on End.", + event: { type: "keyDown", el: listEl, options: { key: "End" }}, + state: { current: 2 }, + }, { + name: "Current item should be updated to first on Home.", + event: { type: "keyDown", el: listEl, options: { key: "Home" }}, + state: { current: 0 }, + }, { + name: "Current item should be set as active on Enter.", + event: { type: "keyDown", el: listEl, options: { key: "Enter" }}, + state: { current: 0, active: 0 }, + activeElement: listEl, + }, { + name: "Active item should be unset on Escape.", + event: { type: "keyDown", el: listEl, options: { key: "Escape" }}, + state: { current: 0, active: null }, + }, { + name: "Current item should be set as active on Space.", + event: { type: "keyDown", el: listEl, options: { key: " " }}, + state: { current: 0, active: 0 }, + activeElement: listEl, + }, { + name: "Current item should unset when focus leaves the list.", + action: () => blurEl(listEl), + state: { current: 0, active: null }, + activeElement: defaultFocus, + }, { + name: "Keyboard focus should be set on list's conatiner (<ul>) on focus.", + action: () => focusEl(listEl), + activeElement: listEl, + }, { + name: "Current item should be updated to next on ArrowDown.", + event: { type: "keyDown", el: listEl, options: { key: "ArrowDown" }}, + state: { current: 1, active: null }, + }, { + name: "Current item should be set as active on Enter. Keyboard focus should be " + + "set on the first focusable element inside the list item, if available.", + event: { type: "keyDown", el: listEl, options: { key: "Enter" }}, + state: { current: 1, active: 1 }, + get activeElement() { + // When list item becomes active/inactive, it is replaced with a newly rendered + // one. + return findRenderedDOMComponentWithTag(tree, "a"); + }, + }, { + name: "Keyboard focus should be set to next tabbable element inside the active " + + "list item on Tab.", + action() { + synthesizeKey("KEY_Tab"); + }, + state: { current: 1, active: 1 }, + get activeElement() { + // When list item becomes active/inactive, it is replaced with a newly rendered + // one. + return findRenderedDOMComponentWithTag(tree, "button"); + }, + }, { + name: "Keyboard focus should wrap inside the list item when focused on last " + + "tabbable element.", + action() { + synthesizeKey("KEY_Tab"); + }, + state: { current: 1, active: 1 }, + get activeElement() { + return findRenderedDOMComponentWithTag(tree, "a"); + }, + }, { + name: "Keyboard focus should wrap inside the list item when focused on first " + + "tabbable element.", + action() { + synthesizeKey("KEY_Tab", { shiftKey: true }); + }, + state: { current: 1, active: 1 }, + get activeElement() { + return findRenderedDOMComponentWithTag(tree, "button"); + }, + }, { + name: "Active item should be unset on Escape. Focus should move back to the " + + "list container.", + event: { type: "keyDown", el: listEl, options: { key: "Escape" }}, + state: { current: 1, active: null }, + activeElement: listEl, + }, { + name: "Current item should be set as active on Space. Keyboard focus should be " + + "set on the first focusable element inside the list item, if available.", + event: { type: "keyDown", el: listEl, options: { key: " " }}, + state: { current: 1, active: 1 }, + get activeElement() { + // When list item becomes active/inactive, it is replaced with a newly rendered + // one. + return findRenderedDOMComponentWithTag(tree, "a"); + }, + }, { + name: "Current item should remain set even when the list is blured. " + + "Keyboard focus should be set back to document body.", + action: () => listEl.ownerDocument.activeElement.blur(), + state: { current: 1, active: null, }, + activeElement: defaultFocus, + }, { + name: "Keyboard focus should be set on list's conatiner (<ul>) on focus.", + action: () => focusEl(listEl), + state: { current: 1, active: null }, + activeElement: listEl, + }, { + name: "Current item should be updated to previous on ArrowUp.", + event: { type: "keyDown", el: listEl, options: { key: "ArrowUp" }}, + state: { current: 0, active: null }, + }, { + name: "Current item should be set as active on Enter.", + event: { type: "keyDown", el: listEl, options: { key: "Enter" }}, + state: { current: 0, active: 0 }, + activeElement: listEl, + }, { + name: "Keyboard focus should move to another focusable element outside of the " + + "list when there's nothing to focus on inside the list item.", + action() { + synthesizeKey("KEY_Tab", { shiftKey: true }); + }, + state: { current: 0, active: null }, + activeElement: listEl.ownerDocument.documentElement, + }]; + + for (const test of tests) { + const { action, event, state, name } = test; + + is(listEl, findRenderedDOMComponentWithClass(tree, "list"), "Sanity check"); + + info(name); + if (event) { + const { type, options, el } = event; + Simulate[type](el, options); + } else if (action) { + action(); + } + + if (test.activeElement) { + is(listEl.ownerDocument.activeElement, test.activeElement, + "Focus is set correctly."); + } + + for (const key in state) { + is(tree.state[key], state[key], `${key} state is correct.`); + } + } + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_notification_box_01.html b/devtools/client/shared/components/test/chrome/test_notification_box_01.html new file mode 100644 index 0000000000..2921d607c3 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_notification_box_01.html @@ -0,0 +1,136 @@ +<!-- 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> +<!-- +Test for Notification Box. The test is checking: +* Basic rendering +* Appending correct classname on wrapping +* Appending a notification +* Notification priority +* Closing notification +--> +<head> + <meta charset="utf-8"> + <title>Notification Box</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const { NotificationBox, PriorityLevels } = browserRequire("devtools/client/shared/components/NotificationBox"); + + const renderedBox = shallowRenderComponent(NotificationBox, {}); + is(renderedBox.type, "div", "NotificationBox is rendered as <div>"); + + info("Test rendering NotificationBox with default props"); + const boxElement = React.createElement(NotificationBox); + const notificationBox = TestUtils.renderIntoDocument(boxElement); + const notificationNode = ReactDOM.findDOMNode(notificationBox); + + ok(notificationNode.classList.contains("notificationbox"), + "NotificationBox has expected class"); + ok(notificationNode.classList.contains("border-bottom"), + "NotificationBox has expected class"); + is(notificationNode.textContent, "", + "Empty NotificationBox has no text content"); + + checkNumberOfNotifications(notificationBox, 0); + + // Append a notification + notificationBox.appendNotification( + "Info message", + "id1", + null, + PriorityLevels.PRIORITY_INFO_HIGH + ); + + is (notificationNode.textContent, "Info message", + "The box must display notification message"); + checkNumberOfNotifications(notificationBox, 1); + + // Append more important notification + notificationBox.appendNotification( + "Critical message", + "id2", + null, + PriorityLevels.PRIORITY_CRITICAL_BLOCK + ); + + checkNumberOfNotifications(notificationBox, 1); + + is (notificationNode.textContent, "Critical message", + "The box must display more important notification message"); + + // Append less important notification + notificationBox.appendNotification( + "Warning message", + "id3", + null, + PriorityLevels.PRIORITY_WARNING_HIGH + ); + + checkNumberOfNotifications(notificationBox, 1); + + is (notificationNode.textContent, "Critical message", + "The box must still display the more important notification"); + + ok(notificationBox.getCurrentNotification(), + "There must be current notification"); + + notificationBox.getNotificationWithValue("id1").close(); + checkNumberOfNotifications(notificationBox, 1); + + notificationBox.getNotificationWithValue("id2").close(); + checkNumberOfNotifications(notificationBox, 1); + + notificationBox.getNotificationWithValue("id3").close(); + checkNumberOfNotifications(notificationBox, 0); + + info(`Check "wrapping" prop works as expected`); + // Append wrapping classname to the dom element when passing wrapping prop + const boxElementWrapped = React.createElement(NotificationBox, {wrapping: true}); + const notificationBoxWrapped = TestUtils.renderIntoDocument(boxElementWrapped); + const wrappedNotificationNode = ReactDOM.findDOMNode(notificationBoxWrapped); + + ok(wrappedNotificationNode.classList.contains("wrapping"), + "Wrapped notificationBox has expected class"); + + info(`Check "displayBorderTop/displayBorderBottom" props work as expected`); + const element = React.createElement(NotificationBox, { + displayBorderTop: true, + displayBorderBottom: false, + }); + const renderedElement = TestUtils.renderIntoDocument(element); + const elementNode = ReactDOM.findDOMNode(renderedElement); + + ok(elementNode.classList.contains("border-top"), + "truthy displayBorderTop render a border-top className"); + ok(!elementNode.classList.contains("border-bottom"), + "falsy displayBorderBottom does not render a border-bottom className"); + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; + +function checkNumberOfNotifications(notificationBox, expected) { + is(TestUtils.scryRenderedDOMComponentsWithClass( + notificationBox, "notification").length, expected, + "The notification box must have expected number of notifications"); +} +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_notification_box_02.html b/devtools/client/shared/components/test/chrome/test_notification_box_02.html new file mode 100644 index 0000000000..f74194d128 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_notification_box_02.html @@ -0,0 +1,73 @@ +<!-- 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> +<!-- +Test for Notification Box. The test is checking: +* Using custom callback in a notification +--> +<head> + <meta charset="utf-8"> + <title>Notification Box</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const { NotificationBox, PriorityLevels } = browserRequire("devtools/client/shared/components/NotificationBox"); + + // Test rendering + const boxElement = React.createElement(NotificationBox); + const notificationBox = TestUtils.renderIntoDocument(boxElement); + const notificationNode = ReactDOM.findDOMNode(notificationBox); + + let callbackExecuted = false; + + // Append a notification. + notificationBox.appendNotification( + "Info message", + "id1", + null, + PriorityLevels.PRIORITY_INFO_LOW, + undefined, + (reason) => { + callbackExecuted = true; + is(reason, "removed", "The reason must be expected string"); + } + ); + + is(TestUtils.scryRenderedDOMComponentsWithClass( + notificationBox, "notification").length, 1, + "There must be one notification"); + + const closeButton = notificationNode.querySelector( + ".messageCloseButton"); + + // Click the close button to close the notification. + TestUtils.Simulate.click(closeButton); + + is(TestUtils.scryRenderedDOMComponentsWithClass( + notificationBox, "notification").length, 0, + "The notification box must be empty now"); + + ok(callbackExecuted, "Event callback must be executed."); + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_notification_box_03.html b/devtools/client/shared/components/test/chrome/test_notification_box_03.html new file mode 100644 index 0000000000..816456edd3 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_notification_box_03.html @@ -0,0 +1,87 @@ +<!-- 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> +<!-- +Test for Notification Box. The test is checking: +* Using custom buttons in a notification +--> +<head> + <meta charset="utf-8"> + <title>Notification Box</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const { NotificationBox, PriorityLevels } = browserRequire("devtools/client/shared/components/NotificationBox"); + + // Test rendering + const boxElement = React.createElement(NotificationBox); + const notificationBox = TestUtils.renderIntoDocument(boxElement); + const notificationNode = ReactDOM.findDOMNode(notificationBox); + + let buttonCallbackExecuted = false; + const buttons = [{ + label: "Button1", + callback: () => { + buttonCallbackExecuted = true; + + // Do not close the notification + return true; + }, + }, { + label: "Button2", + callback: () => { + // Close the notification (return value undefined) + }, + }]; + + // Append a notification with buttons. + notificationBox.appendNotification( + "Info message", + "id1", + null, + PriorityLevels.PRIORITY_INFO_LOW, + buttons + ); + + const buttonNodes = notificationNode.querySelectorAll( + ".notificationButton"); + + is(buttonNodes.length, 2, "There must be two buttons"); + + // Click the first button + TestUtils.Simulate.click(buttonNodes[0]); + ok(buttonCallbackExecuted, "Button callback must be executed."); + + is(TestUtils.scryRenderedDOMComponentsWithClass( + notificationBox, "notification").length, 1, + "There must be one notification"); + + // Click the second button (closing the notification) + TestUtils.Simulate.click(buttonNodes[1]); + + is(TestUtils.scryRenderedDOMComponentsWithClass( + notificationBox, "notification").length, 0, + "The notification box must be empty now"); + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_notification_box_04.html b/devtools/client/shared/components/test/chrome/test_notification_box_04.html new file mode 100644 index 0000000000..07ad9af25c --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_notification_box_04.html @@ -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/. --> +<!DOCTYPE HTML> +<html> +<!-- +Test for Notification Box. The test is checking: +* Adding a mdnLink to a notification +--> +<head> + <meta charset="utf-8"> + <title>Notification Box</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const { NotificationBox, PriorityLevels } = browserRequire("devtools/client/shared/components/NotificationBox"); + + // Render notification + const boxElement = React.createElement(NotificationBox); + const notificationBox = TestUtils.renderIntoDocument(boxElement); + const notificationNode = ReactDOM.findDOMNode(notificationBox); + + const mdnLink = "https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors" + + const mdnLinkButton = {mdnUrl: mdnLink, label: "learn more about error" } + + // Append a notification with a learn-more link + notificationBox.appendNotification( + "Info message", + "id1", + null, + PriorityLevels.PRIORITY_INFO_LOW, + [mdnLinkButton], + (e) => false, + ); + + const linkNode = notificationNode.querySelector( + "a.learn-more-link"); + + ok(linkNode, "Link is present"); + + is(linkNode.title, "learn more about error", "link has correct title"); + ok(linkNode.classList.contains("devtools-button"), "link has correct class") + + + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_notification_box_05.html b/devtools/client/shared/components/test/chrome/test_notification_box_05.html new file mode 100644 index 0000000000..b3a4e96378 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_notification_box_05.html @@ -0,0 +1,63 @@ + +<!-- 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> +<!-- +Test for Notification Box. The test is checking: +* the close button is not present when displayCloseButton is false +--> +<head> + <meta charset="utf-8"> + <title>Notification Box</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const { NotificationBox, PriorityLevels } = browserRequire("devtools/client/shared/components/NotificationBox"); + + // Test rendering with close button disabled + const boxElement = React.createElement(NotificationBox, {displayCloseButton: false}); + const notificationBox = TestUtils.renderIntoDocument(boxElement); + const notificationNode = ReactDOM.findDOMNode(notificationBox); + + + + // Append a notification. + notificationBox.appendNotification( + "Info message", + "id1", + null, + PriorityLevels.PRIORITY_INFO_LOW, + [], + (e) => false, + ); + + // Ensure close button is not present + const linkNode = notificationNode.querySelector( + ".messageCloseButton"); + + ok(!linkNode, "Close button is not present"); + + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_searchbox-with-autocomplete.html b/devtools/client/shared/components/test/chrome/test_searchbox-with-autocomplete.html new file mode 100644 index 0000000000..110d5640c1 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_searchbox-with-autocomplete.html @@ -0,0 +1,301 @@ +<!-- 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> +<!-- +Test the searchbox and autocomplete-popup components +--> +<head> + <meta charset="utf-8"> + <title>SearchBox component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<script src="head.js"></script> +<script> +"use strict"; +window.onload = async function () { + /** + * Takes a DOMNode with its children as list items, + * Typically UL > LI and each item's text value is + * compared with the reference item's value as a test + * + * @params {Node} - Node to be compared + * @reference {array} - Reference array for comparison. The selected index is + * highlighted as a single element array ie. ["[abc]", "ab", "abcPQR"], + * Here the element "abc" is highlighted + */ + function compareAutocompleteList(list, reference) { + const delimiter = " - "; + const observedList = [...list.children].map(el => { + return el.classList.contains("autocomplete-selected") + ? `[${el.textContent}]` + : el.textContent + }); + is(observedList.join(delimiter), reference.join(delimiter), + "Autocomplete items are rendered as expected"); + } + + function compareCursorPosition(initialElement) { + const initialPosition = initialElement.selectionStart; + return (element) => { + is(element.selectionStart, initialPosition, "Input cursor position is not changed"); + } + } + + const React = browserRequire("devtools/client/shared/vendor/react"); + const SearchBox = React.createFactory( + browserRequire("devtools/client/shared/components/SearchBox") + ); + const { component, $ } = await createComponentTest(SearchBox, { + type: "search", + autocompleteProvider: (filter) => { + const baseList = [ + "foo", + "BAR", + "baZ", + "abc", + "pqr", + "xyz", + "ABC", + "a1", + "a2", + "a3", + "a4", + "a5", + ]; + if (!filter) { + return []; + } + + const tokens = filter.split(/\s+/g); + const lastToken = tokens[tokens.length - 1]; + const previousTokens = tokens.slice(0, tokens.length - 1); + + if (!lastToken) { + return []; + } + + return baseList + .filter((item) => { + return item.toLowerCase().startsWith(lastToken.toLowerCase()) + && item.toLowerCase() !== lastToken.toLowerCase(); + }) + .sort() + .map(item => ({ + value: [...previousTokens, item].join(" "), + displayValue: item, + })); + }, + onChange: () => null, + }); + + async function testSearchBoxWithAutocomplete() { + ok(!$(".devtools-autocomplete-popup"), "Autocomplete list not visible"); + + $(".devtools-searchinput").focus(); + await forceRender(component); // Wait for state update + ok(!$(".devtools-autocomplete-popup"), "Autocomplete list not visible"); + + sendString("a"); + await forceRender(component); + + compareAutocompleteList($(".devtools-autocomplete-listbox"), [ + "[ABC]", + "a1", + "a2", + "a3", + "a4", + "a5", + "abc", + ]); + + // Blur event + $(".devtools-searchinput").blur(); + await forceRender(component); + ok(!component.state.focused, "focused state was properly set"); + ok(!$(".devtools-autocomplete-popup"), "Autocomplete list removed from DOM"); + } + + async function testKeyEventsWithAutocomplete() { + // Clear the initial input + $(".devtools-searchinput").focus(); + const cursorPositionIsNotChanged = compareCursorPosition($(".devtools-searchinput")); + + // ArrowDown + synthesizeKey("KEY_ArrowDown"); + await forceRender(component); + compareAutocompleteList($(".devtools-autocomplete-listbox"), [ + "ABC", + "[a1]", + "a2", + "a3", + "a4", + "a5", + "abc", + ]); + ok($(".devtools-autocomplete-listbox .autocomplete-item:nth-child(2)") + .className.includes("autocomplete-selected"), + "Selection class applied"); + + // A double ArrowUp should roll back to the bottom of the list + synthesizeKey("KEY_ArrowUp"); + synthesizeKey("KEY_ArrowUp"); + await forceRender(component); + compareAutocompleteList($(".devtools-autocomplete-listbox"), [ + "ABC", + "a1", + "a2", + "a3", + "a4", + "a5", + "[abc]", + ]); + cursorPositionIsNotChanged($(".devtools-searchinput")); + + // PageDown should take -5 places up + synthesizeKey("KEY_PageUp"); + await forceRender(component); + compareAutocompleteList($(".devtools-autocomplete-listbox"), [ + "ABC", + "[a1]", + "a2", + "a3", + "a4", + "a5", + "abc", + ]); + cursorPositionIsNotChanged($(".devtools-searchinput")); + + // PageDown should take +5 places down + synthesizeKey("KEY_PageDown"); + await forceRender(component); + compareAutocompleteList($(".devtools-autocomplete-listbox"), [ + "ABC", + "a1", + "a2", + "a3", + "a4", + "a5", + "[abc]", + ]); + cursorPositionIsNotChanged($(".devtools-searchinput")); + + // Home should take to the top of the list + synthesizeKey("KEY_Home"); + await forceRender(component); + compareAutocompleteList($(".devtools-autocomplete-listbox"), [ + "[ABC]", + "a1", + "a2", + "a3", + "a4", + "a5", + "abc", + ]); + cursorPositionIsNotChanged($(".devtools-searchinput")); + + // End should take to the bottom of the list + synthesizeKey("KEY_End"); + await forceRender(component); + compareAutocompleteList($(".devtools-autocomplete-listbox"), [ + "ABC", + "a1", + "a2", + "a3", + "a4", + "a5", + "[abc]", + ]); + cursorPositionIsNotChanged($(".devtools-searchinput")); + + // Key down in existing state should rollover to the top + synthesizeKey("KEY_ArrowDown"); + await forceRender(component); + // Tab should select the component and hide popup + synthesizeKey("KEY_Tab"); + await forceRender(component); + is(component.state.value, "ABC", "Tab hit selects the item"); + ok(!$(".devtools-autocomplete-popup"), "Tab hit hides the popup"); + + // Activate popup by removing a key + synthesizeKey("KEY_Backspace"); + await forceRender(component); + ok($(".devtools-autocomplete-popup"), "Popup is up"); + compareAutocompleteList($(".devtools-autocomplete-listbox"), [ + "[ABC]", + "abc" + ]); + + // Enter key selection + synthesizeKey("KEY_ArrowUp"); + await forceRender(component); + synthesizeKey("KEY_Enter"); + is(component.state.value, "abc", "Enter selection"); + ok(!$(".devtools-autocomplete-popup"), "Enter/Return hides the popup"); + + // Escape should remove the autocomplete component + synthesizeKey("KEY_Backspace"); + await forceRender(component); + synthesizeKey("KEY_Escape"); + await forceRender(component); + ok(!$(".devtools-autocomplete-popup"), + "Autocomplete list removed from DOM on Escape"); + } + + async function testMouseEventsWithAutocomplete() { + $(".devtools-searchinput").focus(); + await setState(component, { + value: "", + focused: true, + }); + await forceRender(component); + + // ArrowDown + synthesizeKey("KEY_ArrowDown"); + await forceRender(component); + synthesizeMouseAtCenter($(".devtools-searchinput"), {}, window); + await forceRender(component); + is(component.state.focused, true, "Component should now be focused"); + + sendString("pq"); + await forceRender(component); + synthesizeMouseAtCenter( + $(".devtools-autocomplete-listbox .autocomplete-item:nth-child(1)"), + {}, window + ); + await forceRender(component); + is(component.state.value, "pqr", "Mouse click selects the item."); + ok(!$(".devtools-autocomplete-popup"), "Mouse click on item hides the popup"); + } + + async function testTokenizedAutocomplete() { + // Test for string "pqr ab" which should show list of ABC, abc + sendString(" ab"); + await forceRender(component); + compareAutocompleteList($(".devtools-autocomplete-listbox"), [ + "[ABC]", + "abc" + ]); + + // Select the first element, value now should be "pqr ABC" + synthesizeMouseAtCenter( + $(".devtools-autocomplete-listbox .autocomplete-item:nth-child(1)"), + {}, window + ); + is(component.state.value, "pqr ABC", "Post Tokenization value selection"); + } + + add_task(async function () { + await testSearchBoxWithAutocomplete(); + await testKeyEventsWithAutocomplete(); + await testMouseEventsWithAutocomplete(); + await testTokenizedAutocomplete(); + }); +}; +</script> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_searchbox.html b/devtools/client/shared/components/test/chrome/test_searchbox.html new file mode 100644 index 0000000000..8e2f76c1b9 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_searchbox.html @@ -0,0 +1,74 @@ +<!-- 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> +<!-- +Test the searchbox component +--> +<head> + <meta charset="utf-8"> + <title>SearchBox component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<script src="head.js"></script> +<script> +"use strict"; +window.onload = function () { + const React = browserRequire("devtools/client/shared/vendor/react"); + const SearchBox = React.createFactory( + browserRequire("devtools/client/shared/components/SearchBox") + ); + ok(SearchBox, "Got the SearchBox factory"); + + async function testSimpleSearchBox() { + // Test initial state + const { component, $ } = await createComponentTest(SearchBox, { + type: "search", + keyShortcut: "CmdOrCtrl+F", + placeholder: "crazy placeholder", + }); + + is(component.state.value, "", "Initial value is blank"); + ok(!component.state.focused, "Input isn't initially focused"); + ok($(".devtools-searchinput-clear").hidden, "Clear button hidden"); + is($(".devtools-searchinput").placeholder, "crazy placeholder", + "Placeholder is properly set"); + + synthesizeKey("f", { accelKey: true }); + await forceRender(component); // Wait for state update + ok(component.state.focused, "Shortcut key focused the input box"); + + $(".devtools-searchinput").blur(); + await forceRender(component); + ok(!component.state.focused, "`focused` state set to false after blur"); + + // Test changing value in state + await setState(component, { + value: "foo", + }); + + is(component.state.value, "foo", "value was properly set on state"); + is($(".devtools-searchinput").value, "foo", "value was properly set on element"); + + // Filling input should show clear button + ok(!$(".devtools-searchinput-clear").hidden, "Clear button shown"); + + // Clearing value should hide clear button + await setState(component, { + value: "", + }); + await forceRender(component); + ok($(".devtools-searchinput-clear").hidden, "Clear button was hidden"); + } + + add_task(async function () { + await testSimpleSearchBox(); + }); +}; +</script> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_sidebar_toggle.html b/devtools/client/shared/components/test/chrome/test_sidebar_toggle.html new file mode 100644 index 0000000000..0a31037a84 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_sidebar_toggle.html @@ -0,0 +1,59 @@ +<!-- 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> +<!-- +Test sidebar toggle button +--> +<head> + <meta charset="utf-8"> + <title>Sidebar toggle button test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + const SidebarToggle = browserRequire("devtools/client/shared/components/SidebarToggle.js"); + + try { + await test(); + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + + function test() { + const output1 = shallowRenderComponent(SidebarToggle, { + collapsed: false, + collapsePaneTitle: "Expand", + expandPaneTitle: "Collapse" + }); + + is(output1.type, "button", "Output is a button element"); + is(output1.props.title, "Expand", "Proper title is set"); + is(output1.props.className.indexOf("pane-collapsed"), -1, + "Proper class name is set"); + + const output2 = shallowRenderComponent(SidebarToggle, { + collapsed: true, + collapsePaneTitle: "Expand", + expandPaneTitle: "Collapse" + }); + + is(output2.props.title, "Collapse", "Proper title is set"); + ok(output2.props.className.includes("pane-collapsed"), + "Proper class name is set"); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_smart-trace-grouping.html b/devtools/client/shared/components/test/chrome/test_smart-trace-grouping.html new file mode 100644 index 0000000000..174d0f87b4 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_smart-trace-grouping.html @@ -0,0 +1,141 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<!DOCTYPE HTML> +<html> +<!-- +Test the rendering of a stack trace +--> +<head> + <meta charset="utf-8"> + <title>StackTrace component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<script src="head.js"></script> +<script> +"use strict"; + +window.onload = function() { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const SmartTrace = React.createFactory( + browserRequire("devtools/client/shared/components/SmartTrace")); + ok(SmartTrace, "Got the SmartTrace factory"); + + add_task(async function() { + const REACT_FRAMES_COUNT = 10; + + const stacktrace = [ + { + filename: "https://myfile.com/mahscripts.js", + lineNumber: 55, + columnNumber: 10, + functionName: null, + }, + // Simulated Redux frame + { + functionName: "rootReducer", + filename: "https://myfile.com/loader.js -> https://myfile.com/redux.js", + lineNumber: 2, + }, + { + functionName: "loadFunc", + filename: "https://myfile.com/loader.js -> https://myfile.com/loadee.js", + lineNumber: 10, + }, + // Simulated react frames + ...(Array.from({length: REACT_FRAMES_COUNT}, (_, i) => ({ + functionName: "internalReact" + (REACT_FRAMES_COUNT - i), + filename: "https://myfile.com/loader.js -> https://myfile.com/react.js", + lineNumber: Number(i.toString().repeat(2)), + }))), + { + filename: "https://myfile.com/mahscripts.js", + lineNumber: 10, + columnNumber: 3, + functionName: "onClick", + }, + ]; + + const props = { + stacktrace, + onViewSourceInDebugger: () => {}, + }; + + const trace = ReactDOM.render(SmartTrace(props), window.document.body); + await forceRender(trace); + + const traceEl = ReactDOM.findDOMNode(trace); + ok(traceEl, "Rendered SmartTrace has an element"); + + isDeeply(getStacktraceText(traceEl), [ + `<anonymous> https://myfile.com/mahscripts.js:55`, + `rootReducer Redux`, + `loadFunc https://myfile.com/loadee.js:10`, + `▶︎ React 10`, + `onClick https://myfile.com/mahscripts.js:10`, + ], "React frames are grouped - Redux frame is not"); + + info("Expand React group"); + let onReactGroupExpanded = waitFor(() => + traceEl.querySelector(".frames-group.expanded")); + traceEl.querySelector(".group").click(); + await onReactGroupExpanded; + + isDeeply(getStacktraceText(traceEl), [ + `<anonymous> https://myfile.com/mahscripts.js:55`, + `rootReducer Redux`, + `loadFunc https://myfile.com/loadee.js:10`, + `▼ React 10`, + `| internalReact10`, + `| internalReact9`, + `| internalReact8`, + `| internalReact7`, + `| internalReact6`, + `| internalReact5`, + `| internalReact4`, + `| internalReact3`, + `| internalReact2`, + `| internalReact1`, + `onClick https://myfile.com/mahscripts.js:10`, + ], "React frames can be expanded"); + + info("Collapse React group"); + onReactGroupExpanded = waitFor(() => + !traceEl.querySelector(".frames-group.expanded")); + traceEl.querySelector(".group").click(); + await onReactGroupExpanded; + + isDeeply(getStacktraceText(traceEl), [ + `<anonymous> https://myfile.com/mahscripts.js:55`, + `rootReducer Redux`, + `loadFunc https://myfile.com/loadee.js:10`, + `▶︎ React 10`, + `onClick https://myfile.com/mahscripts.js:10`, + ], "React frames can be collapsed"); + }); + + function getStacktraceText(traceElement) { + return Array.from(traceElement.querySelectorAll(".frame, .frames-group")).map(el => { + // If it's a group, we want to append an arrow representing the group state + if (el.classList.contains("frames-group")) { + const arrow = el.classList.contains("expanded") ? "▼" : "▶︎"; + const content = el.querySelector(".group").textContent.trim(); + return `${arrow} ${content}`; + } + + const title = el.querySelector(".title"); + if (el.closest(".frames-group")) { + return `| ${title.textContent}`; + } + + const location = el.querySelector(".location"); + return `${title.textContent} ${location.textContent}`; + }); + } +}; +</script> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_smart-trace-source-maps.html b/devtools/client/shared/components/test/chrome/test_smart-trace-source-maps.html new file mode 100644 index 0000000000..1beade0c0c --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_smart-trace-source-maps.html @@ -0,0 +1,290 @@ +<!-- 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> +<!-- +Test the rendering of a stack trace +--> +<head> + <meta charset="utf-8"> + <title>StackTrace component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<script src="head.js"></script> +<script> +"use strict"; + +window.onload = function() { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const SmartTrace = React.createFactory( + browserRequire("devtools/client/shared/components/SmartTrace")); + ok(SmartTrace, "Got the SmartTrace factory"); + + add_task(async function testHappyPath() { + const stacktrace = [ + { + filename: "https://myfile.com/bundle.js", + lineNumber: 1, + columnNumber: 10, + }, + { + functionName: "loadFunc", + filename: "https://myfile.com/bundle.js", + lineNumber: 2, + }, + ]; + + let onReadyCount = 0; + const props = { + stacktrace, + initialRenderDelay: 2000, + onViewSourceInDebugger: () => {}, + onReady: () => { + onReadyCount++; + }, + // A mock source map service. + sourceMapURLService: { + subscribeByLocation ({ line, column }, callback) { + const newLine = Number(line.toString().repeat(2)); + // Resolve immediately. + callback({ + url: "https://bugzilla.mozilla.org/original.js", + line: newLine, + column, + }); + return () => {}; + }, + }, + }; + + const trace = ReactDOM.render(SmartTrace(props), + window.document.body.querySelector("#s1")); + await forceRender(trace); + + const traceEl = ReactDOM.findDOMNode(trace); + ok(traceEl, "Rendered SmartTrace has an element"); + + const frameEls = Array.from(traceEl.querySelectorAll(".frame")); + ok(frameEls, "Rendered SmartTrace has frames"); + is(frameEls.length, 2, "SmartTrace has 2 frames"); + + checkSmartFrameString({ + el: frameEls[0], + functionName: "<anonymous>", + location: "original.js:11", + tooltip: "View source in Debugger → https://bugzilla.mozilla.org/original.js:11", + }); + + checkSmartFrameString({ + el: frameEls[1], + functionName: "loadFunc", + location: "original.js:22", + tooltip: "View source in Debugger → https://bugzilla.mozilla.org/original.js:22", + }); + + is(onReadyCount, 1, "onReady was called once"); + }); + + add_task(async function testSlowSourcemapService() { + const stacktrace = [ + { + filename: "https://myfile.com/bundle.js", + functionName: "last", + lineNumber: 1, + columnNumber: 10, + }, + { + filename: "https://myfile.com/bundle.js", + functionName: "first", + lineNumber: 2, + columnNumber: 10, + }, + ]; + + const sourcemapTimeout = 2000; + const initialRenderDelay = 300; + let onReadyCount = 0; + + const props = { + stacktrace, + initialRenderDelay, + onViewSourceInDebugger: () => {}, + onReady: () => { + onReadyCount++; + }, + // A mock source map service. + sourceMapURLService: { + subscribeByLocation ({ line, column }, callback) { + // Resolve after a while. + setTimeout(() => { + const newLine = Number(line.toString().repeat(2)); + callback({ + url: "https://myfile.com/react.js", + line: newLine, + column, + }); + }, sourcemapTimeout) + + return () => {}; + }, + }, + }; + + const trace = ReactDOM.render(SmartTrace(props), + window.document.body.querySelector("#s2")); + + let traceEl = ReactDOM.findDOMNode(trace); + ok(!traceEl, "Nothing was rendered at first"); + is(onReadyCount, 0, "onReady isn't called if SmartTrace isn't rendered"); + + info("Wait for the initial delay to be over"); + await new Promise(res => setTimeout(res, initialRenderDelay)); + + traceEl = ReactDOM.findDOMNode(trace); + ok(traceEl, "The trace was rendered"); + + let frameEls = Array.from(traceEl.querySelectorAll(".frame")); + ok(frameEls, "Rendered SmartTrace has frames"); + is(frameEls.length, 2, "SmartTrace has 2 frames"); + + info("Check that the original frames are displayed after the initial delay"); + checkSmartFrameString({ + el: frameEls[0], + functionName: "last", + location: "https://myfile.com/bundle.js:1", + tooltip: "View source in Debugger → https://myfile.com/bundle.js:1", + }); + + checkSmartFrameString({ + el: frameEls[1], + functionName: "first", + location: "https://myfile.com/bundle.js:2", + tooltip: "View source in Debugger → https://myfile.com/bundle.js:2", + }); + + is(onReadyCount, 1, "onReady was called once"); + + info("Check the the sourcemapped version is rendered after the sourcemapTimeout"); + await waitFor(() => !!traceEl.querySelector(".group")); + + frameEls = Array.from(traceEl.querySelectorAll(".frame")); + is(frameEls.length, 0, "SmartTrace has no frame"); + + const groups = Array.from(traceEl.querySelectorAll(".group")); + is(groups.length, 1, "SmartTrace has a group"); + is(groups[0].textContent.trim(), "React 2", "A collapsed React group is displayed"); + + is(onReadyCount, 1, "onReady was only called once"); + }); + + add_task(async function testFlakySourcemapService() { + const stacktrace = [ + { + filename: "https://myfile.com/bundle.js", + functionName: "last", + lineNumber: 1, + columnNumber: 10, + }, + { + filename: "https://myfile.com/bundle.js", + functionName: "pending", + lineNumber: 2, + columnNumber: 10, + }, + { + filename: "https://myfile.com/bundle.js", + functionName: "first", + lineNumber: 3, + columnNumber: 10, + }, + ]; + + const initialRenderDelay = 300; + const onSourceMapResultDebounceDelay = 50; + let onReadyCount = 0; + + const props = { + stacktrace, + initialRenderDelay, + onSourceMapResultDebounceDelay, + onViewSourceInDebugger: () => {}, + onReady: () => { + onReadyCount++; + }, + // A mock source map service. + sourceMapURLService: { + subscribeByLocation ({ line, column }, callback) { + // Don't call the callback for the second frame to simulate a flaky sourcemap + // service request. + if (line === 2) { + return () => {}; + } + + const newLine = Number(line.toString().repeat(2)); + callback({ + url: `https://myfile.com/file-${line}.js`, + line: newLine, + column, + }); + return () => {}; + }, + }, + }; + + const trace = ReactDOM.render(SmartTrace(props), + window.document.body.querySelector("#s3")); + + let traceEl = ReactDOM.findDOMNode(trace); + ok(!traceEl, "Nothing was rendered at first"); + is(onReadyCount, 0, "onReady isn't called if SmartTrace isn't rendered"); + + info("Wait for the initial delay + debounce to be over"); + await waitFor(() => { + const el = ReactDOM.findDOMNode(trace) + return el && el.textContent.includes("file-1.js"); + }); + + traceEl = ReactDOM.findDOMNode(trace); + ok(traceEl, "The trace was rendered"); + + const frameEls = Array.from(traceEl.querySelectorAll(".frame")); + ok(frameEls, "Rendered SmartTrace has frames"); + is(frameEls.length, 3, "SmartTrace has 3 frames"); + + info("Check that the original frames are displayed even if there's no sourcemap " + + "response for some frames"); + checkSmartFrameString({ + el: frameEls[0], + functionName: "last", + location: "file-1.js:11", + tooltip: "View source in Debugger → https://myfile.com/file-1.js:11", + }); + + checkSmartFrameString({ + el: frameEls[1], + functionName: "pending", + location: "bundle.js:2", + tooltip: "View source in Debugger → https://myfile.com/bundle.js:2", + }); + + checkSmartFrameString({ + el: frameEls[2], + functionName: "first", + location: "file-3.js:33", + tooltip: "View source in Debugger → https://myfile.com/file-3.js:33", + }); + + is(onReadyCount, 1, "onReady was only called once"); + }); + +}; +</script> +<section id=s1></section> +<section id=s2></section> +<section id=s3></section> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_smart-trace.html b/devtools/client/shared/components/test/chrome/test_smart-trace.html new file mode 100644 index 0000000000..eedb72cc13 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_smart-trace.html @@ -0,0 +1,172 @@ +<!-- 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> +<!-- +Test the rendering of a stack trace +--> +<head> + <meta charset="utf-8"> + <title>StackTrace component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> + <section id=s1></section> + <section id=s2></section> + <section id=s3></section> + <section id=s4></section> +<script src="head.js"></script> +<script> +"use strict"; + +window.onload = function() { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const SmartTrace = React.createFactory( + browserRequire("devtools/client/shared/components/SmartTrace")); + ok(SmartTrace, "Got the SmartTrace factory"); + + const stacktrace = [ + { + filename: "https://myfile.com/mahscripts.js", + lineNumber: 55, + columnNumber: 10, + functionName: null, + }, + { + functionName: "loadFunc", + filename: "https://myfile.com/loader.js -> https://myfile.com/loadee.js", + lineNumber: 10, + }, + ]; + + add_task(async function testBasic() { + info("Check basic rendering"); + let onReadyCount = 0; + const props = { + stacktrace, + onViewSourceInDebugger: () => {}, + onReady: () => { + onReadyCount++; + }, + }; + await renderSmartTraceAndAssertContent( + window.document.body.querySelector("#s1"), + props + ); + is(onReadyCount, 1, "onReady was called once"); + }); + + add_task(async function testZeroDelay() { + info("Check rendering with source map service and 0 initial delay"); + let onReadyCount = 0; + const props = { + stacktrace, + onViewSourceInDebugger: () => {}, + onReady: () => { + onReadyCount++; + }, + initialRenderDelay: 0, + sourceMapURLService: { + subscribeByLocation: () => {} + }, + }; + await renderSmartTraceAndAssertContent( + window.document.body.querySelector("#s2"), + props + ); + is(onReadyCount, 1, "onReady was called once"); + }); + + add_task(async function testNullDelay() { + info("Check rendering with source map service and null initial delay"); + let onReadyCount = 0; + const props = { + stacktrace, + onViewSourceInDebugger: () => {}, + onReady: () => { + onReadyCount++; + }, + initialRenderDelay: 0, + sourceMapURLService: { + subscribeByLocation: () => {} + }, + }; + await renderSmartTraceAndAssertContent( + window.document.body.querySelector("#s3"), + props + ); + is(onReadyCount, 1, "onReady was called once"); + }); + + add_task(async function testDelay() { + info("Check rendering with source map service and initial delay"); + let onReadyCount = 0; + const props = { + stacktrace, + onViewSourceInDebugger: () => {}, + onReady: () => { + onReadyCount++; + }, + initialRenderDelay: 500, + sourceMapURLService: { + subscribeByLocation: () => {} + }, + }; + const el = window.document.body.querySelector("#s4"); + await renderSmartTraceAndAssertContent( + el, + props, + false + ); + is(onReadyCount, 0, "onReady wasn't called at first"); + info(`Wait for ${props.initialRenderDelay}ms so the stacktrace should be rendered`) + await new Promise(res => setTimeout(res, props.initialRenderDelay)) + is(onReadyCount, 1, "onReady was called after waiting for the initial delay"); + assertRenderedElementContent(el); + }); + + async function renderSmartTraceAndAssertContent(el, props, shouldBeRendered = true) { + let trace; + await new Promise(resolve => { + trace = ReactDOM.render(SmartTrace(props), el, resolve); + }); + + const traceEl = ReactDOM.findDOMNode(trace); + + if (!shouldBeRendered) { + ok(!traceEl, "SmartTrace wasn't rendered initially"); + return; + } + + ok(traceEl, "Rendered SmartTrace has an element"); + assertRenderedElementContent(traceEl); + } + + function assertRenderedElementContent(el) { + const frameEls = Array.from(el.querySelectorAll(".frame")); + ok(frameEls, "Rendered SmartTrace has frames"); + is(frameEls.length, 2, "SmartTrace has 2 frames"); + + checkSmartFrameString({ + el: frameEls[0], + functionName: "<anonymous>", + location: "https://myfile.com/mahscripts.js:55", + tooltip: "View source in Debugger → https://myfile.com/mahscripts.js:55", + }); + + // Check the third frame, the source should be parsed into a valid source URL + checkSmartFrameString({ + el: frameEls[1], + functionName: "loadFunc", + location: "https://myfile.com/loadee.js:10", + tooltip: "View source in Debugger → https://myfile.com/loadee.js:10", + }); + } + +}; +</script> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_stack-trace-source-maps.html b/devtools/client/shared/components/test/chrome/test_stack-trace-source-maps.html new file mode 100644 index 0000000000..7535d4d2df --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_stack-trace-source-maps.html @@ -0,0 +1,98 @@ +<!-- 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> +<!-- +Test the rendering of a stack trace with source maps +--> +<head> + <meta charset="utf-8"> + <title>StackTrace component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<script src="head.js"></script> +<script> +"use strict"; + +window.onload = function () { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const StackTrace = React.createFactory( + browserRequire("devtools/client/shared/components/StackTrace") + ); + ok(StackTrace, "Got the StackTrace factory"); + + add_task(async function () { + const stacktrace = [ + { + filename: "https://bugzilla.mozilla.org/bundle.js", + lineNumber: 99, + columnNumber: 10 + }, + { + functionName: "loadFunc", + filename: "https://bugzilla.mozilla.org/bundle.js", + lineNumber: 108, + } + ]; + + const props = { + stacktrace, + onViewSourceInDebugger: () => {}, + // A mock source map service. + sourceMapURLService: { + subscribeByLocation ({ line, column }, callback) { + const newLine = line === 99 ? 1 : 7; + // Resolve immediately. + callback({ + url: "https://bugzilla.mozilla.org/original.js", + line: newLine, + column, + }); + + return () => {} + }, + }, + }; + + const trace = ReactDOM.render(StackTrace(props), window.document.body); + await forceRender(trace); + + const traceEl = ReactDOM.findDOMNode(trace); + ok(traceEl, "Rendered StackTrace has an element"); + + // Get the child nodes and filter out the text-only whitespace ones + const frameEls = Array.from(traceEl.childNodes) + .filter(n => n.className && n.className.includes("frame")); + ok(frameEls, "Rendered StackTrace has frames"); + is(frameEls.length, 2, "StackTrace has 2 frames"); + + checkFrameString({ + el: frameEls[0], + functionName: "<anonymous>", + source: "https://bugzilla.mozilla.org/original.js", + file: "original.js", + line: 1, + column: 10, + shouldLink: true, + tooltip: "View source in Debugger → https://bugzilla.mozilla.org/original.js:1:10", + }); + + checkFrameString({ + el: frameEls[1], + functionName: "loadFunc", + source: "https://bugzilla.mozilla.org/original.js", + file: "original.js", + line: 7, + column: null, + shouldLink: true, + tooltip: "View source in Debugger → https://bugzilla.mozilla.org/original.js:7", + }); + }); +}; +</script> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_stack-trace.html b/devtools/client/shared/components/test/chrome/test_stack-trace.html new file mode 100644 index 0000000000..56d9288f06 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_stack-trace.html @@ -0,0 +1,100 @@ +<!-- 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> +<!-- +Test the rendering of a stack trace +--> +<head> + <meta charset="utf-8"> + <title>StackTrace component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<script src="head.js"></script> +<script> +"use strict"; + +window.onload = function() { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const StackTrace = React.createFactory( + browserRequire("devtools/client/shared/components/StackTrace") + ); + ok(StackTrace, "Got the StackTrace factory"); + + add_task(async function() { + const stacktrace = [ + { + filename: "https://myfile.com/mahscripts.js", + lineNumber: 55, + columnNumber: 10, + }, + { + asyncCause: "because", + functionName: "loadFunc", + filename: "https://myfile.com/loadee.js", + lineNumber: 10, + }, + ]; + + const props = { + stacktrace, + onViewSourceInDebugger: () => {}, + }; + + const trace = ReactDOM.render(StackTrace(props), window.document.body); + await forceRender(trace); + + const traceEl = ReactDOM.findDOMNode(trace); + ok(traceEl, "Rendered StackTrace has an element"); + + // Get the child nodes and filter out the text-only whitespace ones + const frameEls = Array.from(traceEl.childNodes) + .filter(n => n.className && n.className.includes("frame")); + ok(frameEls, "Rendered StackTrace has frames"); + is(frameEls.length, 3, "StackTrace has 3 frames"); + + // Check the top frame, function name should be anonymous + checkFrameString({ + el: frameEls[0], + functionName: "<anonymous>", + source: "https://myfile.com/mahscripts.js", + file: "https://myfile.com/mahscripts.js", + line: 55, + column: 10, + shouldLink: true, + tooltip: "View source in Debugger → https://myfile.com/mahscripts.js:55:10", + }); + + // Check the async cause node + is(frameEls[1].className, "frame-link-async-cause", + "Async cause has the right class"); + is(frameEls[1].textContent, "(Async: because)", "Async cause has the right label"); + + // Check the third frame, the source should be parsed into a valid source URL + checkFrameString({ + el: frameEls[2], + functionName: "loadFunc", + source: "https://myfile.com/loadee.js", + file: "https://myfile.com/loadee.js", + line: 10, + column: null, + shouldLink: true, + tooltip: "View source in Debugger → https://myfile.com/loadee.js:10", + }); + + // Check the tabs and newlines in the stack trace textContent + const traceText = traceEl.textContent; + const traceLines = traceText.split("\n"); + ok(!!traceLines.length, "There are newlines in the stack trace text"); + is(traceLines.pop(), "", "There is a newline at the end of the stack trace text"); + is(traceLines.length, 3, "The stack trace text has 3 lines"); + ok(traceLines.every(l => l[0] == "\t"), "Every stack trace line starts with tab"); + }); +}; +</script> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tabs_accessibility.html b/devtools/client/shared/components/test/chrome/test_tabs_accessibility.html new file mode 100644 index 0000000000..4d0ea6ef96 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tabs_accessibility.html @@ -0,0 +1,82 @@ +<!-- 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> +<!-- +Test tabs accessibility. +--> +<head> + <meta charset="utf-8"> + <title>Tabs component accessibility test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const { createFactory } = browserRequire("devtools/client/shared/vendor/react"); + const InspectorTabPanel = createFactory(browserRequire("devtools/client/inspector/components/InspectorTabPanel")); + const Tabbar = + createFactory(browserRequire("devtools/client/shared/components/tabs/TabBar")); + const tabbar = Tabbar(); + const tabbarReact = ReactDOM.render(tabbar, window.document.body); + const tabbarEl = ReactDOM.findDOMNode(tabbarReact); + + // Setup for InspectorTabPanel + const tabpanels = document.createElement("div"); + tabpanels.id = "tabpanels"; + document.body.appendChild(tabpanels); + + await addTabWithPanel(0); + await addTabWithPanel(1); + + const tabAnchors = tabbarEl.querySelectorAll("li.tabs-menu-item a"); + + is(tabAnchors[0].parentElement.getAttribute("role"), "presentation", "li role is set correctly"); + is(tabAnchors[0].getAttribute("role"), "tab", "Anchor role is set correctly"); + is(tabAnchors[0].getAttribute("aria-selected"), "true", "Anchor aria-selected is set correctly by default"); + is(tabAnchors[0].getAttribute("aria-controls"), "sidebar-0-panel", "Anchor aria-controls is set correctly"); + is(tabAnchors[1].parentElement.getAttribute("role"), "presentation", "li role is set correctly"); + is(tabAnchors[1].getAttribute("role"), "tab", "Anchor role is set correctly"); + is(tabAnchors[1].getAttribute("aria-selected"), "false", "Anchor aria-selected is set correctly by default"); + is(tabAnchors[1].getAttribute("aria-controls"), "sidebar-1-panel", "Anchor aria-controls is set correctly"); + + await setState(tabbarReact, Object.assign({}, tabbarReact.state, { + activeTab: 1 + })); + + is(tabAnchors[0].getAttribute("aria-selected"), "false", "Anchor aria-selected is reset correctly"); + is(tabAnchors[1].getAttribute("aria-selected"), "true", "Anchor aria-selected is reset correctly"); + + function addTabWithPanel(tabId) { + // Setup for InspectorTabPanel + const panel = document.createElement("div"); + panel.id = `sidebar-${tabId}`; + document.body.appendChild(panel); + + return setState(tabbarReact, Object.assign({}, tabbarReact.state, { + tabs: tabbarReact.state.tabs.concat({ + id: `sidebar-${tabId}`, + title: `tab-${tabId}`, + panel: InspectorTabPanel + }), + })); + } + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tabs_menu.html b/devtools/client/shared/components/test/chrome/test_tabs_menu.html new file mode 100644 index 0000000000..cc4638e05a --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tabs_menu.html @@ -0,0 +1,84 @@ +<!-- 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 class="theme-light"> +<!-- +Test all-tabs menu. +--> +<head> + <meta charset="utf-8"> + <title>Tabs component All-tabs menu test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="stylesheet" type="text/css" href="chrome://devtools/skin/variables.css"> + <link rel="stylesheet" type="text/css" href="chrome://devtools/skin/common.css"> + <link rel="stylesheet" type="text/css" href="chrome://devtools/content/shared/components/tabs/Tabs.css"> + <link rel="stylesheet" type="text/css" href="chrome://devtools/content/inspector/components/InspectorTabPanel.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const { Component, createFactory } = browserRequire("devtools/client/shared/vendor/react"); + const dom = require("devtools/client/shared/vendor/react-dom-factories"); + const Tabbar = createFactory(browserRequire("devtools/client/shared/components/tabs/TabBar")); + + // Create container for the TabBar. Set smaller width + // to ensure that tabs won't fit and the all-tabs menu + // needs to appear. + const tabBarBox = document.createElement("div"); + tabBarBox.style.width = "200px"; + tabBarBox.style.height = "200px"; + tabBarBox.style.border = "1px solid lightgray"; + document.body.appendChild(tabBarBox); + + // Render the tab-bar. + const tabbar = Tabbar({ + showAllTabsMenu: true, + }); + + const tabbarReact = ReactDOM.render(tabbar, tabBarBox); + + class TabPanelClass extends Component { + render() { + return dom.div({}, "content"); + } + } + + // Test panel. + const TabPanel = createFactory(TabPanelClass); + + // Create a few panels. + await addTabWithPanel(1); + await addTabWithPanel(2); + await addTabWithPanel(3); + await addTabWithPanel(4); + await addTabWithPanel(5); + + // Make sure the all-tabs menu is there. + const allTabsMenu = tabBarBox.querySelector(".all-tabs-menu"); + ok(allTabsMenu, "All-tabs menu must be rendered"); + + function addTabWithPanel(tabId) { + return setState(tabbarReact, Object.assign({}, tabbarReact.state, { + tabs: tabbarReact.state.tabs.concat({id: `${tabId}`, + title: `tab-${tabId}`, panel: TabPanel}), + })); + } + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree-view_01.html b/devtools/client/shared/components/test/chrome/test_tree-view_01.html new file mode 100644 index 0000000000..0acae4c1dc --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree-view_01.html @@ -0,0 +1,290 @@ +<!-- 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> +<!-- +Test that TreeView component has working keyboard interactions. +--> +<head> + <meta charset="utf-8"> + <title>TreeView component keyboard test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="stylesheet" href="chrome://devtools/skin/light-theme.css" type="text/css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +"use strict"; + +window.onload = function() { + try { + const { a, button, div } = + require("devtools/client/shared/vendor/react-dom-factories"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const { + Simulate, + findRenderedDOMComponentWithClass, + findRenderedDOMComponentWithTag, + scryRenderedDOMComponentsWithClass, + } = browserRequire("devtools/client/shared/vendor/react-dom-test-utils"); + const TreeView = + browserRequire("devtools/client/shared/components/tree/TreeView"); + + const _props = { + ...TEST_TREE_VIEW_INTERFACE, + renderValue: props => { + return (props.value === "C" ? + div({}, + props.value + " ", + a({ href: "#" }, "Focusable 1"), + button({ }, "Focusable 2")) : + props.value + "" + ); + }, + }; + const treeView = React.createElement(TreeView, _props); + const tree = ReactDOM.render(treeView, document.body); + const treeViewEl = findRenderedDOMComponentWithClass(tree, "treeTable"); + const rows = scryRenderedDOMComponentsWithClass(tree, "treeRow"); + const defaultFocus = treeViewEl.ownerDocument.body; + + function blurEl(el) { + // Simulate.blur does not seem to update the activeElement. + el.blur(); + } + + function focusEl(el) { + // Simulate.focus does not seem to update the activeElement. + el.focus(); + } + + const tests = [{ + name: "Test default TreeView state. Keyboard focus is set to document " + + "body by default.", + state: { selected: null, active: null }, + activeElement: defaultFocus, + }, { + name: "Selected row must be set to the first row on initial focus. " + + "Keyboard focus should be set on TreeView's conatiner.", + action: () => { + focusEl(treeViewEl); + Simulate.click(rows[0]); + }, + activeElement: treeViewEl, + state: { selected: "/B" }, + }, { + name: "Selected row should remain set even when the treeView is " + + "blured. Keyboard focus should be set back to document body.", + action: () => blurEl(treeViewEl), + state: { selected: "/B" }, + activeElement: defaultFocus, + }, { + name: "Selected row must be re-set again to the first row on initial " + + "focus. Keyboard focus should be set on treeView's conatiner.", + action: () => focusEl(treeViewEl), + activeElement: treeViewEl, + state: { selected: "/B" }, + }, { + name: "Selected row should be updated to next on ArrowDown.", + event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowDown" }}, + state: { selected: "/C" }, + }, { + name: "Selected row should be updated to last on ArrowDown.", + event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowDown" }}, + state: { selected: "/D" }, + }, { + name: "Selected row should remain on last on ArrowDown.", + event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowDown" }}, + state: { selected: "/D" }, + }, { + name: "Selected row should be updated to previous on ArrowUp.", + event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowUp" }}, + state: { selected: "/C" }, + }, { + name: "Selected row should be updated to first on ArrowUp.", + event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowUp" }}, + state: { selected: "/B" }, + }, { + name: "Selected row should remain on first on ArrowUp.", + event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowUp" }}, + state: { selected: "/B" }, + }, { + name: "Selected row should move to the next matching row with first letter navigation.", + event: { type: "keyDown", el: treeViewEl, options: { key: "C" }}, + state: { selected: "/C" }, + }, { + name: "Selected row should not change when there are no more visible nodes matching first letter navigation.", + event: { type: "keyDown", el: treeViewEl, options: { key: "C" }}, + state: { selected: "/C" }, + }, { + name: "Selected row should be updated to last on End.", + event: { type: "keyDown", el: treeViewEl, options: { key: "End" }}, + state: { selected: "/D" }, + }, { + name: "Selected row should be updated to first on Home.", + event: { type: "keyDown", el: treeViewEl, options: { key: "Home" }}, + state: { selected: "/B" }, + }, { + name: "Selected row should be set as active on Enter.", + event: { type: "keyDown", el: treeViewEl, options: { key: "Enter" }}, + state: { selected: "/B", active: "/B" }, + activeElement: treeViewEl, + }, { + name: "Active row should be unset on Escape.", + event: { type: "keyDown", el: treeViewEl, options: { key: "Escape" }}, + state: { selected: "/B", active: null }, + }, { + name: "Selected row should be set as active on Space.", + event: { type: "keyDown", el: treeViewEl, options: { key: " " }}, + state: { selected: "/B", active: "/B" }, + activeElement: treeViewEl, + }, { + name: "Selected row should unset when focus leaves the treeView.", + action: () => blurEl(treeViewEl), + state: { selected: "/B", active: null }, + activeElement: defaultFocus, + }, { + name: "Keyboard focus should be set on treeView's conatiner on focus.", + action: () => focusEl(treeViewEl), + activeElement: treeViewEl, + }, { + name: "Selected row should be updated to next on ArrowDown.", + event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowDown" }}, + state: { selected: "/C", active: null }, + }, { + name: "Selected row should be set as active on Enter. Keyboard focus " + + "should be set on the first focusable element inside the row, if " + + "available.", + event: { type: "keyDown", el: treeViewEl, options: { key: "Enter" }}, + state: { selected: "/C", active: "/C" }, + get activeElement() { + // When row becomes active/inactive, it is replaced with a newly + // rendered one. + return findRenderedDOMComponentWithTag(tree, "a"); + }, + }, { + name: "Keyboard focus should be set to next tabbable element inside " + + "the active row on Tab.", + action() { + synthesizeKey("KEY_Tab"); + }, + state: { selected: "/C", active: "/C" }, + get activeElement() { + // When row becomes active/inactive, it is replaced with a newly + // rendered one. + return findRenderedDOMComponentWithTag(tree, "button"); + }, + }, { + name: "Keyboard focus should wrap inside the row when focused on last " + + "tabbable element.", + action() { + synthesizeKey("KEY_Tab"); + }, + state: { selected: "/C", active: "/C" }, + get activeElement() { + return findRenderedDOMComponentWithTag(tree, "a"); + }, + }, { + name: "Keyboard focus should wrap inside the row when focused on first " + + "tabbable element.", + action() { + synthesizeKey("KEY_Tab", { shiftKey: true }); + }, + state: { selected: "/C", active: "/C" }, + get activeElement() { + return findRenderedDOMComponentWithTag(tree, "button"); + }, + }, { + name: "Active row should be unset on Escape. Focus should move back to " + + "the treeView container.", + event: { type: "keyDown", el: treeViewEl, options: { key: "Escape" }}, + state: { selected: "/C", active: null }, + activeElement: treeViewEl, + }, { + name: "Selected row should be set as active on Space. Keyboard focus " + + "should be set on the first focusable element inside the row, if " + + "available.", + event: { type: "keyDown", el: treeViewEl, options: { key: " " }}, + state: { selected: "/C", active: "/C" }, + get activeElement() { + // When row becomes active/inactive, it is replaced with a newly + // rendered one. + return findRenderedDOMComponentWithTag(tree, "a"); + }, + }, { + name: "Selected row should remain set even when the treeView is " + + "blured. Keyboard focus should be set back to document body.", + action: () => treeViewEl.ownerDocument.activeElement.blur(), + state: { selected: "/C", active: null }, + activeElement: defaultFocus, + }, { + name: "Keyboard focus should be set on treeView's conatiner on focus.", + action: () => focusEl(treeViewEl), + state: { selected: "/C", active: null }, + activeElement: treeViewEl, + }, { + name: "Selected row should be set as active on Space. Keyboard focus " + + "should be set on the first focusable element inside the row, if " + + "available.", + event: { type: "keyDown", el: treeViewEl, options: { key: " " }}, + state: { selected: "/C", active: "/C" }, + get activeElement() { + // When row becomes active/inactive, it is replaced with a newly + // rendered one. + return findRenderedDOMComponentWithTag(tree, "a"); + }, + }, { + name: "Selected row should be updated to previous on ArrowUp.", + event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowUp" }}, + state: { selected: "/B", active: null }, + activeElement: treeViewEl, + }, { + name: "Selected row should be set as active on Enter.", + event: { type: "keyDown", el: treeViewEl, options: { key: "Enter" }}, + state: { selected: "/B", active: "/B" }, + activeElement: treeViewEl, + }, { + name: "Keyboard focus should move to another focusable element outside " + + "of the treeView when there's nothing to focus on inside the row.", + action() { + synthesizeKey("KEY_Tab", { shiftKey: true }); + }, + state: { selected: "/B", active: null }, + activeElement: treeViewEl.ownerDocument.documentElement, + }]; + + for (const test of tests) { + const { action, event, state, name } = test; + + info(name); + if (event) { + const { type, options, el } = event; + Simulate[type](el, options); + } else if (action) { + action(); + } + + if (test.activeElement) { + is(treeViewEl.ownerDocument.activeElement, test.activeElement, + "Focus is set correctly."); + } + + for (const key in state) { + is(tree.state[key], state[key], `${key} state is correct.`); + } + } + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree-view_02.html b/devtools/client/shared/components/test/chrome/test_tree-view_02.html new file mode 100644 index 0000000000..77c5934a66 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree-view_02.html @@ -0,0 +1,136 @@ +<!-- 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> +<!-- +Test that TreeView component filtering works with keyboard. +--> +<head> + <meta charset="utf-8"> + <title>TreeView component filtering keyboard test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="stylesheet" href="chrome://devtools/skin/light-theme.css" type="text/css"> + <link rel="stylesheet" href="chrome://devtools/content/shared/components/tree/TreeView.css" type="text/css"> + <style> + .treeRow.hide { + display: none; + } + </style> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +"use strict"; + +window.onload = function() { + try { + const React = browserRequire("devtools/client/shared/vendor/react"); + const { + Simulate, + findRenderedDOMComponentWithClass, + scryRenderedDOMComponentsWithClass, + } = browserRequire("devtools/client/shared/vendor/react-dom-test-utils"); + const TreeView = + browserRequire("devtools/client/shared/components/tree/TreeView"); + + function testKeyboardInteraction(tree, treeViewEl, rows) { + // Expected tree when filtered (C is filtered) + // + // A + // |-- B + // `-- D + is(window.getComputedStyle(rows[1]).getPropertyValue("display"), "none", + "Row C must be hidden by default."); + + const tests = [{ + name: "Selected row must be set to the first row on initial focus. " + + "Keyboard focus must be set on TreeView's conatiner.", + action: () => { + Simulate.click(rows[0]); + }, + activeElement: treeViewEl, + state: { selected: "/B" }, + }, { + name: "Selecting next row must skip hidden row on ArrowDown.", + event: { + type: "keyDown", + el: treeViewEl, + options: { key: "ArrowDown" }, + }, + state: { selected: "/D" }, + }, { + name: "Selecting previous row must be skip hidden row on ArrowUp.", + event: { + type: "keyDown", + el: treeViewEl, + options: { key: "ArrowUp" }, + }, + state: { selected: "/B" }, + }]; + + for (const test of tests) { + const { action, event, state, name } = test; + + info(name); + if (event) { + const { type, options, el } = event; + Simulate[type](el, options); + } else if (action) { + action(); + } + + for (const key in state) { + is(tree.state[key], state[key], `${key} state is correct.`); + } + } + } + + info("Test hiding rows via decorator."); + const props = { + ...TEST_TREE_VIEW_INTERFACE, + decorator: { + getRowClass: ({ label }) => { + if (label === "C") { + return ["hide"]; + } + return []; + } + } + }; + let treeView = React.createElement(TreeView, props); + let tree = ReactDOM.render(treeView, document.body); + let treeViewEl = findRenderedDOMComponentWithClass(tree, "treeTable"); + let rows = scryRenderedDOMComponentsWithClass(tree, "treeRow"); + + testKeyboardInteraction(tree, treeViewEl, rows); + + // Remove TreeView component. + ReactDOM.unmountComponentAtNode(document.body); + + info("Test hiding rows via onFilter."); + props.decorator = null; + props.onFilter = ({ label }) => { + console.log(`onFILTER ${label !== "C"}`) + return label !== "C"; + }; + treeView = React.createElement(TreeView, props); + tree = ReactDOM.render(treeView, document.body); + treeViewEl = findRenderedDOMComponentWithClass(tree, "treeTable"); + rows = scryRenderedDOMComponentsWithClass(tree, "treeRow"); + + testKeyboardInteraction(tree, treeViewEl, rows); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_01.html b/devtools/client/shared/components/test/chrome/test_tree_01.html new file mode 100644 index 0000000000..0740c8957e --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_01.html @@ -0,0 +1,68 @@ +<!-- 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> +<!-- +Test trees get displayed with the items in correct order and at the correct +depth. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const Tree = React.createFactory(browserRequire("devtools/client/shared/components/VirtualizedTree")); + + ok(React, "Should get React"); + ok(Tree, "Should get Tree"); + + const t = Tree(TEST_TREE_INTERFACE); + ok(t, "Should be able to create Tree instances"); + + const tree = ReactDOM.render(t, window.document.body); + ok(tree, "Should be able to mount Tree instances"); + isAccessibleTree(tree); + + TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split("")); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "Should get the items rendered and indented as expected"); + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_02.html b/devtools/client/shared/components/test/chrome/test_tree_02.html new file mode 100644 index 0000000000..f538965572 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_02.html @@ -0,0 +1,49 @@ +<!-- 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> +<!-- +Test that collapsed subtrees aren't rendered. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const Tree = React.createFactory(browserRequire("devtools/client/shared/components/VirtualizedTree")); + + const tree = ReactDOM.render(Tree(TEST_TREE_INTERFACE), window.document.body); + + isAccessibleTree(tree); + TEST_TREE.expanded = new Set("MNO".split("")); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "M:false", + "-N:false", + "--O:false", + ], "Collapsed subtrees shouldn't be rendered"); + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_03.html b/devtools/client/shared/components/test/chrome/test_tree_03.html new file mode 100644 index 0000000000..6ebefa1fb7 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_03.html @@ -0,0 +1,50 @@ +<!-- 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> +<!-- +Test Tree's autoExpandDepth. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const Tree = React.createFactory(browserRequire("devtools/client/shared/components/VirtualizedTree")); + + const tree = ReactDOM.render(Tree(Object.assign({}, TEST_TREE_INTERFACE, { + autoExpandDepth: 1 + })), window.document.body); + + isAccessibleTree(tree); + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "-C:false", + "-D:false", + "M:false", + "-N:false", + ], "Tree should be auto expanded one level"); + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_04.html b/devtools/client/shared/components/test/chrome/test_tree_04.html new file mode 100644 index 0000000000..2213f72497 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_04.html @@ -0,0 +1,133 @@ +<!-- 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> +<!-- +Test that we only render visible tree items. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + function getSpacerHeights() { + return { + top: document.querySelector(".tree > div:first-of-type").clientHeight, + bottom: document.querySelector(".tree > div:last-of-type").clientHeight, + }; + } + + const ITEM_HEIGHT = 3; + + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const Tree = React.createFactory(browserRequire("devtools/client/shared/components/VirtualizedTree")); + + const tree = ReactDOM.render( + Tree(Object.assign({}, TEST_TREE_INTERFACE, { itemHeight: ITEM_HEIGHT })), + window.document.body); + + TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split("")); + + await setState(tree, { + height: 3 * ITEM_HEIGHT, + scroll: 1 * ITEM_HEIGHT + }); + + isAccessibleTree(tree); + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + ], "Tree should show the 2nd, 3rd, and 4th items + buffer of 1 item at each end"); + + let spacers = getSpacerHeights(); + is(spacers.top, 0, "Top spacer has the correct height"); + is(spacers.bottom, 10 * ITEM_HEIGHT, "Bottom spacer has the correct height"); + + await setState(tree, { + height: 2 * ITEM_HEIGHT, + scroll: 3 * ITEM_HEIGHT + }); + + isAccessibleTree(tree); + isRenderedTree(document.body.textContent, [ + "--E:false", + "---K:false", + "---L:false", + "--F:false", + ], "Tree should show the 4th and 5th item + buffer of 1 item at each end"); + + spacers = getSpacerHeights(); + is(spacers.top, 2 * ITEM_HEIGHT, "Top spacer has the correct height"); + is(spacers.bottom, 9 * ITEM_HEIGHT, "Bottom spacer has the correct height"); + + // Set height to 2 items + 1 pixel at each end, scroll so that 4 items are visible + // (2 fully, 2 partially with 1 visible pixel) + await setState(tree, { + height: 2 * ITEM_HEIGHT + 2, + scroll: 3 * ITEM_HEIGHT - 1 + }); + + isRenderedTree(document.body.textContent, [ + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + ], "Tree should show the 4 visible items + buffer of 1 item at each end"); + + spacers = getSpacerHeights(); + is(spacers.top, 1 * ITEM_HEIGHT, "Top spacer has the correct height"); + is(spacers.bottom, 8 * ITEM_HEIGHT, "Bottom spacer has the correct height"); + + await setState(tree, { + height: 20 * ITEM_HEIGHT, + scroll: 0 + }); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "Tree should show all rows"); + + spacers = getSpacerHeights(); + is(spacers.top, 0, "Top spacer has zero height"); + is(spacers.bottom, 0, "Bottom spacer has zero height"); + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_05.html b/devtools/client/shared/components/test/chrome/test_tree_05.html new file mode 100644 index 0000000000..5427a1bd8d --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_05.html @@ -0,0 +1,195 @@ +<!-- 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> +<!-- +Test focusing with the Tree component. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +"use strict"; + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const { createFactory } = browserRequire("devtools/client/shared/vendor/react"); + const { Simulate } = + browserRequire("devtools/client/shared/vendor/react-dom-test-utils"); + const Tree = + createFactory(browserRequire("devtools/client/shared/components/VirtualizedTree")); + + function renderTree(props) { + const treeProps = Object.assign({}, + TEST_TREE_INTERFACE, + { onFocus: x => renderTree({ focused: x }) }, + props + ); + return ReactDOM.render(Tree(treeProps), window.document.body); + } + + const tree = renderTree(); + const treeElem = document.querySelector(".tree"); + + isAccessibleTree(tree); + TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split("")); + + renderTree({ focused: "G" }); + isAccessibleTree(tree, { hasActiveDescendant: true }); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:false", + "--G:true", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "G should be focused"); + + // When tree gets focus by means other than mouse, do not set first node as + // focused node when there is already a focused node. + Simulate.focus(treeElem); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:false", + "--G:true", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "G should remain focused"); + + // Click the first tree node + document.querySelector(".tree-node").click(); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:true", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "A should be focused"); + + // Mouse down and mouse up events set tree "mouseDown" state correctly. + ok(!tree.state.mouseDown, "Mouse down state is not set."); + Simulate.mouseDown(document.querySelector(".tree-node")); + ok(tree.state.mouseDown, "Mouse down state is set."); + Simulate.mouseUp(document.querySelector(".tree-node")); + ok(!tree.state.mouseDown, "Mouse down state is reset."); + + // Unset focused tree state. + renderTree({ focused: null }); + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "No node should be focused"); + + // When tree gets focus while mouse is down, do not set first node as + // focused node. + Simulate.mouseDown(document.querySelector(".tree-node")); + Simulate.focus(treeElem); + Simulate.mouseUp(document.querySelector(".tree-node")); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "No node should have been focused"); + + // When tree gets focus by means other than mouse, set first node as focused + // node if no nodes are focused. + Simulate.focus(treeElem); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:true", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "A should be focused"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_06.html b/devtools/client/shared/components/test/chrome/test_tree_06.html new file mode 100644 index 0000000000..c8d1aa5e9f --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_06.html @@ -0,0 +1,340 @@ +<!-- 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> +<!-- +Test keyboard navigation with the Tree component. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +"use strict"; + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const { createFactory } = browserRequire("devtools/client/shared/vendor/react"); + const { Simulate } = + browserRequire("devtools/client/shared/vendor/react-dom-test-utils"); + const Tree = + createFactory(browserRequire("devtools/client/shared/components/VirtualizedTree")); + + function renderTree(props) { + const treeProps = Object.assign({}, + TEST_TREE_INTERFACE, + { onFocus: x => renderTree({ focused: x }) }, + props + ); + return ReactDOM.render(Tree(treeProps), window.document.body); + } + + const tree = renderTree(); + + isAccessibleTree(tree); + TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split("")); + + // UP ---------------------------------------------------------------------- + + info("Up to the previous sibling."); + renderTree({ focused: "L" }); + Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowUp" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:true", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "After the UP, K should be focused."); + + info("Up to the parent."); + Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowUp" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:true", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "After the UP, E should be focused."); + + info("Try and navigate up, past the first item."); + renderTree({ focused: "A" }); + Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowUp" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:true", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "After the UP, A should be focused and we shouldn't have overflowed past it."); + + // DOWN -------------------------------------------------------------------- + + info("Down to next sibling."); + renderTree({ focused: "K" }); + Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowDown" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:false", + "---L:true", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "After the DOWN, L should be focused."); + + info("Down to parent's next sibling."); + Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowDown" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:true", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "After the DOWN, F should be focused."); + + info("Try and go down past the last item."); + renderTree({ focused: "O" }); + Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowDown" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:true", + ], "After the DOWN, O should still be focused " + + "and we shouldn't have overflowed past it."); + + // LEFT -------------------------------------------------------------------- + + info("Left to go to parent."); + renderTree({ focused: "L" }); + Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowLeft" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:true", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "After the LEFT, E should be focused."); + + info("Left to collapse children."); + Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowLeft" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:true", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "After the LEFT, E's children should be collapsed."); + + // RIGHT ------------------------------------------------------------------- + + info("Right to expand children."); + Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowRight" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:true", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "After the RIGHT, E's children should be expanded again."); + + info("Right on already expanded node."); + Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowRight" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:true", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "After the RIGHT on already expanded node, E should remain focused."); + + info("Right when preventNavigationOnArrowRight is unset to go to next item."); + renderTree({ focused: "E", preventNavigationOnArrowRight: false }); + Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowRight" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:true", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "After the RIGHT, K should be focused."); + + // Check that keys are ignored if any modifier is present. + const keysWithModifier = [ + { key: "ArrowDown", altKey: true }, + { key: "ArrowDown", ctrlKey: true }, + { key: "ArrowDown", metaKey: true }, + { key: "ArrowDown", shiftKey: true }, + ]; + await forceRender(tree); + + for (const key of keysWithModifier) { + Simulate.keyDown(document.querySelector(".tree"), key); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:true", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "After DOWN + (alt|ctrl|meta|shift), K should remain focused."); + } + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_07.html b/devtools/client/shared/components/test/chrome/test_tree_07.html new file mode 100644 index 0000000000..2e763aaf20 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_07.html @@ -0,0 +1,69 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<!DOCTYPE HTML> +<html> +<!-- +Test that arrows get the open attribute when their item's children are expanded. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="stylesheet" href="chrome://devtools/skin/light-theme.css" type="text/css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const dom = require("devtools/client/shared/vendor/react-dom-factories"); + const Tree = + React.createFactory(browserRequire("devtools/client/shared/components/VirtualizedTree")); + + const treeProps = Object.assign({}, TEST_TREE_INTERFACE, { + renderItem: (item, depth, focused, arrow) => { + return dom.div( + { + id: item, + style: { marginLeft: depth * 16 + "px" } + }, + arrow, + item + ); + } + }); + const tree = ReactDOM.render(Tree(treeProps), window.document.body); + + TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split("")); + await forceRender(tree); + + let arrows = document.querySelectorAll(".arrow"); + for (const a of arrows) { + ok(a.classList.contains("open"), "Every arrow should be open."); + } + + TEST_TREE.expanded = new Set(); + await forceRender(tree); + + arrows = document.querySelectorAll(".arrow"); + for (const a of arrows) { + ok(!a.classList.contains("open"), "Every arrow should be closed."); + } + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_08.html b/devtools/client/shared/components/test/chrome/test_tree_08.html new file mode 100644 index 0000000000..cfdff8090d --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_08.html @@ -0,0 +1,61 @@ +<!-- 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> +<!-- +Test that when an item in the Tree component is clicked, it steals focus from +other inputs. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="stylesheet" href="chrome://devtools/skin/light-theme.css" type="text/css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const { createFactory } = browserRequire("devtools/client/shared/vendor/react"); + const Tree = createFactory(browserRequire("devtools/client/shared/components/VirtualizedTree")); + + function renderTree(props) { + const treeProps = Object.assign({}, + TEST_TREE_INTERFACE, + { onFocus: x => renderTree({ focused: x }) }, + props + ); + return ReactDOM.render(Tree(treeProps), window.document.body); + } + + const tree = renderTree(); + + const input = document.createElement("input"); + document.body.appendChild(input); + + input.focus(); + is(document.activeElement, input, "The text input should be focused."); + + document.querySelector(".tree-node").click(); + await forceRender(tree); + + isnot(document.activeElement, input, + "The input should have had it's focus stolen by clicking on a tree item."); + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_09.html b/devtools/client/shared/components/test/chrome/test_tree_09.html new file mode 100644 index 0000000000..4d6a1010b5 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_09.html @@ -0,0 +1,85 @@ +<!-- 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> +<!-- +Test that when an item in the Tree component is expanded or collapsed the appropriate event handler fires. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="stylesheet" href="chrome://devtools/skin/light-theme.css" type="text/css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const { createFactory } = browserRequire("devtools/client/shared/vendor/react"); + const { Simulate } = browserRequire("devtools/client/shared/vendor/react-dom-test-utils"); + const Tree = createFactory(browserRequire("devtools/client/shared/components/VirtualizedTree")); + + let numberOfExpands = 0; + let lastExpandedItem = null; + + let numberOfCollapses = 0; + let lastCollapsedItem = null; + + function renderTree(props) { + const treeProps = Object.assign({}, + TEST_TREE_INTERFACE, + { + autoExpandDepth: 0, + onExpand: item => { + lastExpandedItem = item; + numberOfExpands++; + TEST_TREE.expanded.add(item); + }, + onCollapse: item => { + lastCollapsedItem = item; + numberOfCollapses++; + TEST_TREE.expanded.delete(item); + }, + onFocus: item => renderTree({ focused: item }) + }, + props + ); + return ReactDOM.render(Tree(treeProps), window.document.body); + } + + const tree = renderTree({ focused: "A" }); + + is(lastExpandedItem, null); + is(lastCollapsedItem, null); + + // Expand "A" via the keyboard and then let the component re-render. + Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowRight" }); + await forceRender(tree); + + is(lastExpandedItem, "A", "Our onExpand callback should have been fired."); + is(numberOfExpands, 1); + + // Now collapse "A" via the keyboard and then let the component re-render. + Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowLeft" }); + await forceRender(tree); + + is(lastCollapsedItem, "A", "Our onCollapsed callback should have been fired."); + is(numberOfCollapses, 1); + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_10.html b/devtools/client/shared/components/test/chrome/test_tree_10.html new file mode 100644 index 0000000000..7cda9e4348 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_10.html @@ -0,0 +1,57 @@ +<!-- 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> +<!-- +Test that when an item in the Tree component is expanded or collapsed the appropriate event handler fires. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="stylesheet" href="chrome://devtools/skin/light-theme.css" type="text/css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const { createFactory } = browserRequire("devtools/client/shared/vendor/react"); + const Tree = createFactory(browserRequire("devtools/client/shared/components/VirtualizedTree")); + + function renderTree(props) { + const treeProps = Object.assign({}, + TEST_TREE_INTERFACE, + { autoExpandDepth: 1 }, + props + ); + return ReactDOM.render(Tree(treeProps), window.document.body); + } + + renderTree({ focused: "A" }); + + isRenderedTree(document.body.textContent, [ + "A:true", + "-B:false", + "-C:false", + "-D:false", + "M:false", + "-N:false", + ], "Should have auto-expanded one level."); + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_11.html b/devtools/client/shared/components/test/chrome/test_tree_11.html new file mode 100644 index 0000000000..612a851018 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_11.html @@ -0,0 +1,100 @@ +<!-- 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> +<!-- +Test that when an item in the Tree component is focused by arrow key, the view is scrolled. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="stylesheet" href="chrome://devtools/skin/light-theme.css" type="text/css"> + <style> + .tree { + height: 30px; + overflow: auto; + display: block; + } + + .tree-node { + font-size: 10px; + height: 10px; + } + </style> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +'use strict' + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const { createFactory } = browserRequire("devtools/client/shared/vendor/react"); + const { Simulate } = browserRequire("devtools/client/shared/vendor/react-dom-test-utils"); + const Tree = createFactory(browserRequire("devtools/client/shared/components/VirtualizedTree")); + + TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split("")); + + function renderTree(props) { + const treeProps = Object.assign({}, + TEST_TREE_INTERFACE, + { + itemHeight: 10, + onFocus: item => renderTree({ focused: item }) + }, + props + ); + return ReactDOM.render(Tree(treeProps), window.document.body); + } + + const tree = renderTree({ focused: "K" }); + + tree.setState({ scroll: 10 }); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:true", + "---L:false", + ], "Should render initial correctly"); + + await new Promise(resolve => { + const treeElem = document.querySelector(".tree"); + treeElem.addEventListener("scroll", function onScroll() { + dumpn("Got scroll event"); + treeElem.removeEventListener("scroll", onScroll); + resolve(); + }); + + dumpn("Sending ArrowDown key"); + Simulate.keyDown(treeElem, { key: "ArrowDown" }); + }); + + dumpn("Forcing re-render"); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "-B:false", + "--E:false", + "---K:false", + "---L:true", + "--F:false", + ], "Should have scrolled down one"); + + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_12.html b/devtools/client/shared/components/test/chrome/test_tree_12.html new file mode 100644 index 0000000000..4bcf7ef705 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_12.html @@ -0,0 +1,146 @@ +<!-- 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> +<!-- +Test keyboard navigation/activation with the VirtualizedTree component. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +"use strict"; + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const { createFactory } = browserRequire("devtools/client/shared/vendor/react"); + const { Simulate } = + browserRequire("devtools/client/shared/vendor/react-dom-test-utils"); + const Tree = + createFactory(browserRequire("devtools/client/shared/components/VirtualizedTree")); + + function renderTree(props) { + const treeProps = { + ...TEST_TREE_INTERFACE, + onFocus: x => renderTree({ focused: x }), + ...props + }; + + return ReactDOM.render(Tree(treeProps), window.document.body); + } + + const tree = renderTree(); + + TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split("")); + + // Test Home key ----------------------------------------------------------- + + info("Press Home to move to the first node."); + renderTree({ focused: "L" }); + Simulate.keyDown(document.querySelector(".tree"), { key: "Home" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:true", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "After the Home key, A should be focused."); + + info("Press Home again when already on first node."); + Simulate.keyDown(document.querySelector(".tree"), { key: "Home" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:true", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "After the Home key again, A should still be focused."); + + // Test End key ------------------------------------------------------------ + + info("Press End to move to the last node."); + Simulate.keyDown(document.querySelector(".tree"), { key: "End" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:true", + ], "After the End key, O should be focused."); + + info("Press End again when already on last node."); + Simulate.keyDown(document.querySelector(".tree"), { key: "End" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + "--I:false", + "-D:false", + "--J:false", + "M:false", + "-N:false", + "--O:true", + ], "After the End key again, O should still be focused."); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_13.html b/devtools/client/shared/components/test/chrome/test_tree_13.html new file mode 100644 index 0000000000..183e144c82 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_13.html @@ -0,0 +1,88 @@ +<!-- 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> +<!-- +Test trees have the correct scroll position when they are resized. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <style> + .tree { + height: 50px; + overflow: auto; + display: block; + } + + .tree-node { + font-size: 10px; + height: 10px; + } + </style> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +"use strict"; + +window.onload = async function() { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const { createFactory } = browserRequire("devtools/client/shared/vendor/react"); + const { Simulate } = + browserRequire("devtools/client/shared/vendor/react-dom-test-utils"); + const Tree = createFactory( + browserRequire("devtools/client/shared/components/VirtualizedTree")); + const ITEM_HEIGHT = 10; + + TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split("")); + + function renderTree(props) { + const treeProps = { + ...TEST_TREE_INTERFACE, + itemHeight: ITEM_HEIGHT, + onFocus: item => renderTree({ focused: item }), + ...props + }; + return ReactDOM.render(Tree(treeProps), document.body); + } + + const tree = renderTree({ focused: "L" }); + const treeEl = tree.refs.tree; + + is(tree.state.scroll, 0, "Scroll position should be 0 by default"); + is(treeEl.scrollTop, 0, "Tree scrollTop should be 0 by default"); + + info(`Focus on the next node and scroll by ${ITEM_HEIGHT}`); + Simulate.keyDown(treeEl, { key: "ArrowDown" }); + await forceRender(tree); + + is(tree.state.scroll, ITEM_HEIGHT, `Scroll position should now be ${ITEM_HEIGHT}`); + is(treeEl.scrollTop, ITEM_HEIGHT, + `Tree scrollTop should now be ${ITEM_HEIGHT}`); + + info("Simulate window resize along with scroll back to top"); + treeEl.scrollTo({ left: 0, top: 0 }); + window.dispatchEvent(new Event("resize")); + await forceRender(tree); + + is(tree.state.scroll, ITEM_HEIGHT, + `Scroll position should remain at ${ITEM_HEIGHT}`); + is(treeEl.scrollTop, ITEM_HEIGHT, + `Tree scrollTop should remain at ${ITEM_HEIGHT}`); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_14.html b/devtools/client/shared/components/test/chrome/test_tree_14.html new file mode 100644 index 0000000000..d68d87d6c5 --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_14.html @@ -0,0 +1,245 @@ +<!-- 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> +<!-- +Test that Tree component has working keyboard interactions. +--> +<head> + <meta charset="utf-8"> + <title>Tree component keyboard test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +"use strict"; + +window.onload = async function() { + try { + const { a, button, div } = + require("devtools/client/shared/vendor/react-dom-factories"); + const { createFactory } = browserRequire("devtools/client/shared/vendor/react"); + const { + Simulate, + findRenderedDOMComponentWithClass, + findRenderedDOMComponentWithTag, + } = browserRequire("devtools/client/shared/vendor/react-dom-test-utils"); + const Tree = createFactory( + browserRequire("devtools/client/shared/components/VirtualizedTree")); + + let gTree, gFocused, gActive; + function renderTree(props = {}) { + let toggle = true; + const treeProps = { + ...TEST_TREE_INTERFACE, + onFocus: x => { + gFocused = x; + renderTree({ focused: gFocused, active: gActive }); + }, + onActivate: x => { + gActive = x; + renderTree({ focused: gFocused, active: gActive }); + }, + renderItem: (x, depth, focused) => { + toggle = !toggle; + return toggle ? + (div( + {}, + `${"-".repeat(depth)}${x}:${focused}`, + a({ href: "#" }, "Focusable 1"), + button({ }, "Focusable 2"), + "\n", + ) + ) : `${"-".repeat(depth)}${x}:${focused}`; + }, + ...props + }; + + gTree = ReactDOM.render(Tree(treeProps), document.body); + } + + renderTree(); + const els = { + get tree() { + // React will replace the tree via renderTree. + return findRenderedDOMComponentWithClass(gTree, "tree"); + }, + get anchor() { + // When tree node becomes active/inactive, it is replaced with a newly rendered + // one. + return findRenderedDOMComponentWithTag(gTree, "a"); + }, + get button() { + // When tree node becomes active/inactive, it is replaced with a newly rendered + // one. + return findRenderedDOMComponentWithTag(gTree, "button"); + }, + }; + + const tests = [{ + name: "Test default Tree props. Keyboard focus is set to document body by default.", + props: { focused: undefined, active: undefined }, + activeElement: document.body, + }, { + name: "Focused props must be set to the first node on initial focus. " + + "Keyboard focus should be set on the tree.", + action: () => els.tree.focus(), + activeElement: "tree", + props: { focused: "A" }, + }, { + name: "Focused node should remain set even when the tree is blured. " + + "Keyboard focus should be set back to document body.", + action: () => els.tree.blur(), + props: { focused: "A" }, + activeElement: document.body, + }, { + name: "Unset tree's focused prop.", + action: () => renderTree({ focused: null }), + props: { focused: null }, + }, { + name: "Focused node must be re-set again to the first tree node on initial " + + "focus. Keyboard focus should be set on tree's conatiner.", + action: () => els.tree.focus(), + activeElement: "tree", + props: { focused: "A" }, + }, { + name: "Focused node should be set as active on Enter.", + event: { type: "keyDown", el: "tree", options: { key: "Enter" }}, + props: { focused: "A", active: "A" }, + activeElement: "tree", + }, { + name: "Active node should be unset on Escape.", + event: { type: "keyDown", el: "tree", options: { key: "Escape" }}, + props: { focused: "A", active: null }, + }, { + name: "Focused node should be set as active on Space.", + event: { type: "keyDown", el: "tree", options: { key: " " }}, + props: { focused: "A", active: "A" }, + activeElement: "tree", + }, { + name: "Active node should unset when focus leaves the tree.", + action: () => els.tree.blur(), + props: { focused: "A", active: null }, + activeElement: document.body, + }, { + name: "Keyboard focus should be set on tree's conatiner on focus.", + action: () => els.tree.focus(), + activeElement: "tree", + }, { + name: "Focused node should be updated to next on ArrowDown.", + event: { type: "keyDown", el: "tree", options: { key: "ArrowDown" }}, + props: { focused: "M", active: null }, + }, { + name: "Focused item should be set as active on Enter. Keyboard focus should be " + + "set on the first focusable element inside the tree node, if available.", + event: { type: "keyDown", el: "tree", options: { key: "Enter" }}, + props: { focused: "M", active: "M" }, + activeElement: "anchor", + }, { + name: "Keyboard focus should be set to next tabbable element inside the active " + + "node on Tab.", + action() { + synthesizeKey("KEY_Tab"); + }, + props: { focused: "M", active: "M" }, + activeElement: "button", + }, { + name: "Keyboard focus should wrap inside the tree node when focused on last " + + "tabbable element.", + action() { + synthesizeKey("KEY_Tab"); + }, + props: { focused: "M", active: "M" }, + activeElement: "anchor", + }, { + name: "Keyboard focus should wrap inside the tree node when focused on first " + + "tabbable element.", + action() { + synthesizeKey("KEY_Tab", { shiftKey: true }); + }, + props: { focused: "M", active: "M" }, + activeElement: "button", + }, { + name: "Active tree node should be unset on Escape. Focus should move back to the " + + "tree container.", + event: { type: "keyDown", el: "tree", options: { key: "Escape" }}, + props: { focused: "M", active: null }, + activeElement: "tree", + }, { + name: "Focused node should be set as active on Space. Keyboard focus should be " + + "set on the first focusable element inside the tree node, if available.", + event: { type: "keyDown", el: "tree", options: { key: " " }}, + props: { focused: "M", active: "M" }, + activeElement: "anchor", + }, { + name: "Focused tree node should remain set even when the tree is blured. " + + "Keyboard focus should be set back to document body.", + action: () => document.activeElement.blur(), + props: { focused: "M", active: null, }, + activeElement: document.body, + }, { + name: "Keyboard focus should be set on tree's conatiner on focus.", + action: () => els.tree.focus(), + props: { focused: "M", active: null }, + activeElement: "tree", + }, { + name: "Focused tree node should be updated to previous on ArrowUp.", + event: { type: "keyDown", el: "tree", options: { key: "ArrowUp" }}, + props: { focused: "A", active: null }, + }, { + name: "Focused item should be set as active on Enter.", + event: { type: "keyDown", el: "tree", options: { key: "Enter" }}, + props: { focused: "A", active: "A" }, + activeElement: "tree", + }, { + name: "Keyboard focus should move to another focusable element outside of the " + + "tree when there's nothing to focus on inside the tree node.", + action() { + synthesizeKey("KEY_Tab", { shiftKey: true }); + }, + props: { focused: "A", active: null }, + activeElement: document.documentElement, + }]; + + for (const test of tests) { + const { action, event, props, name } = test; + + info(name); + if (event) { + const { type, options, el } = event; + const target = typeof el === "string" ? els[el] : el; + Simulate[type](target, options); + } else if (action) { + action(); + } + + await forceRender(gTree); + + if (test.activeElement) { + const expected = typeof test.activeElement === "string" ? + els[test.activeElement] : test.activeElement; + // eslint-disable-next-line no-debugger + if (document.activeElement!==expected) {debugger;} + is(document.activeElement, expected, "Focus is set correctly."); + } + + for (const key in props) { + is(gTree.props[key], props[key], `${key} prop is correct.`); + } + } + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_15.html b/devtools/client/shared/components/test/chrome/test_tree_15.html new file mode 100644 index 0000000000..399e3d9ecd --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_15.html @@ -0,0 +1,99 @@ +<!-- 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> +<!-- +Test scroll position when focusing items in traversal but not rendered. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="stylesheet" href="chrome://devtools/skin/light-theme.css" type="text/css"> + <style> + .tree { + height: 30px; + overflow: auto; + display: block; + } + + .tree-node { + font-size: 10px; + height: 10px; + } + </style> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +"use strict"; + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const { createFactory } = browserRequire("devtools/client/shared/vendor/react"); + const { Simulate } = + browserRequire("devtools/client/shared/vendor/react-dom-test-utils"); + const Tree = + createFactory(browserRequire("devtools/client/shared/components/VirtualizedTree")); + + TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split("")); + + function renderTree(props) { + const treeProps = Object.assign({}, + TEST_TREE_INTERFACE, + { + itemHeight: 10, + onFocus: item => renderTree({ focused: item }) + }, + props + ); + return ReactDOM.render(Tree(treeProps), window.document.body); + } + + info("Test first focused."); + const tree = renderTree({ focused: "A" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:true", + "-B:false", + "--E:false", + "---K:false", + ], "Should render initial correctly"); + + info("Test last item focused when it was not yet rendered."); + Simulate.keyDown(document.querySelector(".tree"), { key: "End" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "--J:false", + "M:false", + "-N:false", + "--O:true", + ], "Should render last focused item correctly"); + + info("Test first item focused when it was not yet rendered."); + Simulate.keyDown(document.querySelector(".tree"), { key: "Home" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:true", + "-B:false", + "--E:false", + "---K:false", + ], "Should render first focused item correctly"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/chrome/test_tree_16.html b/devtools/client/shared/components/test/chrome/test_tree_16.html new file mode 100644 index 0000000000..b70e63eade --- /dev/null +++ b/devtools/client/shared/components/test/chrome/test_tree_16.html @@ -0,0 +1,145 @@ +<!-- 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> +<!-- +Test scroll position when showing items both in traversal and/or rendered. +--> +<head> + <meta charset="utf-8"> + <title>Tree component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="stylesheet" href="chrome://devtools/skin/light-theme.css" type="text/css"> + <style> + .tree { + height: 30px; + overflow: auto; + display: block; + } + + .tree-node { + font-size: 10px; + height: 10px; + } + </style> +</head> +<body> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> + +"use strict"; + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const { createFactory } = browserRequire("devtools/client/shared/vendor/react"); + const Tree = + createFactory(browserRequire("devtools/client/shared/components/VirtualizedTree")); + + TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split("")); + + function renderTree(props) { + const treeProps = Object.assign({}, + TEST_TREE_INTERFACE, + { + itemHeight: 10, + onFocus: item => renderTree({ shown: item }) + }, + props + ); + return ReactDOM.render(Tree(treeProps), window.document.body); + } + + info("Test first shown."); + const tree = renderTree({ shown: "A" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:false", + ], "Should render initial correctly"); + + info("Test last as shown when it was not yet rendered."); + renderTree({ shown: "O" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "--J:false", + "M:false", + "-N:false", + "--O:false", + ], "Should render shown item correctly"); + + info("Test first item shown when it's not first rendered."); + renderTree({ shown: "A" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "A:false", + "-B:false", + "--E:false", + "---K:false", + ], "Should render shown item correctly"); + + info("Test mid item shown when it's not first rendered."); + renderTree({ shown: "G" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "---K:false", + "---L:false", + "--F:false", + "--G:false", + "-C:false", + ], "Should render shown item correctly"); + + info("Test mid item shown when it's already rendered."); + renderTree({ shown: "C" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + ], "Should render shown item correctly"); + + info("Test item that is not in traversal."); + renderTree({ shown: "Z" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + ], "Should render without changes"); + + info("Test item that is already shown."); + renderTree({ shown: "F" }); + await forceRender(tree); + + isRenderedTree(document.body.textContent, [ + "---L:false", + "--F:false", + "--G:false", + "-C:false", + "--H:false", + ], "Should render without changes"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/shared/components/test/node/.eslintrc.js b/devtools/client/shared/components/test/node/.eslintrc.js new file mode 100644 index 0000000000..ffb3e70473 --- /dev/null +++ b/devtools/client/shared/components/test/node/.eslintrc.js @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +module.exports = { + env: { + jest: true, + }, +}; diff --git a/devtools/client/shared/components/test/node/__mocks__/Services.js b/devtools/client/shared/components/test/node/__mocks__/Services.js new file mode 100644 index 0000000000..14581e8fda --- /dev/null +++ b/devtools/client/shared/components/test/node/__mocks__/Services.js @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + appinfo: "", + prefs: { + getBoolPref(name, defaultVal) { + return defaultVal; + }, + }, +}; diff --git a/devtools/client/shared/components/test/node/__mocks__/object-front.js b/devtools/client/shared/components/test/node/__mocks__/object-front.js new file mode 100644 index 0000000000..def182111d --- /dev/null +++ b/devtools/client/shared/components/test/node/__mocks__/object-front.js @@ -0,0 +1,55 @@ +/* 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"; + +function ObjectFront(grip, overrides) { + return { + grip, + enumEntries() { + return Promise.resolve( + this.getIterator({ + ownProperties: {}, + }) + ); + }, + enumProperties(options) { + return Promise.resolve( + this.getIterator({ + ownProperties: {}, + }) + ); + }, + enumSymbols() { + return Promise.resolve( + this.getIterator({ + ownSymbols: [], + }) + ); + }, + enumPrivateProperties() { + return Promise.resolve( + this.getIterator({ + privateProperties: [], + }) + ); + }, + getPrototype() { + return Promise.resolve({ + prototype: {}, + }); + }, + // Declared here so we can override it. + getIterator(res) { + return { + slice(start, count) { + return Promise.resolve(res); + }, + }; + }, + ...overrides, + }; +} + +module.exports = ObjectFront; diff --git a/devtools/client/shared/components/test/node/__mocks__/string-front.js b/devtools/client/shared/components/test/node/__mocks__/string-front.js new file mode 100644 index 0000000000..d743f79e8b --- /dev/null +++ b/devtools/client/shared/components/test/node/__mocks__/string-front.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"; + +function LongStringFront(grip, overrides) { + return { + grip, + substring: async () => "", + ...overrides, + }; +} + +module.exports = { LongStringFront }; diff --git a/devtools/client/shared/components/test/node/babel.config.js b/devtools/client/shared/components/test/node/babel.config.js new file mode 100644 index 0000000000..2a95c9f71c --- /dev/null +++ b/devtools/client/shared/components/test/node/babel.config.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +module.exports = { + plugins: [ + "@babel/plugin-proposal-class-properties", + "@babel/plugin-proposal-optional-chaining", + "@babel/plugin-proposal-nullish-coalescing-operator", + "transform-amd-to-commonjs", + ], +}; diff --git a/devtools/client/shared/components/test/node/components/__snapshots__/tree.test.js.snap b/devtools/client/shared/components/test/node/components/__snapshots__/tree.test.js.snap new file mode 100644 index 0000000000..aad7d06189 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/__snapshots__/tree.test.js.snap @@ -0,0 +1,1171 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Tree Don't auto expand root with very large number of children 1`] = ` +Array [ + "key-A", + "key-B", + "key-E", + "key-F", + "key-G", + "key-C", + "key-H", + "key-I", + "key-D", + "key-J", + "key-M", + "key-N", +] +`; + +exports[`Tree active item - focus is inside the tree node and then blur 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L anchor] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree active item - focus is inside the tree node when possible 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L anchor] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree active item - focus is inside the tree node when possible 2`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L anchor] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree active item - navigate inside the tree node 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L anchor] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree active item - navigate inside the tree node 2`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L anchor] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree active item - navigate inside the tree node 3`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L anchor] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree active item - renders as expected when clicking away 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | L +| | F +| | [G] +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree active item - renders as expected when clicking away 2`] = ` +" +▶︎ [A] +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree active item - renders as expected when moving away with keyboard 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree active item - renders as expected when tree blurs 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | L +| | F +| | [G] +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree active item - renders as expected when tree blurs 2`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | L +| | F +| | [G] +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree active item - renders as expected when using keyboard and Enter 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree active item - renders as expected when using keyboard and Space 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree calls shouldItemUpdate when provided 1`] = ` +" +▶︎ A +▶︎ M +" +`; + +exports[`Tree calls shouldItemUpdate when provided 2`] = ` +" +▶︎ A +▶︎ M +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 2`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 3`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 4`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 5`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 6`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 7`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 8`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 9`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 10`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 11`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 12`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 13`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 14`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 15`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 16`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree ignores key strokes when pressing modifiers 17`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders arrows as expected when nodes are collapsed 1`] = ` +" +▶︎ A +▶︎ M +" +`; + +exports[`Tree renders arrows as expected when nodes are expanded 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | L +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | L +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected navigating down with keyboard on last node 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | L +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | [O] +" +`; + +exports[`Tree renders as expected navigating down with keyboard on last node 2`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | L +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | [O] +" +`; + +exports[`Tree renders as expected navigating up with the keyboard on a root 1`] = ` +" +▼ [A] +| ▼ B +| | ▼ E +| | | K +| | | L +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected navigating up with the keyboard on a root 2`] = ` +" +▼ [A] +| ▼ B +| | ▼ E +| | | K +| | | L +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected navigating with arrows on unexpandable roots 1`] = ` +" + [A] + M +" +`; + +exports[`Tree renders as expected navigating with arrows on unexpandable roots 2`] = ` +" + A + [M] +" +`; + +exports[`Tree renders as expected navigating with arrows on unexpandable roots 3`] = ` +" + [A] + M +" +`; + +exports[`Tree renders as expected when given a focused item 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | L +| | F +| | [G] +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected when given a focused item 2`] = ` +" +▶︎ [A] +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected when given a focused item 3`] = ` +" +▼ [A] +| ▼ B +| | ▼ E +| | | K +| | | L +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected when given a focused item 4`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | L +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected when navigating down with the keyboard 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | [K] +| | | L +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected when navigating down with the keyboard 2`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected when navigating down with the keyboard 3`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | L +| | [F] +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected when navigating up with the keyboard 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected when navigating up with the keyboard 2`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | [K] +| | | L +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected when navigating up with the keyboard 3`] = ` +" +▼ A +| ▼ B +| | ▼ [E] +| | | K +| | | L +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected when navigating with home/end 1`] = ` +" +▶︎ A +▶︎ [M] +" +`; + +exports[`Tree renders as expected when navigating with home/end 2`] = ` +" +▶︎ [A] +▶︎ M +" +`; + +exports[`Tree renders as expected when navigating with home/end 3`] = ` +" +▶︎ [A] +▶︎ M +" +`; + +exports[`Tree renders as expected when navigating with home/end 4`] = ` +" +▶︎ A +▶︎ [M] +" +`; + +exports[`Tree renders as expected when navigating with home/end 5`] = ` +" +▶︎ A +▶︎ [M] +" +`; + +exports[`Tree renders as expected when navigating with home/end 6`] = ` +" +▶︎ A +▼ [M] +| ▶︎ N +" +`; + +exports[`Tree renders as expected when navigating with home/end 7`] = ` +" +▶︎ A +▼ M +| ▶︎ [N] +" +`; + +exports[`Tree renders as expected when navigating with home/end 8`] = ` +" +▶︎ A +▼ M +| ▶︎ [N] +" +`; + +exports[`Tree renders as expected when navigating with home/end 9`] = ` +" +▶︎ [A] +▼ M +| ▶︎ N +" +`; + +exports[`Tree renders as expected when navigating with left arrows on roots 1`] = ` +" +▶︎ A +▶︎ [M] +" +`; + +exports[`Tree renders as expected when navigating with left arrows on roots 2`] = ` +" +▶︎ [A] +▶︎ M +" +`; + +exports[`Tree renders as expected when navigating with left arrows on roots 3`] = ` +" +▶︎ [A] +▶︎ M +" +`; + +exports[`Tree renders as expected when navigating with right/left arrows 1`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | K +| | | [L] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected when navigating with right/left arrows 2`] = ` +" +▼ A +| ▼ B +| | ▼ [E] +| | | K +| | | L +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected when navigating with right/left arrows 3`] = ` +" +▼ A +| ▼ B +| | ▶︎ [E] +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected when navigating with right/left arrows 4`] = ` +" +▼ A +| ▼ B +| | ▼ [E] +| | | K +| | | L +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected when navigating with right/left arrows 5`] = ` +" +▼ A +| ▼ B +| | ▼ E +| | | [K] +| | | L +| | F +| | G +| ▼ C +| | H +| | I +| ▼ D +| | J +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree renders as expected when passed autoDepth:1 1`] = ` +" +▼ A +| ▶︎ B +| ▶︎ C +| ▶︎ D +▼ M +| ▶︎ N +" +`; + +exports[`Tree renders as expected with collapsed nodes 1`] = ` +" +▶︎ A +▼ M +| ▼ N +| | O +" +`; + +exports[`Tree uses isExpandable prop if it exists to render tree nodes 1`] = ` +" +▶︎ A + M +" +`; diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/basic.test.js.snap b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/basic.test.js.snap new file mode 100644 index 0000000000..9db3eadc93 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/basic.test.js.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ObjectInspector - renders renders as expected 1`] = ` +" +▶︎ {…} +" +`; + +exports[`ObjectInspector - renders renders as expected 2`] = ` +" +▶︎ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", … } +" +`; + +exports[`ObjectInspector - renders renders as expected 3`] = ` +" +▶︎ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } +" +`; + +exports[`ObjectInspector - renders renders as expected 4`] = ` +" +▶︎ {…} +" +`; + +exports[`ObjectInspector - renders renders as expected when not provided a name 1`] = ` +" +▶︎ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", … } +" +`; + +exports[`ObjectInspector - renders renders block nodes as expected 1`] = ` +" +▼ ☲ Block +| a: 30 +| b: 32 +" +`; + +exports[`ObjectInspector - renders renders objects as expected when provided a name 1`] = ` +" +▶︎ myproperty: Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", … } +" +`; + +exports[`ObjectInspector - renders renders primitives as expected when provided a name 1`] = ` +" + myproperty: 42 +" +`; + +exports[`ObjectInspector - renders updates when the root changes 1`] = ` +" +[ ▶︎ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } ] +" +`; + +exports[`ObjectInspector - renders updates when the root changes 2`] = ` +" +[ ▶︎ Object { a: \\"a\\", b: \\"b\\", c: \\"c\\" } ] +" +`; diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/classnames.test.js.snap b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/classnames.test.js.snap new file mode 100644 index 0000000000..81cc9f7028 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/classnames.test.js.snap @@ -0,0 +1,351 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ObjectInspector - classnames has the expected class 1`] = ` +<div + className="tree object-inspector" + onBlur={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + onKeyPress={[Function]} + onKeyUp={[Function]} + role="tree" + style={Object {}} + tabIndex="0" +> + <TreeNode + active={false} + depth={0} + expanded={false} + focused={false} + id="root" + index={0} + isExpandable={false} + item={ + Object { + "contents": Object { + "value": 42, + }, + "name": "root", + "path": "root", + } + } + key="root-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + shouldItemUpdate={[Function]} + > + <div + aria-level={1} + className="tree-node" + data-expandable={false} + id="root" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <ObjectInspectorItem + addWatchpoint={[Function]} + arrow={null} + autoExpandDepth={0} + autoReleaseObjectActors={true} + closeObjectInspector={[Function]} + depth={0} + evaluations={Map {}} + expanded={false} + expandedPaths={Set {}} + focused={false} + invokeGetter={[Function]} + item={ + Object { + "contents": Object { + "value": 42, + }, + "name": "root", + "path": "root", + } + } + loadedProperties={Map {}} + nodeCollapse={[Function]} + nodeExpand={[Function]} + nodeLoadProperties={[Function]} + nodePropertiesLoaded={[Function]} + onContextMenu={[Function]} + removeWatchpoint={[Function]} + renderItemActions={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "value": 42, + }, + "name": "root", + "path": "root", + }, + ] + } + rootsChanged={[Function]} + setExpanded={[Function]} + > + <div + className="node object-node" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="object-label" + > + root + </span> + <span + className="object-delimiter" + > + : + </span> + <span + className="objectBox objectBox-number" + title={null} + > + 42 + </span> + </div> + </ObjectInspectorItem> + </div> + </TreeNode> +</div> +`; + +exports[`ObjectInspector - classnames has the inline class when inline prop is true 1`] = ` +<div + className="tree object-inspector inline" + onBlur={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + onKeyPress={[Function]} + onKeyUp={[Function]} + role="tree" + style={Object {}} + tabIndex="0" +> + <TreeNode + active={false} + depth={0} + expanded={false} + focused={false} + id="root" + index={0} + isExpandable={false} + item={ + Object { + "contents": Object { + "value": 42, + }, + "name": "root", + "path": "root", + } + } + key="root-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + shouldItemUpdate={[Function]} + > + <div + aria-level={1} + className="tree-node" + data-expandable={false} + id="root" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <ObjectInspectorItem + addWatchpoint={[Function]} + arrow={null} + autoExpandDepth={0} + autoReleaseObjectActors={true} + closeObjectInspector={[Function]} + depth={0} + evaluations={Map {}} + expanded={false} + expandedPaths={Set {}} + focused={false} + inline={true} + invokeGetter={[Function]} + item={ + Object { + "contents": Object { + "value": 42, + }, + "name": "root", + "path": "root", + } + } + loadedProperties={Map {}} + nodeCollapse={[Function]} + nodeExpand={[Function]} + nodeLoadProperties={[Function]} + nodePropertiesLoaded={[Function]} + onContextMenu={[Function]} + removeWatchpoint={[Function]} + renderItemActions={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "value": 42, + }, + "name": "root", + "path": "root", + }, + ] + } + rootsChanged={[Function]} + setExpanded={[Function]} + > + <div + className="node object-node" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="object-label" + > + root + </span> + <span + className="object-delimiter" + > + : + </span> + <span + className="objectBox objectBox-number" + title={null} + > + 42 + </span> + </div> + </ObjectInspectorItem> + </div> + </TreeNode> +</div> +`; + +exports[`ObjectInspector - classnames has the nowrap class when disableWrap prop is true 1`] = ` +<div + className="tree object-inspector nowrap" + onBlur={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + onKeyPress={[Function]} + onKeyUp={[Function]} + role="tree" + style={Object {}} + tabIndex="0" +> + <TreeNode + active={false} + depth={0} + expanded={false} + focused={false} + id="root" + index={0} + isExpandable={false} + item={ + Object { + "contents": Object { + "value": 42, + }, + "name": "root", + "path": "root", + } + } + key="root-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + shouldItemUpdate={[Function]} + > + <div + aria-level={1} + className="tree-node" + data-expandable={false} + id="root" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <ObjectInspectorItem + addWatchpoint={[Function]} + arrow={null} + autoExpandDepth={0} + autoReleaseObjectActors={true} + closeObjectInspector={[Function]} + depth={0} + disableWrap={true} + evaluations={Map {}} + expanded={false} + expandedPaths={Set {}} + focused={false} + invokeGetter={[Function]} + item={ + Object { + "contents": Object { + "value": 42, + }, + "name": "root", + "path": "root", + } + } + loadedProperties={Map {}} + nodeCollapse={[Function]} + nodeExpand={[Function]} + nodeLoadProperties={[Function]} + nodePropertiesLoaded={[Function]} + onContextMenu={[Function]} + removeWatchpoint={[Function]} + renderItemActions={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "value": 42, + }, + "name": "root", + "path": "root", + }, + ] + } + rootsChanged={[Function]} + setExpanded={[Function]} + > + <div + className="node object-node" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="object-label" + > + root + </span> + <span + className="object-delimiter" + > + : + </span> + <span + className="objectBox objectBox-number" + title={null} + > + 42 + </span> + </div> + </ObjectInspectorItem> + </div> + </TreeNode> +</div> +`; diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/entries.test.js.snap b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/entries.test.js.snap new file mode 100644 index 0000000000..5208db3ebb --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/entries.test.js.snap @@ -0,0 +1,94 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ObjectInspector - entries calls ObjectFront.enumEntries when expected 1`] = ` +" +▼ Map(11) +| ▶︎ <entries> +" +`; + +exports[`ObjectInspector - entries calls ObjectFront.enumEntries when expected 2`] = ` +" + ▼ Map(11) +[ | ▼ <entries> ] + | | ▶︎ 0: \\"key-0\\" → \\"value-0\\" + | | ▶︎ 1: \\"key-1\\" → \\"value-1\\" + | | ▶︎ 2: \\"key-2\\" → \\"value-2\\" + | | ▶︎ 3: \\"key-3\\" → \\"value-3\\" + | | ▶︎ 4: \\"key-4\\" → \\"value-4\\" + | | ▶︎ 5: \\"key-5\\" → \\"value-5\\" + | | ▶︎ 6: \\"key-6\\" → \\"value-6\\" + | | ▶︎ 7: \\"key-7\\" → \\"value-7\\" + | | ▶︎ 8: \\"key-8\\" → \\"value-8\\" + | | ▶︎ 9: \\"key-9\\" → \\"value-9\\" + | | ▶︎ 10: \\"key-10\\" → \\"value-10\\" +" +`; + +exports[`ObjectInspector - entries calls ObjectFront.enumEntries when expected 3`] = ` +" + ▼ Map(11) +[ | ▶︎ <entries> ] +" +`; + +exports[`ObjectInspector - entries calls ObjectFront.enumEntries when expected 4`] = ` +" + ▼ Map(11) +[ | ▼ <entries> ] + | | ▶︎ 0: \\"key-0\\" → \\"value-0\\" + | | ▶︎ 1: \\"key-1\\" → \\"value-1\\" + | | ▶︎ 2: \\"key-2\\" → \\"value-2\\" + | | ▶︎ 3: \\"key-3\\" → \\"value-3\\" + | | ▶︎ 4: \\"key-4\\" → \\"value-4\\" + | | ▶︎ 5: \\"key-5\\" → \\"value-5\\" + | | ▶︎ 6: \\"key-6\\" → \\"value-6\\" + | | ▶︎ 7: \\"key-7\\" → \\"value-7\\" + | | ▶︎ 8: \\"key-8\\" → \\"value-8\\" + | | ▶︎ 9: \\"key-9\\" → \\"value-9\\" + | | ▶︎ 10: \\"key-10\\" → \\"value-10\\" +" +`; + +exports[`ObjectInspector - entries renders Object with entries as expected 1`] = ` +" +▼ Map { Symbol(\\"a\\") → \\"value-a\\", Symbol(\\"b\\") → \\"value-b\\" } +| size: 2 +| ▼ <entries> +| | ▼ 0: \\"key-0\\" → \\"value-0\\" +| | | <key>: \\"key-0\\" +| | | <value>: \\"value-0\\" +| | ▼ 1: \\"key-1\\" → \\"value-1\\" +| | | <key>: \\"key-1\\" +| | | <value>: \\"value-1\\" +| | ▼ 2: \\"key-2\\" → \\"value-2\\" +| | | <key>: \\"key-2\\" +| | | <value>: \\"value-2\\" +| | ▼ 3: \\"key-3\\" → \\"value-3\\" +| | | <key>: \\"key-3\\" +| | | <value>: \\"value-3\\" +| | ▼ 4: \\"key-4\\" → \\"value-4\\" +| | | <key>: \\"key-4\\" +| | | <value>: \\"value-4\\" +| | ▼ 5: \\"key-5\\" → \\"value-5\\" +| | | <key>: \\"key-5\\" +| | | <value>: \\"value-5\\" +| | ▼ 6: \\"key-6\\" → \\"value-6\\" +| | | <key>: \\"key-6\\" +| | | <value>: \\"value-6\\" +| | ▼ 7: \\"key-7\\" → \\"value-7\\" +| | | <key>: \\"key-7\\" +| | | <value>: \\"value-7\\" +| | ▼ 8: \\"key-8\\" → \\"value-8\\" +| | | <key>: \\"key-8\\" +| | | <value>: \\"value-8\\" +| | ▼ 9: \\"key-9\\" → \\"value-9\\" +| | | <key>: \\"key-9\\" +| | | <value>: \\"value-9\\" +| | ▼ 10: \\"key-10\\" → \\"value-10\\" +| | | <key>: \\"key-10\\" +| | | <value>: \\"value-10\\" +| ▼ <prototype>: Object { … } +| | <prototype>: Object { } +" +`; diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/expand.test.js.snap b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/expand.test.js.snap new file mode 100644 index 0000000000..3cb8b39dee --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/expand.test.js.snap @@ -0,0 +1,175 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ObjectInspector - state does not expand if the user selected some text 1`] = ` +" +▶︎ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } +▶︎ Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state does not expand if the user selected some text 2`] = ` +" +▶︎ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } +▶︎ Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state does not handle actors when client does not have releaseActor function 1`] = ` +" +▶︎ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } +▶︎ Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state does not handle actors when client does not have releaseActor function 2`] = ` +" +[ ▼ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } ] + | ▶︎ <prototype>: Object { } + ▶︎ Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state does not handle actors when client does not have releaseActor function 3`] = ` +" + ▼ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } +[ | ▼ <prototype>: Object { } ] + | | ▶︎ <prototype>: Object { } + ▶︎ Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state does not throw when expanding a block node 1`] = ` +" +▶︎ ☲ Block +▶︎ Proxy: Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state does not throw when expanding a block node 2`] = ` +" +[ ▼ ☲ Block ] + | a: 30 + | b: 32 + ▶︎ Proxy: Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state expanding a getter returning a longString does not throw 1`] = ` +" +▼ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } +| ▼ baseVal: \\"data:image/png;base64,initial<<<<\\" +▶︎ Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state expands if user selected some text and clicked the arrow 1`] = ` +" +▶︎ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } +▶︎ Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state expands if user selected some text and clicked the arrow 2`] = ` +" +[ ▼ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } ] + | a: 1 + | Symbol(): \\"hello\\" + | ▶︎ <prototype>: Object { … } + ▶︎ Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state has the expected expandedPaths state when clicking nodes 1`] = ` +" +▶︎ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } +▶︎ Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state has the expected expandedPaths state when clicking nodes 2`] = ` +" +[ ▼ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } ] + | a: 1 + | Symbol(): \\"hello\\" + | ▶︎ <prototype>: Object { … } + ▶︎ Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state has the expected expandedPaths state when clicking nodes 3`] = ` +" +[ ▶︎ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } ] + ▶︎ Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state has the expected expandedPaths state when clicking nodes 4`] = ` +" + ▶︎ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } +[ ▼ Proxy { <target>: {…}, <handler>: (3) […] } ] + | ▶︎ <target>: Object { … } + | ▶︎ <handler>: Array(3) [ … ] +" +`; + +exports[`ObjectInspector - state has the expected expandedPaths state when clicking nodes 5`] = ` +" +[ ▼ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } ] + | a: 1 + | Symbol(): \\"hello\\" + | ▶︎ <prototype>: Object { … } + ▼ Proxy { <target>: {…}, <handler>: (3) […] } + | ▶︎ <target>: Object { … } + | ▶︎ <handler>: Array(3) [ … ] +" +`; + +exports[`ObjectInspector - state has the expected state when expanding a node 1`] = ` +" +▶︎ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } +▶︎ Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state has the expected state when expanding a node 2`] = ` +" +[ ▼ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } ] + | ▶︎ <prototype>: Object { } + ▶︎ Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state has the expected state when expanding a node 3`] = ` +" + ▼ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } +[ | ▼ <prototype>: Object { } ] + | | ▶︎ <prototype>: Object { } + ▶︎ Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state has the expected state when expanding a proxy node 1`] = ` +" +▶︎ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } +▶︎ Proxy { <target>: {…}, <handler>: (3) […] } +" +`; + +exports[`ObjectInspector - state has the expected state when expanding a proxy node 2`] = ` +" + ▶︎ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } +[ ▼ Proxy ] + | ▶︎ <target>: Object { … } + | ▶︎ <handler>: Array(3) [ … ] +" +`; + +exports[`ObjectInspector - state has the expected state when expanding a proxy node 3`] = ` +" + ▶︎ Object { p0: \\"0\\", p1: \\"1\\", p2: \\"2\\", p3: \\"3\\", p4: \\"4\\", p5: \\"5\\", p6: \\"6\\", p7: \\"7\\", p8: \\"8\\", p9: \\"9\\", … } + ▼ Proxy + | ▶︎ <target>: Object { … } +[ | ▼ <handler>: (3) […] ] + | | <prototype>: Object { } +" +`; diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/getter-setter.test.js.snap b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/getter-setter.test.js.snap new file mode 100644 index 0000000000..86ededb690 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/getter-setter.test.js.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ObjectInspector - getters & setters onInvokeGetterButtonClick + getter & setter 1`] = ` +" +▼ root +| x: (>>) +| ▶︎ <get x()>: function x() +| ▶︎ <set x()>: function x() +" +`; + +exports[`ObjectInspector - getters & setters onInvokeGetterButtonClick + getter 1`] = ` +" +▼ root +| x: (>>) +| ▶︎ <get x()>: function x() +" +`; + +exports[`ObjectInspector - getters & setters onInvokeGetterButtonClick + setter 1`] = ` +" +▼ root +| x: Setter +| ▶︎ <set x()>: function x() +" +`; + +exports[`ObjectInspector - getters & setters renders getters and setters as expected 1`] = ` +" +▼ root +| x: Getter & Setter +| ▶︎ <get x()>: function x() +| ▶︎ <set x()>: function x() +" +`; + +exports[`ObjectInspector - getters & setters renders getters as expected 1`] = ` +" +▼ root +| x: Getter +| ▶︎ <get x()>: function x() +" +`; + +exports[`ObjectInspector - getters & setters renders setters as expected 1`] = ` +" +▼ root +| x: Setter +| ▶︎ <set x()>: function x() +" +`; diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/keyboard-navigation.test.js.snap b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/keyboard-navigation.test.js.snap new file mode 100644 index 0000000000..8998ce3aff --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/keyboard-navigation.test.js.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ObjectInspector - keyboard navigation works as expected 1`] = ` +" +▶︎ Object { a: \\"a\\", b: \\"b\\", c: \\"c\\" } +" +`; + +exports[`ObjectInspector - keyboard navigation works as expected 2`] = ` +" +[ ▶︎ Object { a: \\"a\\", b: \\"b\\", c: \\"c\\" } ] +" +`; + +exports[`ObjectInspector - keyboard navigation works as expected 3`] = ` +" +[ ▼ Object { a: \\"a\\", b: \\"b\\", c: \\"c\\" } ] + | <prototype>: Object { } +" +`; + +exports[`ObjectInspector - keyboard navigation works as expected 4`] = ` +" + ▼ Object { a: \\"a\\", b: \\"b\\", c: \\"c\\" } +[ | <prototype>: Object { } ] +" +`; + +exports[`ObjectInspector - keyboard navigation works as expected 5`] = ` +" +[ ▼ Object { a: \\"a\\", b: \\"b\\", c: \\"c\\" } ] + | <prototype>: Object { } +" +`; + +exports[`ObjectInspector - keyboard navigation works as expected 6`] = ` +" + ▼ Object { a: \\"a\\", b: \\"b\\", c: \\"c\\" } +[ | <prototype>: Object { } ] +" +`; + +exports[`ObjectInspector - keyboard navigation works as expected 7`] = ` +" +[ ▼ Object { a: \\"a\\", b: \\"b\\", c: \\"c\\" } ] + | <prototype>: Object { } +" +`; + +exports[`ObjectInspector - keyboard navigation works as expected 8`] = ` +" +▼ Object { a: \\"a\\", b: \\"b\\", c: \\"c\\" } +| <prototype>: Object { } +" +`; diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/properties.test.js.snap b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/properties.test.js.snap new file mode 100644 index 0000000000..3bb9e517a8 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/properties.test.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ObjectInspector - properties renders uninitialized bindings 1`] = ` +" + someFoo: (uninitialized) +" +`; + +exports[`ObjectInspector - properties renders unmapped bindings 1`] = ` +" + someFoo: (unmapped) +" +`; + +exports[`ObjectInspector - properties renders unscoped bindings 1`] = ` +" + someFoo: (unscoped) +" +`; diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/proxy.test.js.snap b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/proxy.test.js.snap new file mode 100644 index 0000000000..d48a6e058d --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/proxy.test.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ObjectInspector - Proxy renders Proxy as expected 1`] = ` +" +▼ Proxy { <target>: {…}, <handler>: (3) […] } +| ▶︎ <target>: Object { … } +| ▶︎ <handler>: Array(3) [ … ] +" +`; diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/window.test.js.snap b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/window.test.js.snap new file mode 100644 index 0000000000..fcd2cc3693 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/__snapshots__/window.test.js.snap @@ -0,0 +1,2119 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ObjectInspector - dimTopLevelWindow renders collapsed top-level window when dimTopLevelWindow =false 1`] = ` +<Provider + store={ + Object { + "dispatch": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + Symbol(observable): [Function], + } + } +> + <Component + autoExpandDepth={0} + roots={ + Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + > + <Connect(ObjectInspector) + autoExpandDepth={0} + roots={ + Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + > + <ObjectInspector + addWatchpoint={[Function]} + autoExpandDepth={0} + autoReleaseObjectActors={true} + closeObjectInspector={[Function]} + evaluations={Map {}} + expandedPaths={Set {}} + invokeGetter={[Function]} + loadedProperties={Map {}} + nodeCollapse={[Function]} + nodeExpand={[Function]} + nodeLoadProperties={[Function]} + nodePropertiesLoaded={[Function]} + removeWatchpoint={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + rootsChanged={[Function]} + > + <Tree + autoExpandAll={true} + autoExpandDepth={0} + className="object-inspector" + getChildren={[Function]} + getKey={[Function]} + getParent={[Function]} + getRoots={[Function]} + isExpandable={[Function]} + isExpanded={[Function]} + onActivate={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + onFocus={[Function]} + renderItem={[Function]} + shouldItemUpdate={[Function]} + > + <div + className="tree object-inspector" + onBlur={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + onKeyPress={[Function]} + onKeyUp={[Function]} + role="tree" + style={Object {}} + tabIndex="0" + > + <TreeNode + active={false} + depth={0} + expanded={false} + focused={false} + id="window" + index={0} + isExpandable={true} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + key="window-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + shouldItemUpdate={[Function]} + > + <div + aria-expanded={false} + aria-level={1} + className="tree-node" + data-expandable={true} + id="window" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <ObjectInspectorItem + addWatchpoint={[Function]} + arrow={ + <ArrowExpander + expanded={false} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + /> + } + autoExpandDepth={0} + autoReleaseObjectActors={true} + closeObjectInspector={[Function]} + depth={0} + evaluations={Map {}} + expanded={false} + expandedPaths={Set {}} + focused={false} + invokeGetter={[Function]} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + loadedProperties={Map {}} + nodeCollapse={[Function]} + nodeExpand={[Function]} + nodeLoadProperties={[Function]} + nodePropertiesLoaded={[Function]} + onContextMenu={[Function]} + removeWatchpoint={[Function]} + renderItemActions={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + rootsChanged={[Function]} + setExpanded={[Function]} + > + <div + className="node object-node" + onClick={[Function]} + onContextMenu={[Function]} + > + <ArrowExpander + expanded={false} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + > + <button + className="arrow" + title="Expand" + /> + </ArrowExpander> + <span + className="object-label" + > + window + </span> + <span + className="object-delimiter" + > + : + </span> + <span + className="objectBox objectBox-Window" + data-link-actor-id="server0.conn0.windowGlobal2147483651/obj35" + title={null} + > + <span + className="objectTitle" + > + Window + </span> + </span> + </div> + </ObjectInspectorItem> + </div> + </TreeNode> + </div> + </Tree> + </ObjectInspector> + </Connect(ObjectInspector)> + </Component> +</Provider> +`; + +exports[`ObjectInspector - dimTopLevelWindow renders sub-level window 1`] = ` +<Provider + store={ + Object { + "dispatch": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + Symbol(observable): [Function], + } + } +> + <Component + autoExpandDepth={0} + dimTopLevelWindow={true} + injectWaitService={true} + roots={ + Array [ + Object { + "contents": Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ], + "meta": undefined, + "name": "root", + "parent": undefined, + "path": "root", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + > + <Connect(ObjectInspector) + autoExpandDepth={0} + dimTopLevelWindow={true} + injectWaitService={true} + roots={ + Array [ + Object { + "contents": Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ], + "meta": undefined, + "name": "root", + "parent": undefined, + "path": "root", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + > + <ObjectInspector + addWatchpoint={[Function]} + autoExpandDepth={0} + autoReleaseObjectActors={true} + closeObjectInspector={[Function]} + dimTopLevelWindow={true} + evaluations={Map {}} + expandedPaths={ + Set { + "root", + } + } + injectWaitService={true} + invokeGetter={[Function]} + loadedProperties={ + Map { + "root" => Object {}, + } + } + nodeCollapse={[Function]} + nodeExpand={[Function]} + nodeLoadProperties={[Function]} + nodePropertiesLoaded={[Function]} + removeWatchpoint={[Function]} + roots={ + Array [ + Object { + "contents": Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ], + "meta": undefined, + "name": "root", + "parent": undefined, + "path": "root", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + rootsChanged={[Function]} + > + <Tree + autoExpandAll={true} + autoExpandDepth={0} + className="object-inspector" + focused={ + Object { + "contents": Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ], + "meta": undefined, + "name": "root", + "parent": undefined, + "path": "root", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + getChildren={[Function]} + getKey={[Function]} + getParent={[Function]} + getRoots={[Function]} + isExpandable={[Function]} + isExpanded={[Function]} + onActivate={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + onFocus={[Function]} + renderItem={[Function]} + shouldItemUpdate={[Function]} + > + <div + aria-activedescendant="root" + className="tree object-inspector" + onBlur={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + onKeyPress={[Function]} + onKeyUp={[Function]} + role="tree" + style={Object {}} + tabIndex="0" + > + <TreeNode + active={false} + depth={0} + expanded={true} + focused={true} + id="root" + index={0} + isExpandable={true} + item={ + Object { + "contents": Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ], + "meta": undefined, + "name": "root", + "parent": undefined, + "path": "root", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + key="root-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + shouldItemUpdate={[Function]} + > + <div + aria-expanded={true} + aria-level={1} + className="tree-node focused" + data-expandable={true} + id="root" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <ObjectInspectorItem + addWatchpoint={[Function]} + arrow={ + <ArrowExpander + expanded={true} + item={ + Object { + "contents": Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ], + "meta": undefined, + "name": "root", + "parent": undefined, + "path": "root", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + /> + } + autoExpandDepth={0} + autoReleaseObjectActors={true} + closeObjectInspector={[Function]} + depth={0} + dimTopLevelWindow={true} + evaluations={Map {}} + expanded={true} + expandedPaths={ + Set { + "root", + } + } + focused={true} + injectWaitService={true} + invokeGetter={[Function]} + item={ + Object { + "contents": Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ], + "meta": undefined, + "name": "root", + "parent": undefined, + "path": "root", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + loadedProperties={Map {}} + nodeCollapse={[Function]} + nodeExpand={[Function]} + nodeLoadProperties={[Function]} + nodePropertiesLoaded={[Function]} + onContextMenu={[Function]} + removeWatchpoint={[Function]} + renderItemActions={[Function]} + roots={ + Array [ + Object { + "contents": Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ], + "meta": undefined, + "name": "root", + "parent": undefined, + "path": "root", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + rootsChanged={[Function]} + setExpanded={[Function]} + > + <div + className="node object-node focused" + onClick={[Function]} + onContextMenu={[Function]} + > + <ArrowExpander + expanded={true} + item={ + Object { + "contents": Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ], + "meta": undefined, + "name": "root", + "parent": undefined, + "path": "root", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + > + <button + className="arrow expanded" + title="Collapse" + /> + </ArrowExpander> + <span + className="object-label" + > + root + </span> + </div> + </ObjectInspectorItem> + </div> + </TreeNode> + <TreeNode + active={false} + depth={1} + expanded={false} + focused={false} + id="window" + index={1} + isExpandable={true} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + key="window-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + shouldItemUpdate={[Function]} + > + <div + aria-expanded={false} + aria-level={2} + className="tree-node" + data-expandable={true} + id="window" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <span + className="tree-indent tree-last-indent" + > + ​ + </span> + <ObjectInspectorItem + addWatchpoint={[Function]} + arrow={ + <ArrowExpander + expanded={false} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + /> + } + autoExpandDepth={0} + autoReleaseObjectActors={true} + closeObjectInspector={[Function]} + depth={1} + dimTopLevelWindow={true} + evaluations={Map {}} + expanded={false} + expandedPaths={ + Set { + "root", + } + } + focused={false} + injectWaitService={true} + invokeGetter={[Function]} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + loadedProperties={Map {}} + nodeCollapse={[Function]} + nodeExpand={[Function]} + nodeLoadProperties={[Function]} + nodePropertiesLoaded={[Function]} + onContextMenu={[Function]} + removeWatchpoint={[Function]} + renderItemActions={[Function]} + roots={ + Array [ + Object { + "contents": Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ], + "meta": undefined, + "name": "root", + "parent": undefined, + "path": "root", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + rootsChanged={[Function]} + setExpanded={[Function]} + > + <div + className="node object-node" + onClick={[Function]} + onContextMenu={[Function]} + > + <ArrowExpander + expanded={false} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + > + <button + className="arrow" + title="Expand" + /> + </ArrowExpander> + <span + className="object-label" + > + window + </span> + <span + className="object-delimiter" + > + : + </span> + <span + className="objectBox objectBox-Window" + data-link-actor-id="server0.conn0.windowGlobal2147483651/obj35" + title={null} + > + <span + className="objectTitle" + > + Window + </span> + </span> + </div> + </ObjectInspectorItem> + </div> + </TreeNode> + </div> + </Tree> + </ObjectInspector> + </Connect(ObjectInspector)> + </Component> +</Provider> +`; + +exports[`ObjectInspector - dimTopLevelWindow renders window as expected when dimTopLevelWindow is true 1`] = ` +<Provider + store={ + Object { + "dispatch": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + Symbol(observable): [Function], + } + } +> + <Component + autoExpandDepth={0} + dimTopLevelWindow={true} + roots={ + Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + > + <Connect(ObjectInspector) + autoExpandDepth={0} + dimTopLevelWindow={true} + roots={ + Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + > + <ObjectInspector + addWatchpoint={[Function]} + autoExpandDepth={0} + autoReleaseObjectActors={true} + closeObjectInspector={[Function]} + dimTopLevelWindow={true} + evaluations={Map {}} + expandedPaths={Set {}} + invokeGetter={[Function]} + loadedProperties={Map {}} + nodeCollapse={[Function]} + nodeExpand={[Function]} + nodeLoadProperties={[Function]} + nodePropertiesLoaded={[Function]} + removeWatchpoint={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + rootsChanged={[Function]} + > + <Tree + autoExpandAll={true} + autoExpandDepth={0} + className="object-inspector" + getChildren={[Function]} + getKey={[Function]} + getParent={[Function]} + getRoots={[Function]} + isExpandable={[Function]} + isExpanded={[Function]} + onActivate={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + onFocus={[Function]} + renderItem={[Function]} + shouldItemUpdate={[Function]} + > + <div + className="tree object-inspector" + onBlur={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + onKeyPress={[Function]} + onKeyUp={[Function]} + role="tree" + style={Object {}} + tabIndex="0" + > + <TreeNode + active={false} + depth={0} + expanded={false} + focused={false} + id="window" + index={0} + isExpandable={true} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + key="window-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + shouldItemUpdate={[Function]} + > + <div + aria-expanded={false} + aria-level={1} + className="tree-node" + data-expandable={true} + id="window" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <ObjectInspectorItem + addWatchpoint={[Function]} + arrow={ + <ArrowExpander + expanded={false} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + /> + } + autoExpandDepth={0} + autoReleaseObjectActors={true} + closeObjectInspector={[Function]} + depth={0} + dimTopLevelWindow={true} + evaluations={Map {}} + expanded={false} + expandedPaths={Set {}} + focused={false} + invokeGetter={[Function]} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + loadedProperties={Map {}} + nodeCollapse={[Function]} + nodeExpand={[Function]} + nodeLoadProperties={[Function]} + nodePropertiesLoaded={[Function]} + onContextMenu={[Function]} + removeWatchpoint={[Function]} + renderItemActions={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + rootsChanged={[Function]} + setExpanded={[Function]} + > + <div + className="node object-node lessen" + onClick={[Function]} + onContextMenu={[Function]} + > + <ArrowExpander + expanded={false} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + > + <button + className="arrow" + title="Expand" + /> + </ArrowExpander> + <span + className="object-label" + > + window + </span> + <span + className="object-delimiter" + > + : + </span> + <span + className="objectBox objectBox-Window" + data-link-actor-id="server0.conn0.windowGlobal2147483651/obj35" + title={null} + > + <span + className="objectTitle" + > + Window + </span> + </span> + </div> + </ObjectInspectorItem> + </div> + </TreeNode> + </div> + </Tree> + </ObjectInspector> + </Connect(ObjectInspector)> + </Component> +</Provider> +`; + +exports[`ObjectInspector - dimTopLevelWindow renders window as expected when dimTopLevelWindow is true 2`] = ` +<Provider + store={ + Object { + "dispatch": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + Symbol(observable): [Function], + } + } +> + <Component + autoExpandDepth={0} + dimTopLevelWindow={true} + roots={ + Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + > + <Connect(ObjectInspector) + autoExpandDepth={0} + dimTopLevelWindow={true} + roots={ + Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + > + <ObjectInspector + addWatchpoint={[Function]} + autoExpandDepth={0} + autoReleaseObjectActors={true} + closeObjectInspector={[Function]} + dimTopLevelWindow={true} + evaluations={Map {}} + expandedPaths={ + Set { + "window", + } + } + invokeGetter={[Function]} + loadedProperties={ + Map { + "window" => Object { + "ownProperties": Object {}, + "prototype": Object {}, + }, + } + } + nodeCollapse={[Function]} + nodeExpand={[Function]} + nodeLoadProperties={[Function]} + nodePropertiesLoaded={[Function]} + removeWatchpoint={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + rootsChanged={[Function]} + > + <Tree + autoExpandAll={true} + autoExpandDepth={0} + className="object-inspector" + focused={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + getChildren={[Function]} + getKey={[Function]} + getParent={[Function]} + getRoots={[Function]} + isExpandable={[Function]} + isExpanded={[Function]} + onActivate={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + onFocus={[Function]} + renderItem={[Function]} + shouldItemUpdate={[Function]} + > + <div + aria-activedescendant="window" + className="tree object-inspector" + onBlur={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + onKeyPress={[Function]} + onKeyUp={[Function]} + role="tree" + style={Object {}} + tabIndex="0" + > + <TreeNode + active={false} + depth={0} + expanded={true} + focused={true} + id="window" + index={0} + isExpandable={true} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + key="window-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + shouldItemUpdate={[Function]} + > + <div + aria-expanded={true} + aria-level={1} + className="tree-node focused" + data-expandable={true} + id="window" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <ObjectInspectorItem + addWatchpoint={[Function]} + arrow={ + <ArrowExpander + expanded={true} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + /> + } + autoExpandDepth={0} + autoReleaseObjectActors={true} + closeObjectInspector={[Function]} + depth={0} + dimTopLevelWindow={true} + evaluations={Map {}} + expanded={true} + expandedPaths={ + Set { + "window", + } + } + focused={true} + invokeGetter={[Function]} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + loadedProperties={Map {}} + nodeCollapse={[Function]} + nodeExpand={[Function]} + nodeLoadProperties={[Function]} + nodePropertiesLoaded={[Function]} + onContextMenu={[Function]} + removeWatchpoint={[Function]} + renderItemActions={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + rootsChanged={[Function]} + setExpanded={[Function]} + > + <div + className="node object-node focused" + onClick={[Function]} + onContextMenu={[Function]} + > + <ArrowExpander + expanded={true} + item={ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + } + } + > + <button + className="arrow expanded" + title="Collapse" + /> + </ArrowExpander> + <span + className="object-label" + > + window + </span> + <span + className="object-delimiter" + > + : + </span> + <span + className="objectBox objectBox-Window" + data-link-actor-id="server0.conn0.windowGlobal2147483651/obj35" + title={null} + > + <span + className="objectTitle" + > + Window + </span> + </span> + </div> + </ObjectInspectorItem> + </div> + </TreeNode> + <TreeNode + active={false} + depth={1} + expanded={false} + focused={false} + id="window◦<prototype>" + index={1} + isExpandable={false} + item={ + Object { + "contents": Object { + "front": null, + "value": Object {}, + }, + "meta": undefined, + "name": "<prototype>", + "parent": Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + "path": "window◦<prototype>", + "propertyName": undefined, + "type": Symbol(<prototype>), + } + } + key="window◦<prototype>-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + shouldItemUpdate={[Function]} + > + <div + aria-level={2} + className="tree-node" + data-expandable={false} + id="window◦<prototype>" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <span + className="tree-indent tree-last-indent" + > + ​ + </span> + <ObjectInspectorItem + addWatchpoint={[Function]} + arrow={null} + autoExpandDepth={0} + autoReleaseObjectActors={true} + closeObjectInspector={[Function]} + depth={1} + dimTopLevelWindow={true} + evaluations={Map {}} + expanded={false} + expandedPaths={ + Set { + "window", + } + } + focused={false} + invokeGetter={[Function]} + item={ + Object { + "contents": Object { + "front": null, + "value": Object {}, + }, + "meta": undefined, + "name": "<prototype>", + "parent": Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + "path": "window◦<prototype>", + "propertyName": undefined, + "type": Symbol(<prototype>), + } + } + loadedProperties={ + Map { + "window" => Object { + "ownProperties": Object {}, + "prototype": Object {}, + }, + } + } + nodeCollapse={[Function]} + nodeExpand={[Function]} + nodeLoadProperties={[Function]} + nodePropertiesLoaded={[Function]} + onContextMenu={[Function]} + removeWatchpoint={[Function]} + renderItemActions={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "value": Object { + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "extensible": true, + "frozen": false, + "isError": false, + "ownPropertyLength": 806, + "preview": Object { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation", + }, + "sealed": false, + "type": "object", + }, + }, + "meta": undefined, + "name": "window", + "parent": undefined, + "path": "window", + "propertyName": undefined, + "type": Symbol(GRIP), + }, + ] + } + rootsChanged={[Function]} + setExpanded={[Function]} + > + <div + className="node object-node lessen" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="object-label" + > + <prototype> + </span> + <span + className="object-delimiter" + > + : + </span> + <span + className="objectBox objectBox-object" + title={null} + > + <span + className="objectLeftBrace" + > + { + </span> + <span + className="objectRightBrace" + > + } + </span> + </span> + </div> + </ObjectInspectorItem> + </div> + </TreeNode> + </div> + </Tree> + </ObjectInspector> + </Connect(ObjectInspector)> + </Component> +</Provider> +`; diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/basic.test.js b/devtools/client/shared/components/test/node/components/object-inspector/component/basic.test.js new file mode 100644 index 0000000000..47a919db02 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/basic.test.js @@ -0,0 +1,439 @@ +/* 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/>. */ + +const { + mountObjectInspector, +} = require("devtools/client/shared/components/test/node/components/object-inspector/test-utils"); +const { mount } = require("enzyme"); +const { + createNode, + NODE_TYPES, +} = require("devtools/client/shared/components/object-inspector/utils/node"); + +const { Rep } = require(`devtools/client/shared/components/reps/reps/rep`); +const { + MODE, +} = require(`devtools/client/shared/components/reps/reps/constants`); +const { + formatObjectInspector, + waitForDispatch, + waitForLoadedProperties, +} = require("devtools/client/shared/components/test/node/components/object-inspector/test-utils"); +const ObjectFront = require("devtools/client/shared/components/test/node/__mocks__/object-front"); +const gripRepStubs = require(`devtools/client/shared/components/test/node/stubs/reps/grip`); + +function generateDefaults(overrides) { + return { + autoExpandDepth: 0, + ...overrides, + }; +} + +function mountOI(props, { initialState } = {}) { + const client = { + createObjectFront: grip => ObjectFront(grip), + }; + + const obj = mountObjectInspector({ + client, + props: generateDefaults(props), + initialState: { + objectInspector: { + ...initialState, + evaluations: new Map(), + }, + }, + }); + + return obj; +} + +function renderOI(props, opts) { + return mountOI(props, opts).wrapper; +} + +describe("ObjectInspector - renders", () => { + it("renders as expected", () => { + const stub = gripRepStubs.get("testMoreThanMaxProps"); + + const renderObjectInspector = mode => + renderOI({ + roots: [ + { + path: "root", + contents: { + value: stub, + }, + }, + ], + mode, + }); + + const renderRep = mode => Rep({ object: stub, mode }); + + const tinyOi = renderObjectInspector(MODE.TINY); + expect(tinyOi.find(".arrow").exists()).toBeTruthy(); + expect(tinyOi.contains(renderRep(MODE.TINY))).toBeTruthy(); + expect(formatObjectInspector(tinyOi)).toMatchSnapshot(); + + const shortOi = renderObjectInspector(MODE.SHORT); + expect(shortOi.find(".arrow").exists()).toBeTruthy(); + expect(shortOi.contains(renderRep(MODE.SHORT))).toBeTruthy(); + expect(formatObjectInspector(shortOi)).toMatchSnapshot(); + + const longOi = renderObjectInspector(MODE.LONG); + expect(longOi.find(".arrow").exists()).toBeTruthy(); + expect(longOi.contains(renderRep(MODE.LONG))).toBeTruthy(); + expect(formatObjectInspector(longOi)).toMatchSnapshot(); + + const oi = renderObjectInspector(); + expect(oi.find(".arrow").exists()).toBeTruthy(); + // When no mode is provided, it defaults to TINY mode to render the Rep. + expect(oi.contains(renderRep(MODE.TINY))).toBeTruthy(); + expect(formatObjectInspector(oi)).toMatchSnapshot(); + }); + + it("directly renders a Rep when the stub is not expandable", () => { + const object = 42; + + const renderObjectInspector = mode => + renderOI({ + roots: [ + { + path: "root", + contents: { + value: object, + }, + }, + ], + mode, + }); + + const renderRep = mode => mount(Rep({ object, mode })); + + const tinyOi = renderObjectInspector(MODE.TINY); + expect(tinyOi.find(".arrow").exists()).toBeFalsy(); + expect(tinyOi.html()).toEqual(renderRep(MODE.TINY).html()); + + const shortOi = renderObjectInspector(MODE.SHORT); + expect(shortOi.find(".arrow").exists()).toBeFalsy(); + expect(shortOi.html()).toEqual(renderRep(MODE.SHORT).html()); + + const longOi = renderObjectInspector(MODE.LONG); + expect(longOi.find(".arrow").exists()).toBeFalsy(); + expect(longOi.html()).toEqual(renderRep(MODE.LONG).html()); + + const oi = renderObjectInspector(); + expect(oi.find(".arrow").exists()).toBeFalsy(); + // When no mode is provided, it defaults to TINY mode to render the Rep. + expect(oi.html()).toEqual(renderRep(MODE.TINY).html()); + }); + + it("renders objects as expected when provided a name", () => { + const object = gripRepStubs.get("testMoreThanMaxProps"); + const name = "myproperty"; + + const oi = renderOI({ + roots: [ + { + path: "root", + name, + contents: { + value: object, + }, + }, + ], + mode: MODE.SHORT, + }); + + expect(oi.find(".object-label").text()).toEqual(name); + expect(formatObjectInspector(oi)).toMatchSnapshot(); + }); + + it("renders primitives as expected when provided a name", () => { + const value = 42; + const name = "myproperty"; + + const oi = renderOI({ + roots: [ + { + path: "root", + name, + contents: { value }, + }, + ], + mode: MODE.SHORT, + }); + + expect(oi.find(".object-label").text()).toEqual(name); + expect(formatObjectInspector(oi)).toMatchSnapshot(); + }); + + it("renders as expected when not provided a name", () => { + const object = gripRepStubs.get("testMoreThanMaxProps"); + + const oi = renderOI({ + roots: [ + { + path: "root", + contents: { + value: object, + }, + }, + ], + mode: MODE.SHORT, + }); + + expect(oi.find(".object-label").exists()).toBeFalsy(); + expect(formatObjectInspector(oi)).toMatchSnapshot(); + }); + + it("renders leaves with a shorter mode than the root", async () => { + const stub = gripRepStubs.get("testMaxProps"); + + const renderObjectInspector = mode => + renderOI( + { + autoExpandDepth: 1, + roots: [ + { + path: "root", + contents: { + value: stub, + }, + }, + ], + mode, + }, + { + initialState: { + loadedProperties: new Map([ + [ + "root", + { + ownProperties: Object.keys(stub.preview.ownProperties).reduce( + (res, key) => ({ + [key]: { + value: stub, + }, + ...res, + }), + {} + ), + }, + ], + ]), + }, + } + ); + + const renderRep = mode => Rep({ object: stub, mode }); + + const tinyOi = renderObjectInspector(MODE.TINY); + + expect( + tinyOi + .find(".node") + .at(1) + .contains(renderRep(MODE.TINY)) + ).toBeTruthy(); + + const shortOi = renderObjectInspector(MODE.SHORT); + expect( + shortOi + .find(".node") + .at(1) + .contains(renderRep(MODE.TINY)) + ).toBeTruthy(); + + const longOi = renderObjectInspector(MODE.LONG); + expect( + longOi + .find(".node") + .at(1) + .contains(renderRep(MODE.SHORT)) + ).toBeTruthy(); + + const oi = renderObjectInspector(); + // When no mode is provided, it defaults to TINY mode to render the Rep. + expect( + oi + .find(".node") + .at(1) + .contains(renderRep(MODE.TINY)) + ).toBeTruthy(); + }); + + it("renders less-important nodes as expected", async () => { + const defaultPropertiesNode = createNode({ + name: "<default>", + contents: [], + type: NODE_TYPES.DEFAULT_PROPERTIES, + }); + + // The <default properties> node should have the "lessen" class only when + // collapsed. + let { store, wrapper } = mountOI({ + roots: [defaultPropertiesNode], + }); + + let defaultPropertiesElementNode = wrapper.find(".node"); + expect(defaultPropertiesElementNode.hasClass("lessen")).toBe(true); + + let onPropertiesLoaded = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + defaultPropertiesElementNode.simulate("click"); + await onPropertiesLoaded; + wrapper.update(); + defaultPropertiesElementNode = wrapper.find(".node").first(); + expect( + wrapper + .find(".node") + .first() + .hasClass("lessen") + ).toBe(false); + + const prototypeNode = createNode({ + name: "<prototype>", + contents: [], + type: NODE_TYPES.PROTOTYPE, + }); + + // The <prototype> node should have the "lessen" class only when collapsed. + ({ wrapper, store } = mountOI({ + roots: [prototypeNode], + injectWaitService: true, + })); + + let protoElementNode = wrapper.find(".node"); + expect(protoElementNode.hasClass("lessen")).toBe(true); + + onPropertiesLoaded = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + protoElementNode.simulate("click"); + await onPropertiesLoaded; + wrapper.update(); + + protoElementNode = wrapper.find(".node").first(); + expect(protoElementNode.hasClass("lessen")).toBe(false); + }); + + it("renders block nodes as expected", async () => { + const blockNode = createNode({ + name: "Block", + contents: [ + { + name: "a", + contents: { + value: 30, + }, + }, + { + name: "b", + contents: { + value: 32, + }, + }, + ], + type: NODE_TYPES.BLOCK, + }); + + const { wrapper, store } = mountOI({ + roots: [blockNode], + autoExpandDepth: 1, + }); + + await waitForLoadedProperties(store, ["Block"]); + wrapper.update(); + + const blockElementNode = wrapper.find(".node").first(); + expect(blockElementNode.hasClass("block")).toBe(true); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + }); + + it.skip("updates when the root changes", async () => { + let root = { + path: "root", + contents: { + value: gripRepStubs.get("testMoreThanMaxProps"), + }, + }; + const { wrapper } = mountOI({ + roots: [root], + mode: MODE.LONG, + focusedItem: root, + }); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + root = { + path: "root-2", + contents: { + value: gripRepStubs.get("testMaxProps"), + }, + }; + + wrapper.setProps({ + roots: [root], + focusedItem: root, + }); + wrapper.update(); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + }); + + it.skip("updates when the root changes but has same path", async () => { + const { wrapper, store } = mountOI({ + injectWaitService: true, + roots: [ + { + path: "root", + name: "root", + contents: [ + { + name: "a", + contents: { + value: 30, + }, + }, + { + name: "b", + contents: { + value: 32, + }, + }, + ], + }, + ], + mode: MODE.LONG, + }); + + wrapper + .find(".node") + .at(0) + .simulate("click"); + + const oldTree = formatObjectInspector(wrapper); + + const onRootsChanged = waitForDispatch(store, "ROOTS_CHANGED"); + + wrapper.setProps({ + roots: [ + { + path: "root", + name: "root", + contents: [ + { + name: "c", + contents: { + value: "i'm the new node", + }, + }, + ], + }, + ], + }); + + await onRootsChanged; + wrapper.update(); + expect(formatObjectInspector(wrapper)).not.toBe(oldTree); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/classnames.test.js b/devtools/client/shared/components/test/node/components/object-inspector/component/classnames.test.js new file mode 100644 index 0000000000..9f93d8fb70 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/classnames.test.js @@ -0,0 +1,53 @@ +/* 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/>. */ + +const ObjectFront = require("resource://devtools/client/shared/components/test/node/__mocks__/object-front.js"); +const { + mountObjectInspector, +} = require("resource://devtools/client/shared/components/test/node/components/object-inspector/test-utils.js"); + +function generateDefaults(overrides) { + return { + autoExpandDepth: 0, + roots: [ + { + path: "root", + name: "root", + contents: { value: 42 }, + }, + ], + ...overrides, + }; +} + +function mount(props) { + const client = { createObjectFront: grip => ObjectFront(grip) }; + + return mountObjectInspector({ + client, + props: generateDefaults(props), + }); +} + +describe("ObjectInspector - classnames", () => { + it("has the expected class", () => { + const { tree } = mount(); + expect(tree.hasClass("tree")).toBeTruthy(); + expect(tree.hasClass("inline")).toBeFalsy(); + expect(tree.hasClass("nowrap")).toBeFalsy(); + expect(tree).toMatchSnapshot(); + }); + + it("has the nowrap class when disableWrap prop is true", () => { + const { tree } = mount({ disableWrap: true }); + expect(tree.hasClass("nowrap")).toBeTruthy(); + expect(tree).toMatchSnapshot(); + }); + + it("has the inline class when inline prop is true", () => { + const { tree } = mount({ inline: true }); + expect(tree.hasClass("inline")).toBeTruthy(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/create-long-string-front.test.js b/devtools/client/shared/components/test/node/components/object-inspector/component/create-long-string-front.test.js new file mode 100644 index 0000000000..40e2fd772a --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/create-long-string-front.test.js @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* global jest */ + +const { + mountObjectInspector, +} = require("devtools/client/shared/components/test/node/components/object-inspector/test-utils"); +const ObjectFront = require("devtools/client/shared/components/test/node/__mocks__/object-front"); +const { + LongStringFront, +} = require("devtools/client/shared/components/test/node/__mocks__/string-front"); + +const longStringStubs = require(`devtools/client/shared/components/test/node/stubs/reps/long-string`); + +function mount(props) { + const substring = jest.fn(() => Promise.resolve("")); + + const client = { + createObjectFront: grip => ObjectFront(grip), + createLongStringFront: jest.fn(grip => + LongStringFront(grip, { substring }) + ), + }; + + const obj = mountObjectInspector({ + client, + props, + }); + + return { ...obj, substring }; +} + +describe("createLongStringFront", () => { + it("is called with the expected object for longString node", () => { + const stub = longStringStubs.get("testMultiline"); + + const { client } = mount({ + autoExpandDepth: 1, + roots: [ + { + path: "root", + contents: { + value: stub, + }, + }, + ], + }); + + expect(client.createLongStringFront.mock.calls[0][0]).toBe(stub); + }); + + describe("substring", () => { + it("is called for longStrings with unloaded full text", () => { + const stub = longStringStubs.get("testUnloadedFullText"); + + const { substring } = mount({ + autoExpandDepth: 1, + roots: [ + { + path: "root", + contents: { + value: stub, + }, + }, + ], + }); + + expect(substring.mock.calls[0]).toHaveLength(2); + const [start, length] = substring.mock.calls[0]; + expect(start).toBe(stub.initial.length); + expect(length).toBe(stub.length); + }); + + it("is not called for longString node w/ loaded full text", () => { + const stub = longStringStubs.get("testLoadedFullText"); + + const { substring } = mount({ + autoExpandDepth: 1, + roots: [ + { + path: "root", + contents: { + value: stub, + }, + }, + ], + }); + + expect(substring.mock.calls).toHaveLength(0); + }); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/create-object-client.test.js b/devtools/client/shared/components/test/node/components/object-inspector/component/create-object-client.test.js new file mode 100644 index 0000000000..f969debfb7 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/create-object-client.test.js @@ -0,0 +1,114 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* global jest */ + +const { + mountObjectInspector, +} = require("devtools/client/shared/components/test/node/components/object-inspector/test-utils"); +const ObjectFront = require("devtools/client/shared/components/test/node/__mocks__/object-front"); + +const { + createNode, + makeNodesForEntries, + makeNumericalBuckets, +} = require("devtools/client/shared/components/object-inspector/utils/node"); + +const gripRepStubs = require(`devtools/client/shared/components/test/node/stubs/reps/grip`); +const gripArrayRepStubs = require(`devtools/client/shared/components/test/node/stubs/reps/grip-array`); + +function mount(props, overrides = {}) { + const client = { + createObjectFront: + overrides.createObjectFront || jest.fn(grip => ObjectFront(grip)), + getFrontByID: _id => null, + }; + + return mountObjectInspector({ + client, + props, + }); +} + +describe("createObjectFront", () => { + it("is called with the expected object for regular node", () => { + const stub = gripRepStubs.get("testMoreThanMaxProps"); + const { client } = mount({ + autoExpandDepth: 1, + roots: [ + { + path: "root", + contents: { + value: stub, + }, + }, + ], + }); + + expect(client.createObjectFront.mock.calls[0][0]).toBe(stub); + }); + + it("is called with the expected object for entries node", () => { + const grip = Symbol(); + const mapStubNode = createNode({ + name: "map", + contents: { value: grip }, + }); + const entriesNode = makeNodesForEntries(mapStubNode); + + const { client } = mount({ + autoExpandDepth: 1, + roots: [entriesNode], + }); + + expect(client.createObjectFront.mock.calls[0][0]).toBe(grip); + }); + + it("is called with the expected object for bucket node", () => { + const grip = gripArrayRepStubs.get("testMaxProps"); + const root = createNode({ name: "root", contents: { value: grip } }); + const [bucket] = makeNumericalBuckets(root); + + const { client } = mount({ + autoExpandDepth: 1, + roots: [bucket], + }); + expect(client.createObjectFront.mock.calls[0][0]).toBe(grip); + }); + + it("is called with the expected object for sub-bucket node", () => { + const grip = gripArrayRepStubs.get("testMaxProps"); + const root = createNode({ name: "root", contents: { value: grip } }); + const [bucket] = makeNumericalBuckets(root); + const [subBucket] = makeNumericalBuckets(bucket); + + const { client } = mount({ + autoExpandDepth: 1, + roots: [subBucket], + }); + + expect(client.createObjectFront.mock.calls[0][0]).toBe(grip); + }); + + it("doesn't fail when ObjectFront doesn't have expected methods", () => { + const stub = gripRepStubs.get("testMoreThanMaxProps"); + const root = createNode({ name: "root", contents: { value: stub } }); + + // Override console.error so we don't spam test results. + const originalConsoleError = console.error; + console.error = () => {}; + + const createObjectFront = x => ({}); + mount( + { + autoExpandDepth: 1, + roots: [root], + }, + { createObjectFront } + ); + + // rollback console.error. + console.error = originalConsoleError; + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/entries.test.js b/devtools/client/shared/components/test/node/components/object-inspector/component/entries.test.js new file mode 100644 index 0000000000..0cb896d3ae --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/entries.test.js @@ -0,0 +1,137 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* global jest */ + +const { + mountObjectInspector, +} = require("resource://devtools/client/shared/components/test/node/components/object-inspector/test-utils.js"); +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const { + formatObjectInspector, + waitForDispatch, + waitForLoadedProperties, +} = require("resource://devtools/client/shared/components/test/node/components/object-inspector/test-utils.js"); + +const gripMapRepStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-map.js"); +const mapStubs = require("resource://devtools/client/shared/components/test/node/stubs/object-inspector/map.js"); +const ObjectFront = require("resource://devtools/client/shared/components/test/node/__mocks__/object-front.js"); + +function generateDefaults(overrides) { + return { + autoExpandDepth: 0, + createObjectFront: grip => ObjectFront(grip), + ...overrides, + }; +} + +function getEnumEntriesMock() { + return jest.fn(() => ({ + slice: () => mapStubs.get("11-entries"), + })); +} + +function mount(props, { initialState }) { + const enumEntries = getEnumEntriesMock(); + + const client = { + createObjectFront: grip => ObjectFront(grip, { enumEntries }), + getFrontByID: _id => null, + }; + const obj = mountObjectInspector({ + client, + props: generateDefaults(props), + initialState: { + objectInspector: { + ...initialState, + evaluations: new Map(), + }, + }, + }); + + return { ...obj, enumEntries }; +} + +describe("ObjectInspector - entries", () => { + it("renders Object with entries as expected", async () => { + const stub = gripMapRepStubs.get("testSymbolKeyedMap"); + + const { store, wrapper, enumEntries } = mount( + { + autoExpandDepth: 3, + roots: [ + { + path: "root", + contents: { value: stub }, + }, + ], + mode: MODE.LONG, + }, + { + initialState: { + loadedProperties: new Map([["root", mapStubs.get("properties")]]), + }, + } + ); + + await waitForLoadedProperties(store, [ + "root◦<entries>◦0", + "root◦<entries>◦1", + ]); + + wrapper.update(); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + }); + + it("calls ObjectFront.enumEntries when expected", async () => { + const stub = gripMapRepStubs.get("testMoreThanMaxEntries"); + + const { wrapper, store, enumEntries } = mount( + { + autoExpandDepth: 1, + injectWaitService: true, + roots: [ + { + path: "root", + contents: { + value: stub, + }, + }, + ], + }, + { + initialState: { + loadedProperties: new Map([ + ["root", { ownProperties: stub.preview.entries }], + ]), + }, + } + ); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + const nodes = wrapper.find(".node"); + const entriesNode = nodes.at(1); + expect(entriesNode.text()).toBe("<entries>"); + + const onEntrieLoad = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + entriesNode.simulate("click"); + await onEntrieLoad; + wrapper.update(); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + expect(enumEntries.mock.calls).toHaveLength(1); + + entriesNode.simulate("click"); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + entriesNode.simulate("click"); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + // it does not call enumEntries if entries were already loaded. + expect(enumEntries.mock.calls).toHaveLength(1); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/events.test.js b/devtools/client/shared/components/test/node/components/object-inspector/component/events.test.js new file mode 100644 index 0000000000..e6483dbefb --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/events.test.js @@ -0,0 +1,171 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* global jest */ +const { + mountObjectInspector, +} = require("resource://devtools/client/shared/components/test/node/components/object-inspector/test-utils.js"); + +const gripRepStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip.js"); +const ObjectFront = require("resource://devtools/client/shared/components/test/node/__mocks__/object-front.js"); + +function generateDefaults(overrides) { + return { + autoExpandDepth: 0, + ...overrides, + }; +} + +function mount(props) { + const client = { createObjectFront: grip => ObjectFront(grip) }; + + return mountObjectInspector({ + client, + props: generateDefaults(props), + }); +} + +describe("ObjectInspector - properties", () => { + it("calls the onFocus prop when provided one and given focus", () => { + const stub = gripRepStubs.get("testMaxProps"); + const onFocus = jest.fn(); + + const { wrapper } = mount({ + roots: [ + { + path: "root", + contents: { + value: stub, + }, + }, + ], + onFocus, + }); + + const node = wrapper.find(".node").first(); + node.simulate("focus"); + + expect(onFocus.mock.calls).toHaveLength(1); + }); + + it("doesn't call the onFocus when given focus but focusable is false", () => { + const stub = gripRepStubs.get("testMaxProps"); + const onFocus = jest.fn(); + + const { wrapper } = mount({ + focusable: false, + roots: [ + { + path: "root", + contents: { + value: stub, + }, + }, + ], + onFocus, + }); + + const node = wrapper.find(".node").first(); + node.simulate("focus"); + + expect(onFocus.mock.calls).toHaveLength(0); + }); + + it("calls onDoubleClick prop when provided one and double clicked", () => { + const stub = gripRepStubs.get("testMaxProps"); + const onDoubleClick = jest.fn(); + + const { wrapper } = mount({ + roots: [ + { + path: "root", + contents: { + value: stub, + }, + }, + ], + onDoubleClick, + }); + + const node = wrapper.find(".node").first(); + node.simulate("doubleclick"); + + expect(onDoubleClick.mock.calls).toHaveLength(1); + }); + + it("calls the onCmdCtrlClick prop when provided and cmd/ctrl-clicked", () => { + const stub = gripRepStubs.get("testMaxProps"); + const onCmdCtrlClick = jest.fn(); + + const { wrapper } = mount({ + roots: [ + { + path: "root", + contents: { + value: stub, + }, + }, + ], + onCmdCtrlClick, + }); + + const node = wrapper.find(".node").first(); + node.simulate("click", { ctrlKey: true }); + + expect(onCmdCtrlClick.mock.calls).toHaveLength(1); + }); + + it("calls the onLabel prop when provided one and label clicked", () => { + const stub = gripRepStubs.get("testMaxProps"); + const onLabelClick = jest.fn(); + + const { wrapper } = mount({ + roots: [ + { + path: "root", + name: "Label", + contents: { + value: stub, + }, + }, + ], + onLabelClick, + }); + + const label = wrapper.find(".object-label").first(); + label.simulate("click"); + + expect(onLabelClick.mock.calls).toHaveLength(1); + }); + + it("does not call the onLabel prop when the user selected text", () => { + const stub = gripRepStubs.get("testMaxProps"); + const onLabelClick = jest.fn(); + + const { wrapper } = mount({ + roots: [ + { + path: "root", + name: "Label", + contents: { + value: stub, + }, + }, + ], + onLabelClick, + }); + + const label = wrapper.find(".object-label").first(); + + // Set a selection using the mock. + getSelection().setMockSelection("test"); + + label.simulate("click"); + + expect(onLabelClick.mock.calls).toHaveLength(0); + + // Clear the selection for other tests. + getSelection().setMockSelection(); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/expand.test.js b/devtools/client/shared/components/test/node/components/object-inspector/component/expand.test.js new file mode 100644 index 0000000000..3243d8c259 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/expand.test.js @@ -0,0 +1,435 @@ +/* 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/>. */ +const { + mountObjectInspector, +} = require("devtools/client/shared/components/test/node/components/object-inspector/test-utils"); + +const { MODE } = require(`devtools/client/shared/components/reps/index`); +const ObjectFront = require("devtools/client/shared/components/test/node/__mocks__/object-front"); +const gripRepStubs = require(`devtools/client/shared/components/test/node/stubs/reps/grip`); +const gripPropertiesStubs = require("devtools/client/shared/components/test/node/stubs/object-inspector/grip"); +const { + formatObjectInspector, + storeHasExactExpandedPaths, + storeHasExpandedPath, + storeHasLoadedProperty, + waitForDispatch, +} = require("devtools/client/shared/components/test/node/components/object-inspector/test-utils"); +const { + createNode, + NODE_TYPES, +} = require("devtools/client/shared/components/object-inspector/utils/node"); +const { + getExpandedPaths, +} = require("devtools/client/shared/components/object-inspector/reducer"); + +const protoStub = { + prototype: { + type: "object", + actor: "server2.conn0.child1/obj628", + class: "Object", + }, +}; + +function generateDefaults(overrides) { + return { + autoExpandDepth: 0, + roots: [ + { + path: "root-1", + contents: { + value: gripRepStubs.get("testMoreThanMaxProps"), + }, + }, + { + path: "root-2", + contents: { + value: gripRepStubs.get("testProxy"), + }, + }, + ], + createObjectFront: grip => ObjectFront(grip), + mode: MODE.LONG, + ...overrides, + }; +} +const { + LongStringFront, +} = require("devtools/client/shared/components/test/node/__mocks__/string-front"); + +function getClient(overrides = {}) { + return { + releaseActor: () => {}, + + createObjectFront: grip => + ObjectFront(grip, { + getPrototype: () => Promise.resolve(protoStub), + getProxySlots: () => + Promise.resolve(gripRepStubs.get("testProxySlots")), + }), + + createLongStringFront: grip => + LongStringFront(grip, { + substring: async function(initiaLength, length) { + return "<<<<"; + }, + }), + + getFrontByID: _id => null, + + ...overrides, + }; +} + +function mount(props, { initialState, client = getClient() } = {}) { + return mountObjectInspector({ + client, + props: generateDefaults(props), + initialState: { + objectInspector: { + ...initialState, + evaluations: new Map(), + }, + }, + }); +} + +describe("ObjectInspector - state", () => { + it("has the expected expandedPaths state when clicking nodes", async () => { + const { wrapper, store } = mount( + {}, + { + initialState: { + loadedProperties: new Map([ + ["root-1", gripPropertiesStubs.get("proto-properties-symbols")], + ]), + }, + } + ); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + let nodes = wrapper.find(".node"); + + // Clicking on the root node adds it path to "expandedPaths". + const root1 = nodes.at(0); + const root2 = nodes.at(1); + + root1.simulate("click"); + + expect(storeHasExactExpandedPaths(store, ["root-1"])).toBeTruthy(); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + // + // Clicking on the root node removes it path from "expandedPaths". + root1.simulate("click"); + expect(storeHasExactExpandedPaths(store, [])).toBeTruthy(); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + const onPropertiesLoaded = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + root2.simulate("click"); + await onPropertiesLoaded; + expect(storeHasExactExpandedPaths(store, ["root-2"])).toBeTruthy(); + + wrapper.update(); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + root1.simulate("click"); + expect( + storeHasExactExpandedPaths(store, ["root-1", "root-2"]) + ).toBeTruthy(); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + nodes = wrapper.find(".node"); + const propNode = nodes.at(1); + const symbolNode = nodes.at(2); + const protoNode = nodes.at(3); + + propNode.simulate("click"); + symbolNode.simulate("click"); + protoNode.simulate("click"); + + expect( + storeHasExactExpandedPaths(store, [ + "root-1", + "root-2", + "root-1◦<prototype>", + ]) + ).toBeTruthy(); + + // The property and symbols have primitive values, and can't be expanded. + expect(getExpandedPaths(store.getState()).size).toBe(3); + }); + + it("has the expected state when expanding a node", async () => { + const { wrapper, store } = mount({}, {}); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + let nodes = wrapper.find(".node"); + const root1 = nodes.at(0); + + let onPropertiesLoad = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + root1.simulate("click"); + await onPropertiesLoad; + wrapper.update(); + + expect(storeHasLoadedProperty(store, "root-1")).toBeTruthy(); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + nodes = wrapper.find(".node"); + const protoNode = nodes.at(1); + + onPropertiesLoad = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + protoNode.simulate("click"); + await onPropertiesLoad; + wrapper.update(); + + // Once all the loading promises are resolved, actors and loadedProperties + // should have the expected values. + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + expect(storeHasLoadedProperty(store, "root-1◦<prototype>")).toBeTruthy(); + }); + + it("does not handle actors when client does not have releaseActor function", async () => { + const { wrapper, store } = mount( + {}, + { client: getClient({ releaseActor: null }) } + ); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + let nodes = wrapper.find(".node"); + const root1 = nodes.at(0); + + let onPropertiesLoad = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + root1.simulate("click"); + await onPropertiesLoad; + wrapper.update(); + + expect(storeHasLoadedProperty(store, "root-1")).toBeTruthy(); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + nodes = wrapper.find(".node"); + const protoNode = nodes.at(1); + + onPropertiesLoad = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + protoNode.simulate("click"); + await onPropertiesLoad; + wrapper.update(); + + // Once all the loading promises are resolved, actors and loadedProperties + // should have the expected values. + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + expect(storeHasLoadedProperty(store, "root-1◦<prototype>")).toBeTruthy(); + }); + + it.skip("has the expected state when expanding a proxy node", async () => { + const { wrapper, store } = mount({}); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + let nodes = wrapper.find(".node"); + + const proxyNode = nodes.at(1); + + let onLoadProperties = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + proxyNode.simulate("click"); + await onLoadProperties; + wrapper.update(); + + // Once the properties are loaded, actors and loadedProperties should have + // the expected values. + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + nodes = wrapper.find(".node"); + const handlerNode = nodes.at(3); + onLoadProperties = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + handlerNode.simulate("click"); + await onLoadProperties; + wrapper.update(); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + expect(storeHasLoadedProperty(store, "root-2◦<handler>")).toBeTruthy(); + }); + + it("does not expand if the user selected some text", async () => { + const { wrapper, store } = mount( + {}, + { + initialSate: { + loadedProperties: new Map([ + ["root-1", gripPropertiesStubs.get("proto-properties-symbols")], + ]), + }, + } + ); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + const nodes = wrapper.find(".node"); + + // Set a selection using the mock. + getSelection().setMockSelection("test"); + + const root1 = nodes.at(0); + root1.simulate("click"); + expect(storeHasExpandedPath(store, "root-1")).toBeFalsy(); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + // Clear the selection for other tests. + getSelection().setMockSelection(); + }); + + it("expands if user selected some text and clicked the arrow", async () => { + const { wrapper, store } = mount( + {}, + { + initialState: { + loadedProperties: new Map([ + ["root-1", gripPropertiesStubs.get("proto-properties-symbols")], + ]), + }, + } + ); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + const nodes = wrapper.find(".node"); + + // Set a selection using the mock. + getSelection().setMockSelection("test"); + + const root1 = nodes.at(0); + root1.find(".arrow").simulate("click"); + expect(getExpandedPaths(store.getState()).has("root-1")).toBeTruthy(); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + // Clear the selection for other tests. + getSelection().setMockSelection(); + }); + + it("does not throw when expanding a block node", async () => { + const blockNode = createNode({ + name: "Block", + contents: [ + { + name: "a", + contents: { + value: 30, + }, + }, + { + name: "b", + contents: { + value: 32, + }, + }, + ], + type: NODE_TYPES.BLOCK, + }); + + const proxyNode = createNode({ + name: "Proxy", + contents: { + value: gripRepStubs.get("testProxy"), + }, + }); + + const { wrapper, store } = mount({ + roots: [blockNode, proxyNode], + }); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + const nodes = wrapper.find(".node"); + const root = nodes.at(0); + const onPropertiesLoaded = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + root.simulate("click"); + await onPropertiesLoaded; + wrapper.update(); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + }); + + it("calls recordTelemetryEvent when expanding a node", async () => { + const recordTelemetryEvent = jest.fn(); + const { wrapper, store } = mount( + { + recordTelemetryEvent, + }, + { + initialState: { + loadedProperties: new Map([ + ["root-1", gripPropertiesStubs.get("proto-properties-symbols")], + ]), + }, + } + ); + + let nodes = wrapper.find(".node"); + const root1 = nodes.at(0); + const root2 = nodes.at(1); + + // Expanding a node calls recordTelemetryEvent. + root1.simulate("click"); + expect(recordTelemetryEvent.mock.calls).toHaveLength(1); + expect(recordTelemetryEvent.mock.calls[0][0]).toEqual("object_expanded"); + + // Collapsing a node does not call recordTelemetryEvent. + root1.simulate("click"); + expect(recordTelemetryEvent.mock.calls).toHaveLength(1); + + // Expanding another node calls recordTelemetryEvent. + const onPropertiesLoaded = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + root2.simulate("click"); + await onPropertiesLoaded; + expect(recordTelemetryEvent.mock.calls).toHaveLength(2); + expect(recordTelemetryEvent.mock.calls[1][0]).toEqual("object_expanded"); + + wrapper.update(); + + // Re-expanding a node calls recordTelemetryEvent. + root1.simulate("click"); + expect(recordTelemetryEvent.mock.calls).toHaveLength(3); + expect(recordTelemetryEvent.mock.calls[2][0]).toEqual("object_expanded"); + + nodes = wrapper.find(".node"); + const propNode = nodes.at(1); + const symbolNode = nodes.at(2); + const protoNode = nodes.at(3); + + propNode.simulate("click"); + symbolNode.simulate("click"); + protoNode.simulate("click"); + + // The property and symbols have primitive values, and can't be expanded. + expect(recordTelemetryEvent.mock.calls).toHaveLength(4); + expect(recordTelemetryEvent.mock.calls[3][0]).toEqual("object_expanded"); + }); + + it("expanding a getter returning a longString does not throw", async () => { + const { wrapper, store } = mount( + { + focusable: false, + }, + { + initialState: { + loadedProperties: new Map([ + ["root-1", gripPropertiesStubs.get("longs-string-safe-getter")], + ]), + }, + } + ); + + wrapper + .find(".node") + .at(0) + .simulate("click"); + wrapper.update(); + + const onPropertiesLoaded = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + wrapper + .find(".node") + .at(1) + .simulate("click"); + await onPropertiesLoaded; + + wrapper.update(); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/function.test.js b/devtools/client/shared/components/test/node/components/object-inspector/component/function.test.js new file mode 100644 index 0000000000..0e5bb9573a --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/function.test.js @@ -0,0 +1,90 @@ +/* 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/>. */ + +const { + mountObjectInspector, +} = require("resource://devtools/client/shared/components/test/node/components/object-inspector/test-utils.js"); +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const { + createNode, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); + +const functionStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/function.js"); +const ObjectFront = require("resource://devtools/client/shared/components/test/node/__mocks__/object-front.js"); + +function generateDefaults(overrides) { + return { + autoExpandDepth: 1, + ...overrides, + }; +} + +function mount(props) { + const client = { + createObjectFront: grip => ObjectFront(grip), + getFrontByID: _id => null, + }; + + return mountObjectInspector({ + client, + props: generateDefaults(props), + }); +} + +describe("ObjectInspector - functions", () => { + it("renders named function properties as expected", () => { + const stub = functionStubs.get("Named"); + const { wrapper } = mount({ + roots: [ + createNode({ + name: "fn", + contents: { value: stub }, + }), + ], + }); + + const nodes = wrapper.find(".node"); + const functionNode = nodes.first(); + expect(functionNode.text()).toBe("fn:testName()"); + }); + + it("renders anon function properties as expected", () => { + const stub = functionStubs.get("Anon"); + const { wrapper } = mount({ + roots: [ + createNode({ + name: "fn", + contents: { value: stub }, + }), + ], + }); + + const nodes = wrapper.find(".node"); + const functionNode = nodes.first(); + // It should have the name of the property. + expect(functionNode.text()).toBe("fn()"); + }); + + it("renders non-TINY mode functions as expected", () => { + const stub = functionStubs.get("Named"); + const { wrapper } = mount({ + autoExpandDepth: 0, + roots: [ + { + path: "root", + name: "x", + contents: { value: stub }, + }, + ], + mode: MODE.LONG, + }); + + const nodes = wrapper.find(".node"); + const functionNode = nodes.first(); + // It should have the name of the property. + expect(functionNode.text()).toBe("x: function testName()"); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/getter-setter.test.js b/devtools/client/shared/components/test/node/components/object-inspector/component/getter-setter.test.js new file mode 100644 index 0000000000..e5fbbff7de --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/getter-setter.test.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/>. */ + +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const { + formatObjectInspector, + waitForLoadedProperties, + mountObjectInspector, +} = require("resource://devtools/client/shared/components/test/node/components/object-inspector/test-utils.js"); + +const { + makeNodesForProperties, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); +const accessorStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/accessor.js"); +const ObjectFront = require("resource://devtools/client/shared/components/test/node/__mocks__/object-front.js"); + +function generateDefaults(overrides) { + return { + autoExpandDepth: 1, + createObjectFront: grip => ObjectFront(grip), + mode: MODE.LONG, + ...overrides, + }; +} + +function mount(stub, propsOverride = {}) { + const client = { createObjectFront: grip => ObjectFront(grip) }; + + const root = { path: "root", name: "root" }; + const nodes = makeNodesForProperties( + { + ownProperties: { + x: stub, + }, + }, + root + ); + root.contents = nodes; + + return mountObjectInspector({ + client, + props: generateDefaults({ roots: [root], ...propsOverride }), + }); +} + +describe("ObjectInspector - getters & setters", () => { + it("renders getters as expected", async () => { + const { store, wrapper } = mount(accessorStubs.get("getter")); + await waitForLoadedProperties(store, ["root"]); + wrapper.update(); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + }); + + it("renders setters as expected", async () => { + const { store, wrapper } = mount(accessorStubs.get("setter")); + await waitForLoadedProperties(store, ["root"]); + wrapper.update(); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + }); + + it("renders getters and setters as expected", async () => { + const { store, wrapper } = mount(accessorStubs.get("getter setter")); + await waitForLoadedProperties(store, ["root"]); + wrapper.update(); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + }); + + it("onInvokeGetterButtonClick + getter", async () => { + const onInvokeGetterButtonClick = jest.fn(); + const { store, wrapper } = mount(accessorStubs.get("getter"), { + onInvokeGetterButtonClick, + }); + await waitForLoadedProperties(store, ["root"]); + wrapper.update(); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + }); + + it("onInvokeGetterButtonClick + setter", async () => { + const onInvokeGetterButtonClick = jest.fn(); + const { store, wrapper } = mount(accessorStubs.get("setter"), { + onInvokeGetterButtonClick, + }); + await waitForLoadedProperties(store, ["root"]); + wrapper.update(); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + }); + + it("onInvokeGetterButtonClick + getter & setter", async () => { + const onInvokeGetterButtonClick = jest.fn(); + const { store, wrapper } = mount(accessorStubs.get("getter setter"), { + onInvokeGetterButtonClick, + }); + await waitForLoadedProperties(store, ["root"]); + wrapper.update(); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/keyboard-navigation.test.js b/devtools/client/shared/components/test/node/components/object-inspector/component/keyboard-navigation.test.js new file mode 100644 index 0000000000..c36c611a53 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/keyboard-navigation.test.js @@ -0,0 +1,89 @@ +/* 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/>. */ + +const { + mountObjectInspector, +} = require("devtools/client/shared/components/test/node/components/object-inspector/test-utils"); +const { MODE } = require("devtools/client/shared/components/reps/index"); + +const { + formatObjectInspector, + waitForDispatch, +} = require("devtools/client/shared/components/test/node/components/object-inspector/test-utils"); +const ObjectFront = require("devtools/client/shared/components/test/node/__mocks__/object-front"); +const gripRepStubs = require(`devtools/client/shared/components/test/node/stubs/reps/grip`); + +function generateDefaults(overrides) { + return { + autoExpandDepth: 0, + mode: MODE.LONG, + ...overrides, + }; +} + +function mount(props) { + const client = { + createObjectFront: grip => ObjectFront(grip), + getFrontByID: _id => null, + }; + + return mountObjectInspector({ + client, + props: generateDefaults(props), + }); +} + +describe("ObjectInspector - keyboard navigation", () => { + it("works as expected", async () => { + const stub = gripRepStubs.get("testMaxProps"); + + const { wrapper, store } = mount({ + roots: [{ path: "root", contents: { value: stub } }], + }); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + wrapper.simulate("focus"); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + // Pressing right arrow key should expand the node and lod its properties. + const onPropertiesLoaded = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + simulateKeyDown(wrapper, "ArrowRight"); + await onPropertiesLoaded; + wrapper.update(); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + // The child node should be focused. + keyNavigate(wrapper, store, "ArrowDown"); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + // The root node should be focused again. + keyNavigate(wrapper, store, "ArrowLeft"); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + // The child node should be focused again. + keyNavigate(wrapper, store, "ArrowRight"); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + // The root node should be focused again. + keyNavigate(wrapper, store, "ArrowUp"); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + wrapper.simulate("blur"); + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + }); +}); + +function keyNavigate(wrapper, store, key) { + simulateKeyDown(wrapper, key); + wrapper.update(); +} + +function simulateKeyDown(wrapper, key) { + wrapper.simulate("keydown", { + key, + preventDefault: () => {}, + stopPropagation: () => {}, + }); +} diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/properties.test.js b/devtools/client/shared/components/test/node/components/object-inspector/component/properties.test.js new file mode 100644 index 0000000000..8773cfd49f --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/properties.test.js @@ -0,0 +1,158 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* global jest */ + +const { + mountObjectInspector, +} = require("resource://devtools/client/shared/components/test/node/components/object-inspector/test-utils.js"); +const gripRepStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip.js"); +const ObjectFront = require("resource://devtools/client/shared/components/test/node/__mocks__/object-front.js"); + +const { + formatObjectInspector, +} = require("resource://devtools/client/shared/components/test/node/components/object-inspector/test-utils.js"); + +function generateDefaults(overrides) { + return { + autoExpandDepth: 0, + createObjectFront: grip => ObjectFront(grip), + ...overrides, + }; +} + +function getEnumPropertiesMock() { + return jest.fn(() => ({ + slice: () => ({}), + })); +} + +function mount(props, { initialState } = {}) { + const enumProperties = getEnumPropertiesMock(); + + const client = { + createObjectFront: grip => ObjectFront(grip, { enumProperties }), + getFrontByID: _id => null, + }; + + const obj = mountObjectInspector({ + client, + props: generateDefaults(props), + initialState, + }); + + return { ...obj, enumProperties }; +} +describe("ObjectInspector - properties", () => { + it("does not load properties if properties are already loaded", () => { + const stub = gripRepStubs.get("testMaxProps"); + + const { enumProperties } = mount( + { + autoExpandDepth: 1, + roots: [ + { + path: "root", + contents: { + value: stub, + }, + }, + ], + }, + { + initialState: { + objectInspector: { + loadedProperties: new Map([ + ["root", { ownProperties: stub.preview.ownProperties }], + ]), + evaluations: new Map(), + }, + }, + } + ); + + expect(enumProperties.mock.calls).toHaveLength(0); + }); + + it("calls enumProperties when expandable leaf is clicked", () => { + const stub = gripRepStubs.get("testMaxProps"); + const { enumProperties, wrapper } = mount({ + roots: [ + { + path: "root", + contents: { + value: stub, + }, + }, + ], + createObjectFront: grip => ObjectFront(grip, { enumProperties }), + }); + + const node = wrapper.find(".node"); + node.simulate("click"); + + // The function is called twice, to get both non-indexed and indexed props. + expect(enumProperties.mock.calls).toHaveLength(2); + expect(enumProperties.mock.calls[0][0]).toEqual({ + ignoreNonIndexedProperties: true, + }); + expect(enumProperties.mock.calls[1][0]).toEqual({ + ignoreIndexedProperties: true, + }); + }); + + it("renders uninitialized bindings", () => { + const { wrapper } = mount({ + roots: [ + { + name: "someFoo", + path: "root/someFoo", + contents: { + value: { + uninitialized: true, + }, + }, + }, + ], + }); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + }); + + it("renders unmapped bindings", () => { + const { wrapper } = mount({ + roots: [ + { + name: "someFoo", + path: "root/someFoo", + contents: { + value: { + unmapped: true, + }, + }, + }, + ], + }); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + }); + + it("renders unscoped bindings", () => { + const { wrapper } = mount({ + roots: [ + { + name: "someFoo", + path: "root/someFoo", + contents: { + value: { + unscoped: true, + }, + }, + }, + ], + }); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/proxy.test.js b/devtools/client/shared/components/test/node/components/object-inspector/component/proxy.test.js new file mode 100644 index 0000000000..0bf716ccff --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/proxy.test.js @@ -0,0 +1,133 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* global jest */ +const { + mountObjectInspector, +} = require("resource://devtools/client/shared/components/test/node/components/object-inspector/test-utils.js"); + +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const gripStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip.js"); +const stub = gripStubs.get("testProxy"); +const proxySlots = gripStubs.get("testProxySlots"); +const { + formatObjectInspector, +} = require("resource://devtools/client/shared/components/test/node/components/object-inspector/test-utils.js"); + +const ObjectFront = require("resource://devtools/client/shared/components/test/node/__mocks__/object-front.js"); +function generateDefaults(overrides) { + return { + roots: [ + { + path: "root", + contents: { + value: stub, + }, + }, + ], + autoExpandDepth: 1, + mode: MODE.LONG, + ...overrides, + }; +} + +function getEnumPropertiesMock() { + return jest.fn(() => ({ + slice: () => ({}), + })); +} + +function getProxySlotsMock() { + return jest.fn(() => proxySlots); +} + +function mount(props, { initialState } = {}) { + const enumProperties = getEnumPropertiesMock(); + const getProxySlots = getProxySlotsMock(); + + const client = { + createObjectFront: grip => + ObjectFront(grip, { enumProperties, getProxySlots }), + getFrontByID: _id => null, + }; + + const obj = mountObjectInspector({ + client, + props: generateDefaults(props), + initialState, + }); + + return { ...obj, enumProperties, getProxySlots }; +} + +describe("ObjectInspector - Proxy", () => { + it("renders Proxy as expected", () => { + const { wrapper, enumProperties, getProxySlots } = mount( + {}, + { + initialState: { + objectInspector: { + // Have the prototype already loaded so the component does not call + // enumProperties for the root's properties. + loadedProperties: new Map([["root", proxySlots]]), + evaluations: new Map(), + }, + }, + } + ); + + expect(formatObjectInspector(wrapper)).toMatchSnapshot(); + + // enumProperties should not have been called. + expect(enumProperties.mock.calls).toHaveLength(0); + + // getProxySlots should not have been called. + expect(getProxySlots.mock.calls).toHaveLength(0); + }); + + it("calls enumProperties on <target> and <handler> clicks", () => { + const { wrapper, enumProperties } = mount( + {}, + { + initialState: { + objectInspector: { + // Have the prototype already loaded so the component does not call + // enumProperties for the root's properties. + loadedProperties: new Map([["root", proxySlots]]), + evaluations: new Map(), + }, + }, + } + ); + + const nodes = wrapper.find(".node"); + + const targetNode = nodes.at(1); + const handlerNode = nodes.at(2); + + targetNode.simulate("click"); + // The function is called twice, + // to get both non-indexed and indexed properties. + expect(enumProperties.mock.calls).toHaveLength(2); + expect(enumProperties.mock.calls[0][0]).toEqual({ + ignoreNonIndexedProperties: true, + }); + expect(enumProperties.mock.calls[1][0]).toEqual({ + ignoreIndexedProperties: true, + }); + + handlerNode.simulate("click"); + // The function is called twice, + // to get both non-indexed and indexed properties. + expect(enumProperties.mock.calls).toHaveLength(4); + expect(enumProperties.mock.calls[2][0]).toEqual({ + ignoreNonIndexedProperties: true, + }); + expect(enumProperties.mock.calls[3][0]).toEqual({ + ignoreIndexedProperties: true, + }); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/should-item-update.test.js b/devtools/client/shared/components/test/node/components/object-inspector/component/should-item-update.test.js new file mode 100644 index 0000000000..645b4ede6c --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/should-item-update.test.js @@ -0,0 +1,96 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* global jest */ + +const { + mountObjectInspector, +} = require("devtools/client/shared/components/test/node/components/object-inspector/test-utils"); +const ObjectFront = require("devtools/client/shared/components/test/node/__mocks__/object-front"); +const { + LongStringFront, +} = require("devtools/client/shared/components/test/node/__mocks__/string-front"); + +const longStringStubs = require(`devtools/client/shared/components/test/node/stubs/reps/long-string`); +const gripStubs = require(`devtools/client/shared/components/test/node/stubs/reps/grip`); + +function mount(stub) { + const root = { + path: "root", + contents: { + value: stub, + }, + }; + + const { wrapper } = mountObjectInspector({ + client: { + createObjectFront: grip => ObjectFront(grip), + createLongStringFront: grip => LongStringFront(grip), + getFrontByID: _id => null, + }, + props: { + roots: [root], + }, + }); + + return { wrapper, root }; +} + +describe("shouldItemUpdate", () => { + it("for longStrings", () => { + shouldItemUpdateCheck(longStringStubs.get("testUnloadedFullText"), true, 2); + }); + + it("for basic object", () => { + shouldItemUpdateCheck(gripStubs.get("testBasic"), false, 1); + }); +}); + +function shouldItemUpdateCheck( + stub, + shouldItemUpdateResult, + renderCallsLength +) { + const { root, wrapper } = mount(stub); + + const shouldItemUpdateSpy = getShouldItemUpdateSpy(wrapper); + const treeNodeRenderSpy = getTreeNodeRenderSpy(wrapper); + + updateObjectInspectorTree(wrapper); + + checkShouldItemUpdate(shouldItemUpdateSpy, root, shouldItemUpdateResult); + expect(treeNodeRenderSpy.mock.calls).toHaveLength(renderCallsLength); +} + +function checkShouldItemUpdate(spy, item, result) { + expect(spy.mock.calls).toHaveLength(1); + expect(spy.mock.calls[0][0]).toBe(item); + expect(spy.mock.calls[0][1]).toBe(item); + expect(spy.mock.results[0].value).toBe(result); +} + +function getInstance(wrapper, selector) { + return wrapper + .find(selector) + .first() + .instance(); +} + +function getShouldItemUpdateSpy(wrapper) { + return jest.spyOn( + getInstance(wrapper, "ObjectInspector"), + "shouldItemUpdate" + ); +} + +function getTreeNodeRenderSpy(wrapper) { + return jest.spyOn(getInstance(wrapper, "TreeNode"), "render"); +} + +function updateObjectInspectorTree(wrapper) { + // Update the ObjectInspector first to propagate its updated options to the + // Tree component. + getInstance(wrapper, "ObjectInspector").forceUpdate(); + getInstance(wrapper, "Tree").forceUpdate(); +} diff --git a/devtools/client/shared/components/test/node/components/object-inspector/component/window.test.js b/devtools/client/shared/components/test/node/components/object-inspector/component/window.test.js new file mode 100644 index 0000000000..c0b1036385 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/component/window.test.js @@ -0,0 +1,96 @@ +/* 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/>. */ + +const { + createNode, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); +const { + waitForDispatch, + mountObjectInspector, +} = require("resource://devtools/client/shared/components/test/node/components/object-inspector/test-utils.js"); + +const gripWindowStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/window.js"); +const ObjectFront = require("resource://devtools/client/shared/components/test/node/__mocks__/object-front.js"); +const windowNode = createNode({ + name: "window", + contents: { value: gripWindowStubs.get("Window")._grip }, +}); + +const client = { + createObjectFront: grip => ObjectFront(grip), + getFrontByID: _id => null, +}; + +function generateDefaults(overrides) { + return { + autoExpandDepth: 0, + roots: [windowNode], + ...overrides, + }; +} + +describe("ObjectInspector - dimTopLevelWindow", () => { + it("renders window as expected when dimTopLevelWindow is true", async () => { + const props = generateDefaults({ + dimTopLevelWindow: true, + }); + + const { wrapper, store } = mountObjectInspector({ client, props }); + let nodes = wrapper.find(".node"); + const node = nodes.at(0); + + expect(nodes.at(0).hasClass("lessen")).toBeTruthy(); + expect(wrapper).toMatchSnapshot(); + + const onPropertiesLoaded = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + node.simulate("click"); + await onPropertiesLoaded; + wrapper.update(); + + nodes = wrapper.find(".node"); + expect(nodes.at(0).hasClass("lessen")).toBeFalsy(); + expect(wrapper).toMatchSnapshot(); + }); + + it("renders collapsed top-level window when dimTopLevelWindow =false", () => { + // The window node should not have the "lessen" class when + // dimTopLevelWindow is falsy. + const props = generateDefaults(); + const { wrapper } = mountObjectInspector({ client, props }); + + expect(wrapper.find(".node.lessen").exists()).toBeFalsy(); + expect(wrapper).toMatchSnapshot(); + }); + + it("renders sub-level window", async () => { + // The window node should not have the "lessen" class when it is not at + // top level. + const root = createNode({ + name: "root", + contents: [windowNode], + }); + + const props = generateDefaults({ + roots: [root], + dimTopLevelWindow: true, + injectWaitService: true, + }); + const { wrapper, store } = mountObjectInspector({ client, props }); + + let nodes = wrapper.find(".node"); + const node = nodes.at(0); + const onPropertiesLoaded = waitForDispatch(store, "NODE_PROPERTIES_LOADED"); + node.simulate("click"); + await onPropertiesLoaded; + wrapper.update(); + + nodes = wrapper.find(".node"); + const win = nodes.at(1); + + // Make sure we target the window object. + expect(win.find(".objectBox-Window").exists()).toBeTruthy(); + expect(win.hasClass("lessen")).toBeFalsy(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/test-utils.js b/devtools/client/shared/components/test/node/components/object-inspector/test-utils.js new file mode 100644 index 0000000000..79d3e41161 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/test-utils.js @@ -0,0 +1,231 @@ +/* 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/>. */ + +const { mount } = require("enzyme"); +const { createFactory } = require("resource://devtools/client/shared/vendor/react.js"); + +const { Provider } = require("resource://devtools/client/shared/vendor/react-redux.js"); +const { + combineReducers, + createStore, + applyMiddleware, +} = require("resource://devtools/client/shared/vendor/redux.js"); + +const { thunk } = require("resource://devtools/client/shared/redux/middleware/thunk.js"); +const { + waitUntilService, +} = require("resource://devtools/client/shared/redux/middleware/wait-service.js"); + +/** + * Redux store utils + * @module utils/create-store + */ +const objectInspector = require("resource://devtools/client/shared/components/object-inspector/index.js"); +const { + getLoadedProperties, + getLoadedPropertyKeys, + getExpandedPaths, + getExpandedPathKeys, +} = require("resource://devtools/client/shared/components/object-inspector/reducer.js"); + +const ObjectInspector = createFactory(objectInspector.ObjectInspector); + +const { + NAME: WAIT_UNTIL_TYPE, +} = require("resource://devtools/client/shared/redux/middleware/wait-service.js"); + +/* + * Takes an Enzyme wrapper (obtained with mount/shallow/…) and + * returns a stringified version of the ObjectInspector, e.g. + * + * ▼ Map { "a" → "value-a", "b" → "value-b" } + * | size : 2 + * | ▼ <entries> + * | | ▼ 0 : "a" → "value-a" + * | | | <key> : "a" + * | | | <value> : "value-a" + * | | ▼ 1 : "b" → "value-b" + * | | | <key> : "b" + * | | | <value> : "value-b" + * | ▼ <prototype> : Object { … } + * + */ +function formatObjectInspector(wrapper) { + const hasFocusedNode = wrapper.find(".tree-node.focused").length > 0; + const textTree = wrapper + .find(".tree-node") + .map(node => { + const indentStr = "| ".repeat((node.prop("aria-level") || 1) - 1); + // Need to target .arrow or Enzyme will also match the ArrowExpander + // component. + const arrow = node.find(".arrow"); + let arrowStr = " "; + if (arrow.exists()) { + arrowStr = arrow.hasClass("expanded") ? "▼ " : "▶︎ "; + } else { + arrowStr = " "; + } + + const icon = node + .find(".node") + .first() + .hasClass("block") + ? "☲ " + : ""; + let text = `${indentStr}${arrowStr}${icon}${getSanitizedNodeText(node)}`; + + if (node.find("button.invoke-getter").exists()) { + text = `${text}(>>)`; + } + + if (!hasFocusedNode) { + return text; + } + return node.hasClass("focused") ? `[ ${text} ]` : ` ${text}`; + }) + .join("\n"); + // Wrap the text representation in new lines so it keeps alignment between + // tree nodes. + return `\n${textTree}\n`; +} + +function getSanitizedNodeText(node) { + // Stripping off the invisible space used in the indent. + return node.text().replace(/^\u200B+/, ""); +} + +/** + * Wait for a specific action type to be dispatched. + * + * @param {Object} store: Redux store + * @param {String} type: type of the actin to wait for + * @return {Promise} + */ +function waitForDispatch(store, type) { + return new Promise(resolve => { + store.dispatch({ + type: WAIT_UNTIL_TYPE, + predicate: action => action.type === type, + run: (dispatch, getState, action) => { + resolve(action); + }, + }); + }); +} + +/** + * Wait until the condition evaluates to something truthy + * @param {function} condition: function that we need for returning something + * truthy. + * @param {int} interval: Time to wait before trying to evaluate condition again + * @param {int} maxTries: Number of evaluation to try. + */ +async function waitFor(condition, interval = 50, maxTries = 100) { + let res = condition(); + while (!res) { + await new Promise(done => setTimeout(done, interval)); + maxTries--; + + if (maxTries <= 0) { + throw new Error("waitFor - maxTries limit hit"); + } + + res = condition(); + } + return res; +} + +/** + * Wait until the state has all the expected keys for the loadedProperties + * state prop. + * @param {Redux Store} store: function that we need for returning something + * truthy. + * @param {Array} expectedKeys: Array of stringified keys. + * @param {int} interval: Time to wait before trying to evaluate condition again + * @param {int} maxTries: Number of evaluation to try. + */ +function waitForLoadedProperties(store, expectedKeys, interval, maxTries) { + return waitFor( + () => storeHasLoadedPropertiesKeys(store, expectedKeys), + interval, + maxTries + ); +} + +function storeHasLoadedPropertiesKeys(store, expectedKeys) { + return expectedKeys.every(key => storeHasLoadedProperty(store, key)); +} + +function storeHasLoadedProperty(store, key) { + return getLoadedPropertyKeys(store.getState()).some( + k => k.toString() === key + ); +} + +function storeHasExactLoadedProperties(store, expectedKeys) { + return ( + expectedKeys.length === getLoadedProperties(store.getState()).size && + expectedKeys.every(key => storeHasLoadedProperty(store, key)) + ); +} + +function storeHasExpandedPaths(store, expectedKeys) { + return expectedKeys.every(key => storeHasExpandedPath(store, key)); +} + +function storeHasExpandedPath(store, key) { + return getExpandedPathKeys(store.getState()).some(k => k.toString() === key); +} + +function storeHasExactExpandedPaths(store, expectedKeys) { + return ( + expectedKeys.length === getExpandedPaths(store.getState()).size && + expectedKeys.every(key => storeHasExpandedPath(store, key)) + ); +} + +function createOiStore(client, initialState = {}) { + const reducers = { objectInspector: objectInspector.reducer.default }; + return configureStore({ + thunkArgs: { client }, + })(combineReducers(reducers), initialState); +} + +const configureStore = (opts = {}) => { + const middleware = [thunk(opts.thunkArgs), waitUntilService]; + return applyMiddleware(...middleware)(createStore); +}; + +function mountObjectInspector({ props, client, initialState = {} }) { + if (initialState.objectInspector) { + initialState.objectInspector = { + expandedPaths: new Set(), + loadedProperties: new Map(), + ...initialState.objectInspector, + }; + } + const store = createOiStore(client, initialState); + const wrapper = mount( + createFactory(Provider)({ store }, ObjectInspector(props)) + ); + + const tree = wrapper.find(".tree"); + + return { store, tree, wrapper, client }; +} + +module.exports = { + formatObjectInspector, + storeHasExpandedPaths, + storeHasExpandedPath, + storeHasExactExpandedPaths, + storeHasLoadedPropertiesKeys, + storeHasLoadedProperty, + storeHasExactLoadedProperties, + waitFor, + waitForDispatch, + waitForLoadedProperties, + mountObjectInspector, + createStore: createOiStore, +}; diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/__snapshots__/promises.test.js.snap b/devtools/client/shared/components/test/node/components/object-inspector/utils/__snapshots__/promises.test.js.snap new file mode 100644 index 0000000000..75903c0ff1 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/__snapshots__/promises.test.js.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`promises utils function makeNodesForPromiseProperties 1`] = ` +Array [ + Object { + "contents": Object { + "value": "rejected", + }, + "meta": undefined, + "name": "<state>", + "parent": Object { + "contents": Object { + "value": Object { + "actor": "server2.conn2.child1/obj36", + "class": "Promise", + "type": "object", + }, + }, + "path": "root", + }, + "path": "root◦<state>", + "propertyName": undefined, + "type": Symbol(<state>), + }, + Object { + "contents": Object { + "front": null, + "value": Object { + "type": "3", + }, + }, + "meta": undefined, + "name": "<reason>", + "parent": Object { + "contents": Object { + "value": Object { + "actor": "server2.conn2.child1/obj36", + "class": "Promise", + "type": "object", + }, + }, + "path": "root", + }, + "path": "root◦<reason>", + "propertyName": undefined, + "type": Symbol(<reason>), + }, +] +`; diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/create-node.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/create-node.test.js new file mode 100644 index 0000000000..792ad2dfb0 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/create-node.test.js @@ -0,0 +1,87 @@ +/* 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/>. */ + +const { createNode, NODE_TYPES } = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); + +describe("createNode", () => { + it("returns null when contents is undefined", () => { + expect(createNode({ name: "name" })).toBeNull(); + }); + + it("does not return null when contents is null", () => { + expect( + createNode({ + name: "name", + path: "path", + contents: null, + }) + ).not.toBe(null); + }); + + it("returns the expected object when parent is undefined", () => { + const node = createNode({ + name: "name", + path: "path", + contents: "contents", + }); + expect(node).toEqual({ + name: "name", + path: node.path, + contents: "contents", + type: NODE_TYPES.GRIP, + }); + }); + + it("returns the expected object when parent is not null", () => { + const root = createNode({ name: "name", contents: null }); + const child = createNode({ + parent: root, + name: "name", + path: "path", + contents: "contents", + }); + expect(child.parent).toEqual(root); + }); + + it("returns the expected object when type is not undefined", () => { + const root = createNode({ name: "name", contents: null }); + const child = createNode({ + parent: root, + name: "name", + path: "path", + contents: "contents", + type: NODE_TYPES.BUCKET, + }); + + expect(child.type).toEqual(NODE_TYPES.BUCKET); + }); + + it("uses the name property for the path when path is not provided", () => { + expect( + createNode({ name: "name", contents: "contents" }).path.toString() + ).toBe("name"); + }); + + it("wraps the path in a Symbol when provided", () => { + expect( + createNode({ + name: "name", + path: "path", + contents: "contents", + }).path.toString() + ).toBe("path"); + }); + + it("uses parent path to compute its path", () => { + const root = createNode({ name: "root", contents: null }); + expect( + createNode({ + parent: root, + name: "name", + path: "path", + contents: "contents", + }).path.toString() + ).toBe("root◦path"); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/get-children.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/get-children.test.js new file mode 100644 index 0000000000..f57f82073d --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/get-children.test.js @@ -0,0 +1,278 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +const accessorStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/accessor.js"); +const performanceStubs = require("resource://devtools/client/shared/components/test/node/stubs/object-inspector/performance.js"); +const gripMapStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-map.js"); +const gripArrayStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); +const gripEntryStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-entry.js"); +const gripStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip.js"); + +const { + createNode, + getChildren, + getValue, + makeNodesForProperties, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); + +function createRootNodeWithAccessorProperty(accessorStub) { + const node = { name: "root", path: "rootpath" }; + const nodes = makeNodesForProperties( + { + ownProperties: { + x: accessorStub, + }, + }, + node + ); + node.contents = nodes; + + return createNode(node); +} + +describe("getChildren", () => { + it("accessors - getter", () => { + const children = getChildren({ + item: createRootNodeWithAccessorProperty(accessorStubs.get("getter")), + }); + + const names = children.map(n => n.name); + const paths = children.map(n => n.path.toString()); + + expect(names).toEqual(["x", "<get x()>"]); + expect(paths).toEqual(["rootpath◦x", "rootpath◦<get x()>"]); + }); + + it("accessors - setter", () => { + const children = getChildren({ + item: createRootNodeWithAccessorProperty(accessorStubs.get("setter")), + }); + + const names = children.map(n => n.name); + const paths = children.map(n => n.path.toString()); + + expect(names).toEqual(["x", "<set x()>"]); + expect(paths).toEqual(["rootpath◦x", "rootpath◦<set x()>"]); + }); + + it("accessors - getter & setter", () => { + const children = getChildren({ + item: createRootNodeWithAccessorProperty( + accessorStubs.get("getter setter") + ), + }); + + const names = children.map(n => n.name); + const paths = children.map(n => n.path.toString()); + + expect(names).toEqual(["x", "<get x()>", "<set x()>"]); + expect(paths).toEqual([ + "rootpath◦x", + "rootpath◦<get x()>", + "rootpath◦<set x()>", + ]); + }); + + it("returns the expected nodes for Proxy", () => { + const proxyNode = createNode({ + name: "root", + path: "rootpath", + contents: { value: gripStubs.get("testProxy") }, + }); + const loadedProperties = new Map([ + [proxyNode.path, gripStubs.get("testProxySlots")], + ]); + const nodes = getChildren({ item: proxyNode, loadedProperties }); + const names = nodes.map(n => n.name); + const paths = nodes.map(n => n.path.toString()); + + expect(names).toEqual(["<target>", "<handler>"]); + expect(paths).toEqual(["rootpath◦<target>", "rootpath◦<handler>"]); + }); + + it("safeGetterValues", () => { + const stub = performanceStubs.get("timing"); + const root = createNode({ + name: "root", + path: "rootpath", + contents: { + value: { + actor: "rootactor", + type: "object", + }, + }, + }); + const nodes = getChildren({ + item: root, + loadedProperties: new Map([[root.path, stub]]), + }); + + const nodeEntries = nodes.map(n => [n.name, getValue(n)]); + const nodePaths = nodes.map(n => n.path.toString()); + + const childrenEntries = [ + ["connectEnd", 1500967716401], + ["connectStart", 1500967716401], + ["domComplete", 1500967716719], + ["domContentLoadedEventEnd", 1500967716715], + ["domContentLoadedEventStart", 1500967716696], + ["domInteractive", 1500967716552], + ["domLoading", 1500967716426], + ["domainLookupEnd", 1500967716401], + ["domainLookupStart", 1500967716401], + ["fetchStart", 1500967716401], + ["loadEventEnd", 1500967716720], + ["loadEventStart", 1500967716719], + ["navigationStart", 1500967716401], + ["redirectEnd", 0], + ["redirectStart", 0], + ["requestStart", 1500967716401], + ["responseEnd", 1500967716401], + ["responseStart", 1500967716401], + ["secureConnectionStart", 1500967716401], + ["unloadEventEnd", 0], + ["unloadEventStart", 0], + ["<prototype>", stub.prototype], + ]; + const childrenPaths = childrenEntries.map(([name]) => `rootpath◦${name}`); + + expect(nodeEntries).toEqual(childrenEntries); + expect(nodePaths).toEqual(childrenPaths); + }); + + it("gets data from the cache when it exists", () => { + const mapNode = createNode({ + name: "map", + contents: { + value: gripMapStubs.get("testSymbolKeyedMap"), + }, + }); + const cachedData = ""; + const children = getChildren({ + cachedNodes: new Map([[mapNode.path, cachedData]]), + item: mapNode, + }); + expect(children).toBe(cachedData); + }); + + it("returns an empty array if the node does not represent an object", () => { + const node = createNode({ name: "root", contents: { value: 42 } }); + expect( + getChildren({ + item: node, + }) + ).toEqual([]); + }); + + it("returns an empty array if a grip node has no loaded properties", () => { + const node = createNode({ + name: "root", + contents: { value: gripMapStubs.get("testMaxProps") }, + }); + expect( + getChildren({ + item: node, + }) + ).toEqual([]); + }); + + it("adds children to cache when a grip node has loaded properties", () => { + const stub = performanceStubs.get("timing"); + const cachedNodes = new Map(); + + const rootNode = createNode({ + name: "root", + contents: { + value: { + actor: "rootactor", + type: "object", + }, + }, + }); + const children = getChildren({ + cachedNodes, + item: rootNode, + loadedProperties: new Map([[rootNode.path, stub]]), + }); + expect(cachedNodes.get(rootNode.path)).toBe(children); + }); + + it("adds children to cache when it already has some", () => { + const cachedNodes = new Map(); + const children = [""]; + const rootNode = createNode({ name: "root", contents: children }); + getChildren({ + cachedNodes, + item: rootNode, + }); + expect(cachedNodes.get(rootNode.path)).toBe(children); + }); + + it("adds children to cache on a node with accessors", () => { + const cachedNodes = new Map(); + const node = createRootNodeWithAccessorProperty( + accessorStubs.get("getter setter") + ); + + const children = getChildren({ + cachedNodes, + item: node, + }); + expect(cachedNodes.get(node.path)).toBe(children); + }); + + it("adds children to cache on a map entry node", () => { + const cachedNodes = new Map(); + const node = createNode({ + name: "root", + contents: { value: gripEntryStubs.get("A → 0") }, + }); + const children = getChildren({ + cachedNodes, + item: node, + }); + expect(cachedNodes.get(node.path)).toBe(children); + }); + + it("adds children to cache on a proxy node having loaded props", () => { + const cachedNodes = new Map(); + const node = createNode({ + name: "root", + contents: { value: gripStubs.get("testProxy") }, + }); + const children = getChildren({ + cachedNodes, + item: node, + loadedProperties: new Map([[node.path, gripStubs.get("testProxySlots")]]), + }); + expect(cachedNodes.get(node.path)).toBe(children); + }); + + it("doesn't cache children on node with buckets and no loaded props", () => { + const cachedNodes = new Map(); + const node = createNode({ + name: "root", + contents: { value: gripArrayStubs.get("Array(234)") }, + }); + getChildren({ + cachedNodes, + item: node, + }); + expect(cachedNodes.has(node.path)).toBeFalsy(); + }); + + it("caches children on a node with buckets having loaded props", () => { + const cachedNodes = new Map(); + const node = createNode({ + name: "root", + contents: { value: gripArrayStubs.get("Array(234)") }, + }); + const children = getChildren({ + cachedNodes, + item: node, + loadedProperties: new Map([[node.path, { prototype: {} }]]), + }); + expect(cachedNodes.get(node.path)).toBe(children); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/get-closest-grip-node.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/get-closest-grip-node.test.js new file mode 100644 index 0000000000..9aa7e127a8 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/get-closest-grip-node.test.js @@ -0,0 +1,52 @@ +/* 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/>. */ + +const { + createNode, + getClosestGripNode, + makeNodesForEntries, + makeNumericalBuckets, +} = require("devtools/client/shared/components/object-inspector/utils/node"); + +const gripRepStubs = require(`devtools/client/shared/components/test/node/stubs/reps/grip`); +const gripArrayRepStubs = require(`devtools/client/shared/components/test/node/stubs/reps/grip-array`); + +describe("getClosestGripNode", () => { + it("returns grip node itself", () => { + const stub = gripRepStubs.get("testMoreThanMaxProps"); + const node = createNode({ name: "root", contents: { value: stub } }); + expect(getClosestGripNode(node)).toBe(node); + }); + + it("returns the expected node for entries node", () => { + const mapStubNode = createNode({ name: "map", contents: { value: {} } }); + const entriesNode = makeNodesForEntries(mapStubNode); + expect(getClosestGripNode(entriesNode)).toBe(mapStubNode); + }); + + it("returns the expected node for bucket node", () => { + const grip = gripArrayRepStubs.get("testMaxProps"); + const root = createNode({ name: "root", contents: { value: grip } }); + const [bucket] = makeNumericalBuckets(root); + expect(getClosestGripNode(bucket)).toBe(root); + }); + + it("returns the expected node for sub-bucket node", () => { + const grip = gripArrayRepStubs.get("testMaxProps"); + const root = createNode({ name: "root", contents: { value: grip } }); + const [bucket] = makeNumericalBuckets(root); + const [subBucket] = makeNumericalBuckets(bucket); + expect(getClosestGripNode(subBucket)).toBe(root); + }); + + it("returns the expected node for deep sub-bucket node", () => { + const grip = gripArrayRepStubs.get("testMaxProps"); + const root = createNode({ name: "root", contents: { value: grip } }); + let [bucket] = makeNumericalBuckets(root); + for (let i = 0; i < 10; i++) { + bucket = makeNumericalBuckets({ ...bucket })[0]; + } + expect(getClosestGripNode(bucket)).toBe(root); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/get-value.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/get-value.test.js new file mode 100644 index 0000000000..29f0c0ffce --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/get-value.test.js @@ -0,0 +1,91 @@ +/* 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/>. */ + +const { getValue } = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); + +describe("getValue", () => { + it("get the value from contents.value", () => { + let item = { + contents: { + value: "my value", + }, + }; + expect(getValue(item)).toBe("my value"); + + item = { + contents: { + value: 0, + }, + }; + expect(getValue(item)).toBe(0); + + item = { + contents: { + value: false, + }, + }; + expect(getValue(item)).toBe(false); + + item = { + contents: { + value: null, + }, + }; + expect(getValue(item)).toBe(null); + }); + + it("get the value from contents.getterValue", () => { + let item = { + contents: { + getterValue: "my getter value", + }, + }; + expect(getValue(item)).toBe("my getter value"); + + item = { + contents: { + getterValue: 0, + }, + }; + expect(getValue(item)).toBe(0); + + item = { + contents: { + getterValue: false, + }, + }; + expect(getValue(item)).toBe(false); + + item = { + contents: { + getterValue: null, + }, + }; + expect(getValue(item)).toBe(null); + }); + + it("get the value from getter and setter", () => { + let item = { + contents: { + get: "get", + }, + }; + expect(getValue(item)).toEqual({ get: "get" }); + + item = { + contents: { + set: "set", + }, + }; + expect(getValue(item)).toEqual({ set: "set" }); + + item = { + contents: { + get: "get", + set: "set", + }, + }; + expect(getValue(item)).toEqual({ get: "get", set: "set" }); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/make-node-for-properties.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/make-node-for-properties.test.js new file mode 100644 index 0000000000..da0a221531 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/make-node-for-properties.test.js @@ -0,0 +1,295 @@ +/* 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/>. */ + +const { + createNode, + makeNodesForProperties, + nodeIsDefaultProperties, + nodeIsEntries, + nodeIsMapEntry, + nodeIsPrototype, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); +const gripArrayStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); + +const root = { + path: "root", + contents: { + value: gripArrayStubs.get("testBasic"), + }, +}; + +const objProperties = { + ownProperties: { + "0": { + value: {}, + }, + "2": {}, + length: { + value: 3, + }, + }, + prototype: { + type: "object", + actor: "server2.conn1.child1/obj618", + class: "bla", + }, +}; + +describe("makeNodesForProperties", () => { + it("kitchen sink", () => { + const nodes = makeNodesForProperties(objProperties, root); + + const names = nodes.map(n => n.name); + expect(names).toEqual(["0", "length", "<prototype>"]); + + const paths = nodes.map(n => n.path.toString()); + expect(paths).toEqual(["root◦0", "root◦length", "root◦<prototype>"]); + }); + + it("includes getters and setters", () => { + const nodes = makeNodesForProperties( + { + ownProperties: { + foo: { value: "foo" }, + bar: { + get: { + type: "object", + }, + set: { + type: "undefined", + }, + }, + baz: { + get: { + type: "undefined", + }, + set: { + type: "object", + }, + }, + }, + prototype: { + class: "bla", + }, + }, + root + ); + + const names = nodes.map(n => n.name); + const paths = nodes.map(n => n.path.toString()); + + expect(names).toEqual([ + "bar", + "baz", + "foo", + "<get bar()>", + "<set baz()>", + "<prototype>", + ]); + + expect(paths).toEqual([ + "root◦bar", + "root◦baz", + "root◦foo", + "root◦<get bar()>", + "root◦<set baz()>", + "root◦<prototype>", + ]); + }); + + it("does not include unrelevant properties", () => { + const nodes = makeNodesForProperties( + { + ownProperties: { + foo: undefined, + bar: null, + baz: {}, + }, + }, + root + ); + + const names = nodes.map(n => n.name); + const paths = nodes.map(n => n.path); + + expect(names).toEqual([]); + expect(paths).toEqual([]); + }); + + it("sorts keys", () => { + const nodes = makeNodesForProperties( + { + ownProperties: { + bar: { value: {} }, + 1: { value: {} }, + 11: { value: {} }, + 2: { value: {} }, + _bar: { value: {} }, + }, + prototype: { + class: "bla", + }, + }, + root + ); + + const names = nodes.map(n => n.name); + const paths = nodes.map(n => n.path.toString()); + + expect(names).toEqual(["1", "2", "11", "_bar", "bar", "<prototype>"]); + expect(paths).toEqual([ + "root◦1", + "root◦2", + "root◦11", + "root◦_bar", + "root◦bar", + "root◦<prototype>", + ]); + }); + + it("prototype is included", () => { + const nodes = makeNodesForProperties( + { + ownProperties: { + bar: { value: {} }, + }, + prototype: { value: {}, class: "bla" }, + }, + root + ); + + const names = nodes.map(n => n.name); + const paths = nodes.map(n => n.path.toString()); + + expect(names).toEqual(["bar", "<prototype>"]); + expect(paths).toEqual(["root◦bar", "root◦<prototype>"]); + + expect(nodeIsPrototype(nodes[1])).toBe(true); + }); + + it("window object", () => { + const nodes = makeNodesForProperties( + { + ownProperties: { + bar: { + value: {}, + get: { type: "function" }, + set: { type: "function" }, + }, + location: { value: {} }, + onload: { + get: { type: "function" }, + set: { type: "function" }, + }, + }, + class: "Window", + }, + { + path: "root", + contents: { value: { class: "Window" } }, + } + ); + + const names = nodes.map(n => n.name); + const paths = nodes.map(n => n.path); + + expect(names).toEqual([ + "bar", + "<default properties>", + "<get bar()>", + "<set bar()>", + ]); + expect(paths).toEqual([ + "root◦bar", + "root◦<default properties>", + "root◦<get bar()>", + "root◦<set bar()>", + ]); + + const defaultPropertyNode = nodes[1]; + expect(nodeIsDefaultProperties(defaultPropertyNode)).toBe(true); + + const defaultPropNames = defaultPropertyNode.contents.map(n => n.name); + const defaultPropPath = defaultPropertyNode.contents.map(n => n.path); + expect(defaultPropNames).toEqual([ + "location", + "onload", + "<get onload()>", + "<set onload()>", + ]); + expect(defaultPropPath).toEqual([ + "root◦<default properties>◦location", + "root◦<default properties>◦onload", + "root◦<default properties>◦<get onload()>", + "root◦<default properties>◦<set onload()>", + ]); + }); + + it("object with entries", () => { + const gripMapStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-map.js"); + + const mapNode = createNode({ + name: "map", + path: "root", + contents: { + value: gripMapStubs.get("testSymbolKeyedMap"), + }, + }); + + const nodes = makeNodesForProperties( + { + ownProperties: { + size: { value: 1 }, + custom: { value: "customValue" }, + }, + }, + mapNode + ); + + const names = nodes.map(n => n.name); + const paths = nodes.map(n => n.path.toString()); + + expect(names).toEqual(["custom", "size", "<entries>"]); + expect(paths).toEqual(["root◦custom", "root◦size", "root◦<entries>"]); + + const entriesNode = nodes[2]; + expect(nodeIsEntries(entriesNode)).toBe(true); + }); + + it("quotes property names", () => { + const nodes = makeNodesForProperties( + { + ownProperties: { + // Numbers are ok. + 332217: { value: {} }, + "needs-quotes": { value: {} }, + unquoted: { value: {} }, + "": { value: {} }, + }, + prototype: { + class: "WindowPrototype", + }, + }, + root + ); + + const names = nodes.map(n => n.name); + const paths = nodes.map(n => n.path.toString()); + + expect(names).toEqual([ + '""', + "332217", + '"needs-quotes"', + "unquoted", + "<prototype>", + ]); + expect(paths).toEqual([ + 'root◦""', + "root◦332217", + 'root◦"needs-quotes"', + "root◦unquoted", + "root◦<prototype>", + ]); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/make-numerical-buckets.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/make-numerical-buckets.test.js new file mode 100644 index 0000000000..02fb1a3bc5 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/make-numerical-buckets.test.js @@ -0,0 +1,138 @@ +/* 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/>. */ + +const { + createNode, + makeNumericalBuckets, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); +const gripArrayStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); + +describe("makeNumericalBuckets", () => { + it("handles simple numerical buckets", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("Array(234)"), + }, + }); + const nodes = makeNumericalBuckets(node); + + const names = nodes.map(n => n.name); + const paths = nodes.map(n => n.path.toString()); + + expect(names).toEqual(["[0…99]", "[100…199]", "[200…233]"]); + + expect(paths).toEqual(["root◦[0…99]", "root◦[100…199]", "root◦[200…233]"]); + }); + + // TODO: Re-enable when we have support for lonely node. + it.skip("does not create a numerical bucket for a single node", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("Array(101)"), + }, + }); + const nodes = makeNumericalBuckets(node); + + const names = nodes.map(n => n.name); + const paths = nodes.map(n => n.path.toString()); + + expect(names).toEqual(["[0…99]", "100"]); + + expect(paths).toEqual(["root◦bucket_0-99", "root◦100"]); + }); + + // TODO: Re-enable when we have support for lonely node. + it.skip("does create a numerical bucket for two node", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("Array(234)"), + }, + }); + const nodes = makeNumericalBuckets(node); + + const names = nodes.map(n => n.name); + const paths = nodes.map(n => n.path.toString()); + + expect(names).toEqual(["[0…99]", "[100…101]"]); + + expect(paths).toEqual(["root◦bucket_0-99", "root◦bucket_100-101"]); + }); + + it("creates sub-buckets when needed", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("Array(23456)"), + }, + }); + const nodes = makeNumericalBuckets(node); + const names = nodes.map(n => n.name); + + expect(names).toEqual([ + "[0…999]", + "[1000…1999]", + "[2000…2999]", + "[3000…3999]", + "[4000…4999]", + "[5000…5999]", + "[6000…6999]", + "[7000…7999]", + "[8000…8999]", + "[9000…9999]", + "[10000…10999]", + "[11000…11999]", + "[12000…12999]", + "[13000…13999]", + "[14000…14999]", + "[15000…15999]", + "[16000…16999]", + "[17000…17999]", + "[18000…18999]", + "[19000…19999]", + "[20000…20999]", + "[21000…21999]", + "[22000…22999]", + "[23000…23455]", + ]); + + const firstBucketNodes = makeNumericalBuckets(nodes[0]); + const firstBucketNames = firstBucketNodes.map(n => n.name); + const firstBucketPaths = firstBucketNodes.map(n => n.path.toString()); + + expect(firstBucketNames).toEqual([ + "[0…99]", + "[100…199]", + "[200…299]", + "[300…399]", + "[400…499]", + "[500…599]", + "[600…699]", + "[700…799]", + "[800…899]", + "[900…999]", + ]); + expect(firstBucketPaths[0]).toEqual("root◦[0…999]◦[0…99]"); + expect(firstBucketPaths[firstBucketPaths.length - 1]).toEqual( + "root◦[0…999]◦[900…999]" + ); + + const lastBucketNodes = makeNumericalBuckets(nodes[nodes.length - 1]); + const lastBucketNames = lastBucketNodes.map(n => n.name); + const lastBucketPaths = lastBucketNodes.map(n => n.path.toString()); + expect(lastBucketNames).toEqual([ + "[23000…23099]", + "[23100…23199]", + "[23200…23299]", + "[23300…23399]", + "[23400…23455]", + ]); + expect(lastBucketPaths[0]).toEqual("root◦[23000…23455]◦[23000…23099]"); + expect(lastBucketPaths[lastBucketPaths.length - 1]).toEqual( + "root◦[23000…23455]◦[23400…23455]" + ); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/node-has-entries.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/node-has-entries.test.js new file mode 100644 index 0000000000..4a7aaa971d --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/node-has-entries.test.js @@ -0,0 +1,51 @@ +/* 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/>. */ + +const gripArrayStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); +const gripMapStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-map.js"); + +const { + createNode, + nodeHasEntries, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); + +const createRootNode = value => + createNode({ name: "root", contents: { value } }); +describe("nodeHasEntries", () => { + it("returns true for Maps", () => { + expect( + nodeHasEntries(createRootNode(gripMapStubs.get("testSymbolKeyedMap"))) + ).toBe(true); + }); + + it("returns true for WeakMaps", () => { + expect( + nodeHasEntries(createRootNode(gripMapStubs.get("testWeakMap"))) + ).toBe(true); + }); + + it("returns true for Sets", () => { + expect( + nodeHasEntries(createRootNode(gripArrayStubs.get("new Set([1,2,3,4])"))) + ).toBe(true); + }); + + it("returns true for WeakSets", () => { + expect( + nodeHasEntries( + createRootNode( + gripArrayStubs.get( + "new WeakSet(document.querySelectorAll('div, button'))" + ) + ) + ) + ).toBe(true); + }); + + it("returns false for Arrays", () => { + expect( + nodeHasEntries(createRootNode(gripMapStubs.get("testMaxProps"))) + ).toBe(false); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/node-is-window.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/node-is-window.test.js new file mode 100644 index 0000000000..8fe920ed17 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/node-is-window.test.js @@ -0,0 +1,20 @@ +/* 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/>. */ + +const gripWindowStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/window.js"); + +const { + createNode, + nodeIsWindow, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); + +const createRootNode = value => + createNode({ name: "root", contents: { value } }); +describe("nodeIsWindow", () => { + it("returns true for Window", () => { + expect( + nodeIsWindow(createRootNode(gripWindowStubs.get("Window")._grip)) + ).toBe(true); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/node-supports-numerical-bucketing.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/node-supports-numerical-bucketing.test.js new file mode 100644 index 0000000000..51199146e0 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/node-supports-numerical-bucketing.test.js @@ -0,0 +1,72 @@ +/* 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/>. */ + +const { + createNode, + makeNodesForEntries, + nodeSupportsNumericalBucketing, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); + +const createRootNode = stub => + createNode({ + name: "root", + contents: { value: stub }, + }); + +const gripArrayStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); +const gripMapStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-map.js"); + +describe("nodeSupportsNumericalBucketing", () => { + it("returns true for Arrays", () => { + expect( + nodeSupportsNumericalBucketing( + createRootNode(gripArrayStubs.get("testBasic")) + ) + ).toBe(true); + }); + + it("returns true for NodeMap", () => { + expect( + nodeSupportsNumericalBucketing( + createRootNode(gripArrayStubs.get("testNamedNodeMap")) + ) + ).toBe(true); + }); + + it("returns true for NodeList", () => { + expect( + nodeSupportsNumericalBucketing( + createRootNode(gripArrayStubs.get("testNodeList")) + ) + ).toBe(true); + }); + + it("returns true for DocumentFragment", () => { + expect( + nodeSupportsNumericalBucketing( + createRootNode(gripArrayStubs.get("testDocumentFragment")) + ) + ).toBe(true); + }); + + it("returns true for <entries> node", () => { + expect( + nodeSupportsNumericalBucketing( + makeNodesForEntries( + createRootNode(gripMapStubs.get("testSymbolKeyedMap")) + ) + ) + ).toBe(true); + }); + + it("returns true for buckets node", () => { + expect( + nodeSupportsNumericalBucketing( + makeNodesForEntries( + createRootNode(gripMapStubs.get("testSymbolKeyedMap")) + ) + ) + ).toBe(true); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/promises.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/promises.test.js new file mode 100644 index 0000000000..229f717b56 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/promises.test.js @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +const { + makeNodesForPromiseProperties, + nodeIsPromise, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); + +describe("promises utils function", () => { + it("is promise", () => { + const promise = { + contents: { + enumerable: true, + configurable: false, + value: { + actor: "server2.conn2.child1/obj36", + promiseState: { + state: "rejected", + reason: { + type: "undefined", + }, + }, + class: "Promise", + type: "object", + }, + }, + }; + + expect(nodeIsPromise(promise)).toEqual(true); + }); + + it("makeNodesForPromiseProperties", () => { + const item = { + path: "root", + contents: { + value: { + actor: "server2.conn2.child1/obj36", + class: "Promise", + type: "object", + }, + }, + }; + const promiseState = { + state: "rejected", + reason: { + type: "3", + }, + }; + + const properties = makeNodesForPromiseProperties({promiseState}, item); + expect(properties).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-entries.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-entries.test.js new file mode 100644 index 0000000000..e4672f2a92 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-entries.test.js @@ -0,0 +1,171 @@ +/* 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/>. */ + +const Utils = require("resource://devtools/client/shared/components/object-inspector/utils/index.js"); +const { createNode, getChildren, makeNodesForEntries } = Utils.node; + +const { shouldLoadItemEntries } = Utils.loadProperties; + +const gripMapStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-map.js"); +const gripArrayStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); +const gripStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip.js"); + +describe("shouldLoadItemEntries", () => { + it("returns true for an entries node", () => { + const mapStubNode = createNode({ + name: "map", + contents: { + value: gripMapStubs.get("20-entries Map"), + }, + }); + const entriesNode = makeNodesForEntries(mapStubNode); + expect(shouldLoadItemEntries(entriesNode)).toBeTruthy(); + }); + + it("returns false for an already loaded entries node", () => { + const mapStubNode = createNode({ + name: "map", + contents: { + value: gripMapStubs.get("20-entries Map"), + }, + }); + const entriesNode = makeNodesForEntries(mapStubNode); + const loadedProperties = new Map([[entriesNode.path, true]]); + expect(shouldLoadItemEntries(entriesNode, loadedProperties)).toBeFalsy(); + }); + + it("returns true for entries on a Map with everything in preview", () => { + const mapStubNode = createNode({ + name: "map", + contents: { + value: gripMapStubs.get("testSymbolKeyedMap"), + }, + }); + const entriesNode = makeNodesForEntries(mapStubNode); + expect(shouldLoadItemEntries(entriesNode)).toBeTruthy(); + }); + + it("returns true for entries on a Set with everything in preview", () => { + const setStubNode = createNode({ + name: "set", + contents: { + value: gripArrayStubs.get("new Set([1,2,3,4])"), + }, + }); + const entriesNode = makeNodesForEntries(setStubNode); + expect(shouldLoadItemEntries(entriesNode)).toBeTruthy(); + }); + + it("returns false for a Set node", () => { + const setStubNode = createNode({ + name: "set", + contents: { + value: gripArrayStubs.get("new Set([1,2,3,4])"), + }, + }); + expect(shouldLoadItemEntries(setStubNode)).toBeFalsy(); + }); + + it("returns false for a Map node", () => { + const mapStubNode = createNode({ + name: "map", + contents: { + value: gripMapStubs.get("20-entries Map"), + }, + }); + expect(shouldLoadItemEntries(mapStubNode)).toBeFalsy(); + }); + + it("returns false for an array", () => { + const node = createNode({ + name: "array", + contents: { + value: gripMapStubs.get("testMaxProps"), + }, + }); + expect(shouldLoadItemEntries(node)).toBeFalsy(); + }); + + it("returns false for an object", () => { + const node = createNode({ + name: "array", + contents: { + value: gripStubs.get("testMaxProps"), + }, + }); + expect(shouldLoadItemEntries(node)).toBeFalsy(); + }); + + it("returns false for an entries node with buckets", () => { + const mapStubNode = createNode({ + name: "map", + contents: { + value: gripMapStubs.get("234-entries Map"), + }, + }); + const entriesNode = makeNodesForEntries(mapStubNode); + expect(shouldLoadItemEntries(entriesNode)).toBeFalsy(); + }); + + it("returns true for an entries bucket node", () => { + const mapStubNode = createNode({ + name: "map", + contents: { + value: gripMapStubs.get("234-entries Map"), + }, + }); + const entriesNode = makeNodesForEntries(mapStubNode); + const bucketNodes = getChildren({ + item: entriesNode, + loadedProperties: new Map([[entriesNode.path, true]]), + }); + + // Make sure we do have a bucket. + expect(bucketNodes[0].name).toBe("[0…99]"); + expect(shouldLoadItemEntries(bucketNodes[0])).toBeTruthy(); + }); + + it("returns false for an entries bucket node with sub-buckets", () => { + const mapStubNode = createNode({ + name: "map", + contents: { + value: gripMapStubs.get("23456-entries Map"), + }, + }); + const entriesNode = makeNodesForEntries(mapStubNode); + const bucketNodes = getChildren({ + item: entriesNode, + loadedProperties: new Map([[entriesNode.path, true]]), + }); + + // Make sure we do have a bucket. + expect(bucketNodes[0].name).toBe("[0…999]"); + expect(shouldLoadItemEntries(bucketNodes[0])).toBeFalsy(); + }); + + it("returns true for an entries sub-bucket node", () => { + const mapStubNode = createNode({ + name: "map", + contents: { + value: gripMapStubs.get("23456-entries Map"), + }, + }); + const entriesNode = makeNodesForEntries(mapStubNode); + const bucketNodes = getChildren({ + item: entriesNode, + loadedProperties: new Map([[entriesNode.path, true]]), + }); + // Make sure we do have a bucket. + expect(bucketNodes[0].name).toBe("[0…999]"); + + // Get the sub-buckets + const subBucketNodes = getChildren({ + item: bucketNodes[0], + loadedProperties: new Map([[bucketNodes[0].path, true]]), + }); + // Make sure we do have a bucket. + expect(subBucketNodes[0].name).toBe("[0…99]"); + expect(shouldLoadItemEntries(subBucketNodes[0])).toBeTruthy(); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-full-text.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-full-text.test.js new file mode 100644 index 0000000000..9e696a028c --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-full-text.test.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/>. */ + +const Utils = require("resource://devtools/client/shared/components/object-inspector/utils/index.js"); +const { createNode } = Utils.node; +const { shouldLoadItemFullText } = Utils.loadProperties; + +const longStringStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/long-string.js"); +const symbolStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/symbol.js"); + +describe("shouldLoadItemFullText", () => { + it("returns true for a longString node with unloaded full text", () => { + const node = createNode({ + name: "root", + contents: { + value: longStringStubs.get("testUnloadedFullText"), + }, + }); + expect(shouldLoadItemFullText(node)).toBeTruthy(); + }); + + it("returns false for a longString node with loaded full text", () => { + const node = createNode({ + name: "root", + contents: { + value: longStringStubs.get("testLoadedFullText"), + }, + }); + const loadedProperties = new Map([[node.path, true]]); + expect(shouldLoadItemFullText(node, loadedProperties)).toBeFalsy(); + }); + + it("returns false for non longString primitive nodes", () => { + const values = [ + "primitive string", + 1, + -1, + 0, + true, + false, + null, + undefined, + symbolStubs.get("Symbol"), + ]; + + const nodes = values.map((value, i) => + createNode({ + name: `root${i}`, + contents: { value }, + }) + ); + + nodes.forEach(node => expect(shouldLoadItemFullText(node)).toBeFalsy()); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-indexed-properties.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-indexed-properties.test.js new file mode 100644 index 0000000000..63e505b947 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-indexed-properties.test.js @@ -0,0 +1,259 @@ +/* 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/>. */ + +const Utils = require("resource://devtools/client/shared/components/object-inspector/utils/index.js"); +const { + createNode, + createGetterNode, + createSetterNode, + getChildren, + makeNodesForEntries, + nodeIsDefaultProperties, +} = Utils.node; + +const { shouldLoadItemIndexedProperties } = Utils.loadProperties; + +const { + createGripMapEntry, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); +const accessorStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/accessor.js"); +const gripMapStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-map.js"); +const gripArrayStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); +const gripStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip.js"); +const windowStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/window.js"); + +describe("shouldLoadItemIndexedProperties", () => { + it("returns true for an array", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("testMaxProps"), + }, + }); + expect(shouldLoadItemIndexedProperties(node)).toBeTruthy(); + }); + + it("returns false for an already loaded item", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("testMaxProps"), + }, + }); + const loadedProperties = new Map([[node.path, true]]); + expect(shouldLoadItemIndexedProperties(node, loadedProperties)).toBeFalsy(); + }); + + it("returns false for an array node with buckets", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("Array(234)"), + }, + }); + expect(shouldLoadItemIndexedProperties(node)).toBeFalsy(); + }); + + it("returns true for an array bucket node", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("Array(234)"), + }, + }); + const bucketNodes = getChildren({ + item: node, + loadedProperties: new Map([[node.path, true]]), + }); + + // Make sure we do have a bucket. + expect(bucketNodes[0].name).toBe("[0…99]"); + expect(shouldLoadItemIndexedProperties(bucketNodes[0])).toBeTruthy(); + }); + + it("returns false for an array bucket node with sub-buckets", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("Array(23456)"), + }, + }); + const bucketNodes = getChildren({ + item: node, + loadedProperties: new Map([[node.path, true]]), + }); + + // Make sure we do have a bucket. + expect(bucketNodes[0].name).toBe("[0…999]"); + expect(shouldLoadItemIndexedProperties(bucketNodes[0])).toBeFalsy(); + }); + + it("returns true for an array sub-bucket node", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("Array(23456)"), + }, + }); + const bucketNodes = getChildren({ + item: node, + loadedProperties: new Map([[node.path, true]]), + }); + // Make sure we do have a bucket. + expect(bucketNodes[0].name).toBe("[0…999]"); + + // Get the sub-buckets + const subBucketNodes = getChildren({ + item: bucketNodes[0], + loadedProperties: new Map([[bucketNodes[0].path, true]]), + }); + // Make sure we do have a bucket. + expect(subBucketNodes[0].name).toBe("[0…99]"); + expect(shouldLoadItemIndexedProperties(subBucketNodes[0])).toBeTruthy(); + }); + + it("returns false for an entries node", () => { + const mapStubNode = createNode({ + name: "map", + contents: { + value: gripMapStubs.get("20-entries Map"), + }, + }); + const entriesNode = makeNodesForEntries(mapStubNode); + expect(shouldLoadItemIndexedProperties(entriesNode)).toBeFalsy(); + }); + + it("returns true for an Object", () => { + const node = createNode({ + name: "root", + contents: { + value: gripStubs.get("testMaxProps"), + }, + }); + expect(shouldLoadItemIndexedProperties(node)).toBeTruthy(); + }); + + it("returns true for a Map", () => { + const node = createNode({ + name: "root", + contents: { + value: gripMapStubs.get("20-entries Map"), + }, + }); + expect(shouldLoadItemIndexedProperties(node)).toBeTruthy(); + }); + + it("returns true for a Set", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("new Set([1,2,3,4])"), + }, + }); + expect(shouldLoadItemIndexedProperties(node)).toBeTruthy(); + }); + + it("returns true for a Window", () => { + const node = createNode({ + name: "root", + contents: { + value: windowStubs.get("Window")._grip, + }, + }); + expect(shouldLoadItemIndexedProperties(node)).toBeTruthy(); + }); + + it("returns false for a <default properties> node", () => { + const windowNode = createNode({ + name: "root", + contents: { + value: windowStubs.get("Window")._grip, + }, + }); + const loadedProperties = new Map([ + [ + windowNode.path, + { + ownProperties: { + foo: { value: "bar" }, + location: { value: "a" }, + }, + }, + ], + ]); + const [, defaultPropertiesNode] = getChildren({ + item: windowNode, + loadedProperties, + }); + expect(nodeIsDefaultProperties(defaultPropertiesNode)).toBe(true); + expect(shouldLoadItemIndexedProperties(defaultPropertiesNode)).toBeFalsy(); + }); + + it("returns false for a MapEntry node", () => { + const node = createGripMapEntry("key", "value"); + expect(shouldLoadItemIndexedProperties(node)).toBeFalsy(); + }); + + it("returns false for a Proxy node", () => { + const node = createNode({ + name: "root", + contents: { + value: gripStubs.get("testProxy"), + }, + }); + expect(shouldLoadItemIndexedProperties(node)).toBeFalsy(); + }); + + it("returns true for a Proxy target node", () => { + const proxyNode = createNode({ + name: "root", + contents: { + value: gripStubs.get("testProxy"), + }, + }); + const loadedProperties = new Map([ + [proxyNode.path, gripStubs.get("testProxySlots")], + ]); + const [targetNode] = getChildren({ item: proxyNode, loadedProperties }); + // Make sure we have the target node. + expect(targetNode.name).toBe("<target>"); + expect(shouldLoadItemIndexedProperties(targetNode)).toBeTruthy(); + }); + + it("returns false for an accessor node", () => { + const accessorNode = createNode({ + name: "root", + contents: { + value: accessorStubs.get("getter"), + }, + }); + expect(shouldLoadItemIndexedProperties(accessorNode)).toBeFalsy(); + }); + + it("returns true for an accessor <get> node", () => { + const getNode = createGetterNode({ + name: "root", + property: accessorStubs.get("getter"), + }); + expect(getNode.name).toBe("<get root()>"); + expect(shouldLoadItemIndexedProperties(getNode)).toBeTruthy(); + }); + + it("returns true for an accessor <set> node", () => { + const setNode = createSetterNode({ + name: "root", + property: accessorStubs.get("setter"), + }); + expect(setNode.name).toBe("<set root()>"); + expect(shouldLoadItemIndexedProperties(setNode)).toBeTruthy(); + }); + + it("returns false for a primitive node", () => { + const node = createNode({ + name: "root", + contents: { value: 42 }, + }); + expect(shouldLoadItemIndexedProperties(node)).toBeFalsy(); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-non-indexed-properties.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-non-indexed-properties.test.js new file mode 100644 index 0000000000..425540eee2 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-non-indexed-properties.test.js @@ -0,0 +1,222 @@ +/* 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/>. */ + +const Utils = require("resource://devtools/client/shared/components/object-inspector/utils/index.js"); +const { + createNode, + createGetterNode, + createSetterNode, + getChildren, + makeNodesForEntries, + nodeIsDefaultProperties, +} = Utils.node; + +const { shouldLoadItemNonIndexedProperties } = Utils.loadProperties; + +const { + createGripMapEntry, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); +const accessorStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/accessor.js"); +const gripMapStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-map.js"); +const gripArrayStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); +const gripStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip.js"); +const windowStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/window.js"); + +describe("shouldLoadItemNonIndexedProperties", () => { + it("returns true for an array", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("testMaxProps"), + }, + }); + expect(shouldLoadItemNonIndexedProperties(node)).toBeTruthy(); + }); + + it("returns false for an already loaded item", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("testMaxProps"), + }, + }); + const loadedProperties = new Map([[node.path, true]]); + expect( + shouldLoadItemNonIndexedProperties(node, loadedProperties) + ).toBeFalsy(); + }); + + it("returns true for an array node with buckets", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("Array(234)"), + }, + }); + expect(shouldLoadItemNonIndexedProperties(node)).toBeTruthy(); + }); + + it("returns false for an array bucket node", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("Array(234)"), + }, + }); + const bucketNodes = getChildren({ + item: node, + loadedProperties: new Map([[node.path, true]]), + }); + + // Make sure we do have a bucket. + expect(bucketNodes[0].name).toBe("[0…99]"); + expect(shouldLoadItemNonIndexedProperties(bucketNodes[0])).toBeFalsy(); + }); + + it("returns false for an entries node", () => { + const mapStubNode = createNode({ + name: "map", + contents: { + value: gripMapStubs.get("20-entries Map"), + }, + }); + const entriesNode = makeNodesForEntries(mapStubNode); + expect(shouldLoadItemNonIndexedProperties(entriesNode)).toBeFalsy(); + }); + + it("returns true for an Object", () => { + const node = createNode({ + name: "root", + contents: { + value: gripStubs.get("testMaxProps"), + }, + }); + expect(shouldLoadItemNonIndexedProperties(node)).toBeTruthy(); + }); + + it("returns true for a Map", () => { + const node = createNode({ + name: "root", + contents: { + value: gripMapStubs.get("20-entries Map"), + }, + }); + expect(shouldLoadItemNonIndexedProperties(node)).toBeTruthy(); + }); + + it("returns true for a Set", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("new Set([1,2,3,4])"), + }, + }); + expect(shouldLoadItemNonIndexedProperties(node)).toBeTruthy(); + }); + + it("returns true for a Window", () => { + const node = createNode({ + name: "root", + contents: { + value: windowStubs.get("Window")._grip, + }, + }); + expect(shouldLoadItemNonIndexedProperties(node)).toBeTruthy(); + }); + + it("returns false for a <default properties> node", () => { + const windowNode = createNode({ + name: "root", + contents: { + value: windowStubs.get("Window")._grip, + }, + }); + const loadedProperties = new Map([ + [ + windowNode.path, + { + ownProperties: { + foo: { value: "bar" }, + location: { value: "a" }, + }, + }, + ], + ]); + const [, defaultPropertiesNode] = getChildren({ + item: windowNode, + loadedProperties, + }); + expect(nodeIsDefaultProperties(defaultPropertiesNode)).toBe(true); + expect( + shouldLoadItemNonIndexedProperties(defaultPropertiesNode) + ).toBeFalsy(); + }); + + it("returns false for a MapEntry node", () => { + const node = createGripMapEntry("key", "value"); + expect(shouldLoadItemNonIndexedProperties(node)).toBeFalsy(); + }); + + it("returns false for a Proxy node", () => { + const node = createNode({ + name: "root", + contents: { + value: gripStubs.get("testProxy"), + }, + }); + expect(shouldLoadItemNonIndexedProperties(node)).toBeFalsy(); + }); + + it("returns true for a Proxy target node", () => { + const proxyNode = createNode({ + name: "root", + contents: { + value: gripStubs.get("testProxy"), + }, + }); + const loadedProperties = new Map([ + [proxyNode.path, gripStubs.get("testProxySlots")], + ]); + const [targetNode] = getChildren({ item: proxyNode, loadedProperties }); + // Make sure we have the target node. + expect(targetNode.name).toBe("<target>"); + expect(shouldLoadItemNonIndexedProperties(targetNode)).toBeTruthy(); + }); + + it("returns false for an accessor node", () => { + const accessorNode = createNode({ + name: "root", + contents: { + value: accessorStubs.get("getter"), + }, + }); + expect(shouldLoadItemNonIndexedProperties(accessorNode)).toBeFalsy(); + }); + + it("returns true for an accessor <get> node", () => { + const getNode = createGetterNode({ + name: "root", + property: accessorStubs.get("getter"), + }); + expect(getNode.name).toBe("<get root()>"); + expect(shouldLoadItemNonIndexedProperties(getNode)).toBeTruthy(); + }); + + it("returns true for an accessor <set> node", () => { + const setNode = createSetterNode({ + name: "root", + property: accessorStubs.get("setter"), + }); + expect(setNode.name).toBe("<set root()>"); + expect(shouldLoadItemNonIndexedProperties(setNode)).toBeTruthy(); + }); + + it("returns false for a primitive node", () => { + const node = createNode({ + name: "root", + contents: { value: 42 }, + }); + expect(shouldLoadItemNonIndexedProperties(node)).toBeFalsy(); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-prototype.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-prototype.test.js new file mode 100644 index 0000000000..83d45df70c --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-prototype.test.js @@ -0,0 +1,218 @@ +/* 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/>. */ + +const Utils = require("resource://devtools/client/shared/components/object-inspector/utils/index.js"); +const { + createNode, + createGetterNode, + createSetterNode, + getChildren, + makeNodesForEntries, + nodeIsDefaultProperties, +} = Utils.node; + +const { shouldLoadItemPrototype } = Utils.loadProperties; + +const { + createGripMapEntry, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); +const accessorStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/accessor.js"); +const gripMapStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-map.js"); +const gripArrayStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); +const gripStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip.js"); +const windowStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/window.js"); + +describe("shouldLoadItemPrototype", () => { + it("returns true for an array", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("testMaxProps"), + }, + }); + expect(shouldLoadItemPrototype(node)).toBeTruthy(); + }); + + it("returns false for an already loaded item", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("testMaxProps"), + }, + }); + const loadedProperties = new Map([[node.path, true]]); + expect(shouldLoadItemPrototype(node, loadedProperties)).toBeFalsy(); + }); + + it("returns true for an array node with buckets", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("Array(234)"), + }, + }); + expect(shouldLoadItemPrototype(node)).toBeTruthy(); + }); + + it("returns false for an array bucket node", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("Array(234)"), + }, + }); + const bucketNodes = getChildren({ + item: node, + loadedProperties: new Map([[node.path, true]]), + }); + + // Make sure we do have a bucket. + expect(bucketNodes[0].name).toBe("[0…99]"); + expect(shouldLoadItemPrototype(bucketNodes[0])).toBeFalsy(); + }); + + it("returns false for an entries node", () => { + const mapStubNode = createNode({ + name: "map", + contents: { + value: gripMapStubs.get("20-entries Map"), + }, + }); + const entriesNode = makeNodesForEntries(mapStubNode); + expect(shouldLoadItemPrototype(entriesNode)).toBeFalsy(); + }); + + it("returns true for an Object", () => { + const node = createNode({ + name: "root", + contents: { + value: gripStubs.get("testMaxProps"), + }, + }); + expect(shouldLoadItemPrototype(node)).toBeTruthy(); + }); + + it("returns true for a Map", () => { + const node = createNode({ + name: "root", + contents: { + value: gripMapStubs.get("20-entries Map"), + }, + }); + expect(shouldLoadItemPrototype(node)).toBeTruthy(); + }); + + it("returns true for a Set", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("new Set([1,2,3,4])"), + }, + }); + expect(shouldLoadItemPrototype(node)).toBeTruthy(); + }); + + it("returns true for a Window", () => { + const node = createNode({ + name: "root", + contents: { + value: windowStubs.get("Window")._grip, + }, + }); + expect(shouldLoadItemPrototype(node)).toBeTruthy(); + }); + + it("returns false for a <default properties> node", () => { + const windowNode = createNode({ + name: "root", + contents: { + value: windowStubs.get("Window")._grip, + }, + }); + const loadedProperties = new Map([ + [ + windowNode.path, + { + ownProperties: { + foo: { value: "bar" }, + location: { value: "a" }, + }, + }, + ], + ]); + const [, defaultPropertiesNode] = getChildren({ + item: windowNode, + loadedProperties, + }); + expect(nodeIsDefaultProperties(defaultPropertiesNode)).toBe(true); + expect(shouldLoadItemPrototype(defaultPropertiesNode)).toBeFalsy(); + }); + + it("returns false for a MapEntry node", () => { + const node = createGripMapEntry("key", "value"); + expect(shouldLoadItemPrototype(node)).toBeFalsy(); + }); + + it("returns false for a Proxy node", () => { + const node = createNode({ + name: "root", + contents: { + value: gripStubs.get("testProxy"), + }, + }); + expect(shouldLoadItemPrototype(node)).toBeFalsy(); + }); + + it("returns true for a Proxy target node", () => { + const proxyNode = createNode({ + name: "root", + contents: { + value: gripStubs.get("testProxy"), + }, + }); + const loadedProperties = new Map([ + [proxyNode.path, gripStubs.get("testProxySlots")], + ]); + const [targetNode] = getChildren({ item: proxyNode, loadedProperties }); + // Make sure we have the target node. + expect(targetNode.name).toBe("<target>"); + expect(shouldLoadItemPrototype(targetNode)).toBeTruthy(); + }); + + it("returns false for an accessor node", () => { + const accessorNode = createNode({ + name: "root", + contents: { + value: accessorStubs.get("getter"), + }, + }); + expect(shouldLoadItemPrototype(accessorNode)).toBeFalsy(); + }); + + it("returns true for an accessor <get> node", () => { + const getNode = createGetterNode({ + name: "root", + property: accessorStubs.get("getter"), + }); + expect(getNode.name).toBe("<get root()>"); + expect(shouldLoadItemPrototype(getNode)).toBeTruthy(); + }); + + it("returns true for an accessor <set> node", () => { + const setNode = createSetterNode({ + name: "root", + property: accessorStubs.get("setter"), + }); + expect(setNode.name).toBe("<set root()>"); + expect(shouldLoadItemPrototype(setNode)).toBeTruthy(); + }); + + it("returns false for a primitive node", () => { + const node = createNode({ + name: "root", + contents: { value: 42 }, + }); + expect(shouldLoadItemPrototype(node)).toBeFalsy(); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-symbols.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-symbols.test.js new file mode 100644 index 0000000000..a937b9fcab --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/should-load-item-symbols.test.js @@ -0,0 +1,218 @@ +/* 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/>. */ + +const Utils = require("resource://devtools/client/shared/components/object-inspector/utils/index.js"); +const { + createNode, + createGetterNode, + createSetterNode, + getChildren, + makeNodesForEntries, + nodeIsDefaultProperties, +} = Utils.node; + +const { shouldLoadItemSymbols } = Utils.loadProperties; + +const { + createGripMapEntry, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); +const accessorStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/accessor.js"); +const gripMapStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-map.js"); +const gripArrayStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); +const gripStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip.js"); +const windowStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/window.js"); + +describe("shouldLoadItemSymbols", () => { + it("returns true for an array", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("testMaxProps"), + }, + }); + expect(shouldLoadItemSymbols(node)).toBeTruthy(); + }); + + it("returns false for an already loaded item", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("testMaxProps"), + }, + }); + const loadedProperties = new Map([[node.path, true]]); + expect(shouldLoadItemSymbols(node, loadedProperties)).toBeFalsy(); + }); + + it("returns true for an array node with buckets", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("Array(234)"), + }, + }); + expect(shouldLoadItemSymbols(node)).toBeTruthy(); + }); + + it("returns false for an array bucket node", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("Array(234)"), + }, + }); + const bucketNodes = getChildren({ + item: node, + loadedProperties: new Map([[node.path, true]]), + }); + + // Make sure we do have a bucket. + expect(bucketNodes[0].name).toBe("[0…99]"); + expect(shouldLoadItemSymbols(bucketNodes[0])).toBeFalsy(); + }); + + it("returns false for an entries node", () => { + const mapStubNode = createNode({ + name: "map", + contents: { + value: gripMapStubs.get("20-entries Map"), + }, + }); + const entriesNode = makeNodesForEntries(mapStubNode); + expect(shouldLoadItemSymbols(entriesNode)).toBeFalsy(); + }); + + it("returns true for an Object", () => { + const node = createNode({ + name: "root", + contents: { + value: gripStubs.get("testMaxProps"), + }, + }); + expect(shouldLoadItemSymbols(node)).toBeTruthy(); + }); + + it("returns true for a Map", () => { + const node = createNode({ + name: "root", + contents: { + value: gripMapStubs.get("20-entries Map"), + }, + }); + expect(shouldLoadItemSymbols(node)).toBeTruthy(); + }); + + it("returns true for a Set", () => { + const node = createNode({ + name: "root", + contents: { + value: gripArrayStubs.get("new Set([1,2,3,4])"), + }, + }); + expect(shouldLoadItemSymbols(node)).toBeTruthy(); + }); + + it("returns true for a Window", () => { + const node = createNode({ + name: "root", + contents: { + value: windowStubs.get("Window")._grip, + }, + }); + expect(shouldLoadItemSymbols(node)).toBeTruthy(); + }); + + it("returns false for a <default properties> node", () => { + const windowNode = createNode({ + name: "root", + contents: { + value: windowStubs.get("Window")._grip, + }, + }); + const loadedProperties = new Map([ + [ + windowNode.path, + { + ownProperties: { + foo: { value: "bar" }, + location: { value: "a" }, + }, + }, + ], + ]); + const [, defaultPropertiesNode] = getChildren({ + item: windowNode, + loadedProperties, + }); + expect(nodeIsDefaultProperties(defaultPropertiesNode)).toBe(true); + expect(shouldLoadItemSymbols(defaultPropertiesNode)).toBeFalsy(); + }); + + it("returns false for a MapEntry node", () => { + const node = createGripMapEntry("key", "value"); + expect(shouldLoadItemSymbols(node)).toBeFalsy(); + }); + + it("returns false for a Proxy node", () => { + const node = createNode({ + name: "root", + contents: { + value: gripStubs.get("testProxy"), + }, + }); + expect(shouldLoadItemSymbols(node)).toBeFalsy(); + }); + + it("returns true for a Proxy target node", () => { + const proxyNode = createNode({ + name: "root", + contents: { + value: gripStubs.get("testProxy"), + }, + }); + const loadedProperties = new Map([ + [proxyNode.path, gripStubs.get("testProxySlots")], + ]); + const [targetNode] = getChildren({ item: proxyNode, loadedProperties }); + // Make sure we have the target node. + expect(targetNode.name).toBe("<target>"); + expect(shouldLoadItemSymbols(targetNode)).toBeTruthy(); + }); + + it("returns false for an accessor node", () => { + const accessorNode = createNode({ + name: "root", + contents: { + value: accessorStubs.get("getter"), + }, + }); + expect(shouldLoadItemSymbols(accessorNode)).toBeFalsy(); + }); + + it("returns true for an accessor <get> node", () => { + const getNode = createGetterNode({ + name: "root", + property: accessorStubs.get("getter"), + }); + expect(getNode.name).toBe("<get root()>"); + expect(shouldLoadItemSymbols(getNode)).toBeTruthy(); + }); + + it("returns true for an accessor <set> node", () => { + const setNode = createSetterNode({ + name: "root", + property: accessorStubs.get("setter"), + }); + expect(setNode.name).toBe("<set root()>"); + expect(shouldLoadItemSymbols(setNode)).toBeTruthy(); + }); + + it("returns false for a primitive node", () => { + const node = createNode({ + name: "root", + contents: { value: 42 }, + }); + expect(shouldLoadItemSymbols(node)).toBeFalsy(); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/object-inspector/utils/should-render-roots-in-reps.test.js b/devtools/client/shared/components/test/node/components/object-inspector/utils/should-render-roots-in-reps.test.js new file mode 100644 index 0000000000..456326545a --- /dev/null +++ b/devtools/client/shared/components/test/node/components/object-inspector/utils/should-render-roots-in-reps.test.js @@ -0,0 +1,153 @@ +/* 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/>. */ + +const Utils = require("resource://devtools/client/shared/components/object-inspector/utils/index.js"); +const { shouldRenderRootsInReps } = Utils; + +const nullStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/null.js"); +const numberStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/number.js"); +const undefinedStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/undefined.js"); +const gripStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip.js"); +const gripArrayStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); +const symbolStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/symbol.js"); +const errorStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/error.js"); +const bigIntStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/big-int.js"); + +describe("shouldRenderRootsInReps", () => { + it("returns true for a string", () => { + expect( + shouldRenderRootsInReps([ + { + contents: { value: "Hello" }, + }, + ]) + ).toBeTruthy(); + }); + + it("returns true for an integer", () => { + expect( + shouldRenderRootsInReps([ + { + contents: { value: numberStubs.get("Int") }, + }, + ]) + ).toBeTruthy(); + }); + + it("returns false for empty roots", () => { + expect(shouldRenderRootsInReps([])).toBeFalsy(); + }); + + it("returns true for a big int", () => { + expect( + shouldRenderRootsInReps([ + { + contents: { value: bigIntStubs.get("1n") }, + }, + ]) + ).toBeTruthy(); + }); + + it("returns true for undefined", () => { + expect( + shouldRenderRootsInReps([ + { + contents: { value: undefinedStubs.get("Undefined") }, + }, + ]) + ).toBeTruthy(); + }); + + it("returns true for null", () => { + expect( + shouldRenderRootsInReps([ + { + contents: { value: nullStubs.get("Null") }, + }, + ]) + ).toBeTruthy(); + }); + + it("returns true for Symbols", () => { + expect( + shouldRenderRootsInReps([ + { + contents: { value: symbolStubs.get("Symbol") }, + }, + ]) + ).toBeTruthy(); + }); + + it("returns true for Errors when customFormat prop is true", () => { + expect( + shouldRenderRootsInReps( + [ + { + contents: { value: errorStubs.get("MultilineStackError") }, + }, + ], + { customFormat: true } + ) + ).toBeTruthy(); + }); + + it("returns false for Errors when customFormat prop is false", () => { + expect( + shouldRenderRootsInReps( + [ + { + contents: { value: errorStubs.get("MultilineStackError") }, + }, + ], + { customFormat: false } + ) + ).toBeFalsy(); + }); + + it("returns false when there are multiple primitive roots", () => { + expect( + shouldRenderRootsInReps([ + { + contents: { value: "Hello" }, + }, + { + contents: { value: 42 }, + }, + ]) + ).toBeFalsy(); + }); + + it("returns false for primitive when the root specifies a name", () => { + expect( + shouldRenderRootsInReps([ + { + name: "label", + contents: { value: 42 }, + }, + ]) + ).toBeFalsy(); + }); + + it("returns false for Grips", () => { + expect( + shouldRenderRootsInReps([ + { + name: "label", + contents: { value: gripStubs.get("testMaxProps") }, + }, + ]) + ).toBeFalsy(); + }); + + it("returns false for Arrays", () => { + expect( + shouldRenderRootsInReps([ + { + name: "label", + contents: { value: gripArrayStubs.get("testMaxProps") }, + }, + ]) + ).toBeFalsy(); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/__snapshots__/accessor.test.js.snap b/devtools/client/shared/components/test/node/components/reps/__snapshots__/accessor.test.js.snap new file mode 100644 index 0000000000..d8e298956a --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/__snapshots__/accessor.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Accessor - Invoke getter does not render an icon when the object has an evaluation 1`] = `"\\"hello\\""`; diff --git a/devtools/client/shared/components/test/node/components/reps/__snapshots__/element-node.test.js.snap b/devtools/client/shared/components/test/node/components/reps/__snapshots__/element-node.test.js.snap new file mode 100644 index 0000000000..077bc6cc71 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/__snapshots__/element-node.test.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ElementNode - Node with spaces in the class name renders with expected text content 1`] = ` +<span + className="objectBox objectBox-node" + data-link-actor-id="server1.conn3.child1/obj59" +> + <span + className="angleBracket" + > + < + </span> + <span + className="tag-name" + > + body + </span> + + <span> + <span + className="attrName" + > + class + </span> + <span + className="attrEqual" + > + = + </span> + <span + className="objectBox objectBox-string attrValue" + > + "a b c" + </span> + </span> + <span + className="angleBracket" + > + > + </span> +</span> +`; diff --git a/devtools/client/shared/components/test/node/components/reps/__snapshots__/error.test.js.snap b/devtools/client/shared/components/test/node/components/reps/__snapshots__/error.test.js.snap new file mode 100644 index 0000000000..2dc830576f --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/__snapshots__/error.test.js.snap @@ -0,0 +1,1225 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Error - Error with V8-like stack renders with expected text 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj1020" + title={null} +> + Error: + <span + className="objectBox objectBox-string" + > + BOOM + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + getAccount + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + http://moz.com/script.js:1:2 + </span> + + + </span> +</span> +`; + +exports[`Error - Error with invalid stack renders with expected text 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj1020" + title={null} +> + Error: + <span + className="objectBox objectBox-string" + > + bad stack + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + /> +</span> +`; + +exports[`Error - Error with stack having frames with multiple @ renders with expected text for Error object 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj1021" + title={null} +> + Error: + <span + className="objectBox objectBox-string" + > + bar + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + errorBar + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + https://example.com/turbo/from-npm.js@0.8.26/dist/from-npm.js:814:31 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn1" + > + errorFoo + </span> + + <span + className="objectBox-stackTrace-location" + key="location1" + > + https://example.com/turbo/from-npm.js@0.8.26/dist/from-npm.js:815:31 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn2" + > + <anonymous> + </span> + + <span + className="objectBox-stackTrace-location" + key="location2" + > + https://example.com/turbo/from-npm.js@0.8.26/dist/from-npm.js:816:31 + </span> + + + </span> +</span> +`; + +exports[`Error - Error with undefined-grip message renders with expected text 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server0.conn0.child1/obj88" + title={null} +> + Error: + <span + className="objectBox objectBox-undefined" + title={null} + > + undefined + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + <anonymous> + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + debugger eval code:16:13 + </span> + + + </span> +</span> +`; + +exports[`Error - Error with undefined-grip message renders with expected text 2`] = ` +<span + className="objectBox-stackTrace " + data-link-actor-id="server0.conn0.child1/obj88" + title={null} +> + <span + className="objectTitle" + key="title" + > + Error + </span> +</span> +`; + +exports[`Error - Error with undefined-grip name renders with expected text 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server0.conn0.child1/obj88" + title={null} +> + Error: + <span + className="objectBox objectBox-string" + > + too much recursion + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + <anonymous> + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + debugger eval code:16:13 + </span> + + + </span> +</span> +`; + +exports[`Error - Error with undefined-grip name renders with expected text 2`] = ` +<span + className="objectBox-stackTrace " + data-link-actor-id="server0.conn0.child1/obj88" + title={null} +> + <span + className="objectTitle" + key="title" + > + Error + </span> +</span> +`; + +exports[`Error - Error with undefined-grip stack renders with expected text 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server0.conn0.child1/obj88" + title={null} +> + InternalError: + <span + className="objectBox objectBox-string" + > + too much recursion + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + /> +</span> +`; + +exports[`Error - Eval error renders with expected text for an EvalError 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj1022" + title={null} +> + EvalError: + <span + className="objectBox objectBox-string" + > + EvalError message + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + <anonymous> + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + debugger eval code:10:13 + </span> + + + </span> +</span> +`; + +exports[`Error - Internal error renders with expected text for an InternalError 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj1023" + title={null} +> + InternalError: + <span + className="objectBox objectBox-string" + > + InternalError message + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + <anonymous> + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + debugger eval code:11:13 + </span> + + + </span> +</span> +`; + +exports[`Error - Multi line stack error renders with expected text for Error object 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj1021" + title={null} +> + Error: + <span + className="objectBox objectBox-string" + > + bar + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + errorBar + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + debugger eval code:6:15 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn1" + > + errorFoo + </span> + + <span + className="objectBox-stackTrace-location" + key="location1" + > + debugger eval code:3:3 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn2" + > + <anonymous> + </span> + + <span + className="objectBox-stackTrace-location" + key="location2" + > + debugger eval code:8:1 + </span> + + + </span> +</span> +`; + +exports[`Error - Range error renders with expected text for RangeError 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj1024" + title={null} +> + RangeError: + <span + className="objectBox objectBox-string" + > + RangeError message + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + <anonymous> + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + debugger eval code:12:13 + </span> + + + </span> +</span> +`; + +exports[`Error - Reference error renders with expected text for ReferenceError 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj1025" + title={null} +> + ReferenceError: + <span + className="objectBox objectBox-string" + > + ReferenceError message + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + <anonymous> + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + debugger eval code:13:13 + </span> + + + </span> +</span> +`; + +exports[`Error - Simple error renders with error type and preview message when in short mode 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj1021" + title={null} +> + Error: + <span + className="objectBox objectBox-string" + > + bar + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + errorBar + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + debugger eval code:6:15 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn1" + > + errorFoo + </span> + + <span + className="objectBox-stackTrace-location" + key="location1" + > + debugger eval code:3:3 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn2" + > + <anonymous> + </span> + + <span + className="objectBox-stackTrace-location" + key="location2" + > + debugger eval code:8:1 + </span> + + + </span> +</span> +`; + +exports[`Error - Simple error renders with error type only when customFormat prop isn't set 1`] = ` +<span + className="objectBox-stackTrace " + data-link-actor-id="server1.conn1.child1/obj1021" + title={null} +> + <span + className="objectTitle" + key="title" + > + Error: + </span> + <span + className="objectBox objectBox-string" + > + bar + </span> +</span> +`; + +exports[`Error - Simple error renders with error type only when depth is > 0 1`] = ` +<span + className="objectBox-stackTrace " + data-link-actor-id="server1.conn1.child1/obj1021" + title={null} +> + <span + className="objectTitle" + key="title" + > + Error: + </span> + <span + className="objectBox objectBox-string" + > + bar + </span> +</span> +`; + +exports[`Error - Simple error renders with expected text for simple error 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj1020" + title="Error: \\"Error message\\"" +> + Error: + <span + className="objectBox objectBox-string" + title="Error message" + > + Error message + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + <anonymous> + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + debugger eval code:1:13 + </span> + + + </span> +</span> +`; + +exports[`Error - Syntax error renders with expected text for SyntaxError 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj1026" + title={null} +> + SyntaxError: + <span + className="objectBox objectBox-string" + > + SyntaxError message + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + <anonymous> + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + debugger eval code:14:13 + </span> + + + </span> +</span> +`; + +exports[`Error - Type error renders with expected text for TypeError 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj1027" + title={null} +> + TypeError: + <span + className="objectBox objectBox-string" + > + TypeError message + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + <anonymous> + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + debugger eval code:15:13 + </span> + + + </span> +</span> +`; + +exports[`Error - URI error renders with expected text for URIError 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj1028" + title={null} +> + URIError: + <span + className="objectBox objectBox-string" + > + URIError message + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + <anonymous> + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + debugger eval code:16:13 + </span> + + + </span> +</span> +`; + +exports[`Error - base-loader.sys.mjs renders as expected in HEADER mode 1`] = ` +<span + className="objectBox-stackTrace " + data-link-actor-id="server1.conn1.child1/obj1020" + title={null} +> + <span + className="objectTitle" + key="title" + > + Error + </span> +</span> +`; + +exports[`Error - base-loader.sys.mjs renders as expected in tiny mode 1`] = ` +<span + className="objectBox-stackTrace " + data-link-actor-id="server1.conn1.child1/obj1020" + title={null} +> + <span + className="objectTitle" + key="title" + > + Error + </span> +</span> +`; + +exports[`Error - base-loader.sys.mjs renders as expected without mode 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj1020" + title={null} +> + Error: + <span + className="objectBox objectBox-string" + > + Error message + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + onPacket + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + resource://devtools/client/debugger-client.js:856:9 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn1" + > + send + </span> + + <span + className="objectBox-stackTrace-location" + key="location1" + > + resource://devtools/shared/transport/transport.js:569:13 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn2" + > + makeInfallible + </span> + + <span + className="objectBox-stackTrace-location" + key="location2" + > + resource://devtools/shared/ThreadSafeDevToolsUtils.js:109:14 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn3" + > + makeInfallible + </span> + + <span + className="objectBox-stackTrace-location" + key="location3" + > + resource://devtools/shared/ThreadSafeDevToolsUtils.js:109:14 + </span> + + + </span> +</span> +`; + +exports[`Error - longString stacktrace - cut-off location renders as expected 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj33" + title={null} +> + InternalError: + <span + className="objectBox objectBox-string" + > + too much recursion + </span> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + doStuff + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:32:1 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn1" + > + doStuff + </span> + + <span + className="objectBox-stackTrace-location" + key="location1" + > + https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn2" + > + doStuff + </span> + + <span + className="objectBox-stackTrace-location" + key="location2" + > + https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn3" + > + doStuff + </span> + + <span + className="objectBox-stackTrace-location" + key="location3" + > + https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn4" + > + doStuff + </span> + + <span + className="objectBox-stackTrace-location" + key="location4" + > + https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn5" + > + doStuff + </span> + + <span + className="objectBox-stackTrace-location" + key="location5" + > + https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn6" + > + doStuff + </span> + + <span + className="objectBox-stackTrace-location" + key="location6" + > + https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21 + </span> + + + </span> +</span> +`; + +exports[`Error - longString stacktrace renders as expected 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn2.child1/obj33" + title={null} +> + Error: + <span + className="objectBox objectBox-string" + /> + <span + className="objectBox-stackTrace-grid" + key="stack" + > + + <span + className="objectBox-stackTrace-fn" + key="fn0" + > + ngOnChanges + </span> + + <span + className="objectBox-stackTrace-location" + key="location0" + > + webpack-internal:///./node_modules/@angular/common/esm5/common.js:2656:27 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn1" + > + checkAndUpdateDirectiveInline + </span> + + <span + className="objectBox-stackTrace-location" + key="location1" + > + webpack-internal:///./node_modules/@angular/core/esm5/core.js:12581:9 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn2" + > + checkAndUpdateNodeInline + </span> + + <span + className="objectBox-stackTrace-location" + key="location2" + > + webpack-internal:///./node_modules/@angular/core/esm5/core.js:14109:20 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn3" + > + checkAndUpdateNode + </span> + + <span + className="objectBox-stackTrace-location" + key="location3" + > + webpack-internal:///./node_modules/@angular/core/esm5/core.js:14052:16 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn4" + > + debugCheckAndUpdateNode + </span> + + <span + className="objectBox-stackTrace-location" + key="location4" + > + webpack-internal:///./node_modules/@angular/core/esm5/core.js:14945:55 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn5" + > + debugCheckDirectivesFn + </span> + + <span + className="objectBox-stackTrace-location" + key="location5" + > + webpack-internal:///./node_modules/@angular/core/esm5/core.js:14886:13 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn6" + > + View_MetaTableComponent_6 + </span> + + <span + className="objectBox-stackTrace-location" + key="location6" + > + ng:///AppModule/MetaTableComponent.ngfactory.js:98:5 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn7" + > + debugUpdateDirectives + </span> + + <span + className="objectBox-stackTrace-location" + key="location7" + > + webpack-internal:///./node_modules/@angular/core/esm5/core.js:14871:12 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn8" + > + checkAndUpdateView + </span> + + <span + className="objectBox-stackTrace-location" + key="location8" + > + webpack-internal:///./node_modules/@angular/core/esm5/core.js:14018:5 + </span> + + + + <span + className="objectBox-stackTrace-fn" + key="fn9" + > + callViewAction + </span> + + <span + className="objectBox-stackTrace-location" + key="location9" + > + webpack-internal:///./node_modules/@angular/core/esm5/core.js:14369:21 + </span> + + + </span> +</span> +`; + +exports[`Error - renderStacktrace prop uses renderStacktrace prop when provided 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj1021" + title={null} +> + Error: + <span + className="objectBox objectBox-string" + > + bar + </span> + <li + className="frame" + > + Function errorBar called from debugger eval code:6:15 + + </li> + <li + className="frame" + > + Function errorFoo called from debugger eval code:3:3 + + </li> + <li + className="frame" + > + Function <anonymous> called from debugger eval code:8:1 + + </li> +</span> +`; + +exports[`Error - renderStacktrace prop uses renderStacktrace with longString errors too 1`] = ` +<span + className="objectBox-stackTrace reps-custom-format" + data-link-actor-id="server1.conn1.child1/obj33" + title={null} +> + InternalError: + <span + className="objectBox objectBox-string" + > + too much recursion + </span> + <li + className="frame" + > + Function execute/AppComponent</AppComponent.prototype.doStuff called from https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:32:1 + + </li> + <li + className="frame" + > + Function execute/AppComponent</AppComponent.prototype.doStuff called from https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21 + + </li> + <li + className="frame" + > + Function execute/AppComponent</AppComponent.prototype.doStuff called from https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21 + + </li> + <li + className="frame" + > + Function execute/AppComponent</AppComponent.prototype.doStuff called from https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21 + + </li> + <li + className="frame" + > + Function execute/AppComponent</AppComponent.prototype.doStuff called from https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21 + + </li> + <li + className="frame" + > + Function execute/AppComponent</AppComponent.prototype.doStuff called from https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21 + + </li> + <li + className="frame" + > + Function execute/AppComponent</AppComponent.prototype.doStuff called from https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21 + + </li> +</span> +`; diff --git a/devtools/client/shared/components/test/node/components/reps/__snapshots__/nan.test.js.snap b/devtools/client/shared/components/test/node/components/reps/__snapshots__/nan.test.js.snap new file mode 100644 index 0000000000..c80b14a2fb --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/__snapshots__/nan.test.js.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NaN renders NaN Rep as expected 1`] = ` +<span + className="objectBox objectBox-nan" + title={null} +> + NaN +</span> +`; diff --git a/devtools/client/shared/components/test/node/components/reps/accessible.test.js b/devtools/client/shared/components/test/node/components/reps/accessible.test.js new file mode 100644 index 0000000000..8df9650c36 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/accessible.test.js @@ -0,0 +1,321 @@ +/* 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"; + +/* global jest, __dirname */ +const { mount, shallow } = require("enzyme"); +const { JSDOM } = require("jsdom"); +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const { Accessible } = REPS; +const { + ELLIPSIS, +} = require("resource://devtools/client/shared/components/reps/reps/rep-utils.js"); +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/accessible.js"); + +describe("Accessible - Document", () => { + const stub = stubs.get("Document"); + + it("selects Accessible Rep", () => { + expect(getRep(stub)).toBe(Accessible.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + Accessible.rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual('"New Tab": document'); + expect(renderedComponent.prop("title")).toEqual('"New Tab": document'); + }); +}); + +describe("Accessible - ButtonMenu", () => { + const stub = stubs.get("ButtonMenu"); + + it("selects Accessible Rep", () => { + expect(getRep(stub)).toBe(Accessible.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + Accessible.rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual( + '"New to Nightly? Let’s get started.": buttonmenu' + ); + }); + + it("renders an inspect icon", () => { + const onInspectIconClick = jest.fn(); + const renderedComponent = shallow( + Accessible.rep({ + object: stub, + onInspectIconClick, + }) + ); + + const node = renderedComponent.find(".open-accessibility-inspector"); + node.simulate("click", { type: "click" }); + + expect(node.exists()).toBeTruthy(); + expect(onInspectIconClick.mock.calls).toHaveLength(1); + expect(onInspectIconClick.mock.calls[0][0]).toEqual(stub); + expect(onInspectIconClick.mock.calls[0][1].type).toEqual("click"); + }); + + it("calls the expected function when click is fired on Rep", () => { + const onAccessibleClick = jest.fn(); + const renderedComponent = shallow( + Accessible.rep({ + object: stub, + onAccessibleClick, + }) + ); + + renderedComponent.simulate("click"); + + expect(onAccessibleClick.mock.calls).toHaveLength(1); + }); + + it("calls the expected function when mouseout is fired on Rep", () => { + const onAccessibleMouseOut = jest.fn(); + const renderedComponent = shallow( + Accessible.rep({ + object: stub, + onAccessibleMouseOut, + }) + ); + + renderedComponent.simulate("mouseout"); + + expect(onAccessibleMouseOut.mock.calls).toHaveLength(1); + }); + + it("calls the expected function when mouseover is fired on Rep", () => { + const onAccessibleMouseOver = jest.fn(); + const renderedComponent = shallow( + Accessible.rep({ + object: stub, + onAccessibleMouseOver, + }) + ); + + renderedComponent.simulate("mouseover"); + + expect(onAccessibleMouseOver.mock.calls).toHaveLength(1); + expect(onAccessibleMouseOver.mock.calls[0][0]).toEqual(stub); + }); +}); + +describe("Accessible - No Name Accessible", () => { + const stub = stubs.get("NoName"); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + Accessible.rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("text container"); + expect(renderedComponent.prop("title")).toEqual("text container"); + expect(renderedComponent.find(".separator").exists()).toBeFalsy(); + expect(renderedComponent.find(".accessible-namer").exists()).toBeFalsy(); + }); +}); + +describe("Accessible - Disconnected accessible", () => { + const stub = stubs.get("DisconnectedAccessible"); + + it( + "renders no inspect icon when the accessible is not in the Accessible " + + "tree", + () => { + const onInspectIconClick = jest.fn(); + const renderedComponent = shallow( + Accessible.rep({ + object: stub, + onInspectIconClick, + }) + ); + + expect( + renderedComponent.find(".open-accessibility-inspector").exists() + ).toBeFalsy(); + } + ); +}); + +describe("Accessible - No Preview (not a valid grip)", () => { + const stub = stubs.get("NoPreview"); + + it("does not select Accessible Rep", () => { + expect(getRep(stub)).not.toBe(Accessible.rep); + }); +}); + +describe("Accessible - Accessible with long name", () => { + const stub = stubs.get("AccessibleWithLongName"); + + it("selects ElementNode Rep", () => { + expect(getRep(stub)).toBe(Accessible.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + Accessible.rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual( + `"${"a".repeat(1000)}": text leaf` + ); + }); + + it("renders with expected text content with name max length", () => { + const renderedComponent = shallow( + Accessible.rep({ + object: stub, + nameMaxLength: 20, + }) + ); + + expect(renderedComponent.text()).toEqual( + `"${"a".repeat(9)}${ELLIPSIS}${"a".repeat(8)}": text leaf` + ); + }); +}); + +describe("Accessible - Inspect icon title", () => { + const stub = stubs.get("PushButton"); + + it("renders with expected title", () => { + const inspectIconTitle = "inspect icon title"; + + const renderedComponent = shallow( + Accessible.rep({ + inspectIconTitle, + object: stub, + onInspectIconClick: jest.fn(), + }) + ); + + const iconNode = renderedComponent.find(".open-accessibility-inspector"); + expect(iconNode.prop("title")).toEqual(inspectIconTitle); + }); +}); + +describe("Accessible - Separator text", () => { + const stub = stubs.get("PushButton"); + + it("renders with expected title", () => { + const separatorText = " - "; + + const renderedComponent = shallow( + Accessible.rep({ + separatorText, + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual('"Search" - pushbutton'); + }); +}); + +describe("Accessible - Role first", () => { + const stub = stubs.get("PushButton"); + + it("renders with expected title", () => { + const renderedComponent = shallow( + Accessible.rep({ + roleFirst: true, + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual('pushbutton: "Search"'); + }); +}); + +describe("Accessible - Cursor style", () => { + const stub = stubs.get("PushButton"); + + it("renders with styled cursor", async () => { + const window = await createWindowForCursorTest(); + const attachTo = window.document.querySelector("#attach-to"); + const renderedComponent = mount( + Accessible.rep({ + object: stub, + onAccessibleClick: jest.fn(), + onInspectIconClick: jest.fn(), + }), + { + attachTo, + } + ); + + const objectNode = renderedComponent.getDOMNode(); + const iconNode = objectNode.querySelector(".open-accessibility-inspector"); + expect(renderedComponent.hasClass("clickable")).toBeTruthy(); + expect(window.getComputedStyle(objectNode).cursor).toEqual("pointer"); + expect(window.getComputedStyle(iconNode).cursor).toEqual("pointer"); + }); + + it("renders with unstyled cursor", async () => { + const window = await createWindowForCursorTest(); + const attachTo = window.document.querySelector("#attach-to"); + const renderedComponent = mount( + Accessible.rep({ + object: stub, + }), + { + attachTo, + } + ); + + const objectNode = renderedComponent.getDOMNode(); + expect(renderedComponent.hasClass("clickable")).toBeFalsy(); + expect(window.getComputedStyle(objectNode).cursor).toEqual(""); + }); +}); + +async function createWindowForCursorTest() { + const path = require("path"); + const css = await readTextFile( + path.resolve(__dirname, "../../../../reps/", "reps.css") + ); + const html = ` + <body> + <style>${css}</style> + <div id="attach-to"></div> + </body> + `; + + return new JSDOM(html).window; +} + +async function readTextFile(fileName) { + return new Promise((resolve, reject) => { + const fs = require("fs"); + fs.readFile(fileName, "utf8", (error, text) => { + if (error) { + reject(error); + } else { + resolve(text); + } + }); + }); +} diff --git a/devtools/client/shared/components/test/node/components/reps/accessor.test.js b/devtools/client/shared/components/test/node/components/reps/accessor.test.js new file mode 100644 index 0000000000..31499fea52 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/accessor.test.js @@ -0,0 +1,137 @@ +/* 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 { shallow } = require("enzyme"); + +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { Accessor, Rep } = REPS; + +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/accessor.js"); + +describe("Accessor - getter", () => { + const object = stubs.get("getter"); + + it("Rep correctly selects Accessor Rep", () => { + expect(getRep(object)).toBe(Accessor.rep); + }); + + it("Accessor rep has expected text content", () => { + const renderedComponent = shallow( + Rep({ object, shouldRenderTooltip: true }) + ); + expect(renderedComponent.text()).toEqual("Getter"); + expect(renderedComponent.prop("title")).toEqual("Getter"); + + const node = renderedComponent.find(".jump-definition"); + expect(node.exists()).toBeFalsy(); + }); +}); + +describe("Accessor - setter", () => { + const object = stubs.get("setter"); + + it("Rep correctly selects Accessor Rep", () => { + expect(getRep(object)).toBe(Accessor.rep); + }); + + it("Accessor rep has expected text content", () => { + const renderedComponent = shallow( + Rep({ object, shouldRenderTooltip: true }) + ); + expect(renderedComponent.text()).toEqual("Setter"); + expect(renderedComponent.prop("title")).toEqual("Setter"); + + const node = renderedComponent.find(".jump-definition"); + expect(node.exists()).toBeFalsy(); + }); +}); + +describe("Accessor - getter & setter", () => { + const object = stubs.get("getter setter"); + + it("Rep correctly selects Accessor Rep", () => { + expect(getRep(object)).toBe(Accessor.rep); + }); + + it("Accessor rep has expected text content", () => { + const renderedComponent = shallow( + Rep({ object, shouldRenderTooltip: true }) + ); + expect(renderedComponent.text()).toEqual("Getter & Setter"); + expect(renderedComponent.prop("title")).toEqual("Getter & Setter"); + + const node = renderedComponent.find(".jump-definition"); + expect(node.exists()).toBeFalsy(); + }); +}); + +describe("Accessor - Invoke getter", () => { + it("renders an icon for getter with onInvokeGetterButtonClick", () => { + const onInvokeGetterButtonClick = jest.fn(); + const object = stubs.get("getter"); + const renderedComponent = shallow( + Rep({ object, onInvokeGetterButtonClick }) + ); + + const node = renderedComponent.find(".invoke-getter"); + node.simulate("click", { + type: "click", + stopPropagation: () => {}, + }); + expect(node.prop("title")).toEqual("Invoke getter"); + expect(node.exists()).toBeTruthy(); + expect(onInvokeGetterButtonClick.mock.calls).toHaveLength(1); + }); + + it("does not render an icon for a setter only", () => { + const onInvokeGetterButtonClick = jest.fn(); + const object = stubs.get("setter"); + const renderedComponent = shallow( + Rep({ object, onInvokeGetterButtonClick }) + ); + expect(renderedComponent.text()).toEqual("Setter"); + + const node = renderedComponent.find(".jump-definition"); + expect(node.exists()).toBeFalsy(); + }); + + it("renders an icon for getter/setter with onInvokeGetterButtonClick", () => { + const onInvokeGetterButtonClick = jest.fn(); + const object = stubs.get("getter setter"); + const renderedComponent = shallow( + Rep({ object, onInvokeGetterButtonClick }) + ); + + const node = renderedComponent.find(".invoke-getter"); + node.simulate("click", { + type: "click", + stopPropagation: () => {}, + }); + + expect(node.exists()).toBeTruthy(); + expect(onInvokeGetterButtonClick.mock.calls).toHaveLength(1); + }); + + it("does not render an icon when the object has an evaluation", () => { + const onInvokeGetterButtonClick = jest.fn(); + const object = stubs.get("getter"); + const renderedComponent = shallow( + Rep({ + object, + onInvokeGetterButtonClick, + evaluation: { getterValue: "hello" }, + }) + ); + expect(renderedComponent.text()).toMatchSnapshot(); + + const node = renderedComponent.find(".invoke-getter"); + expect(node.exists()).toBeFalsy(); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/array.test.js b/devtools/client/shared/components/test/node/components/reps/array.test.js new file mode 100644 index 0000000000..cd8cfb9d97 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/array.test.js @@ -0,0 +1,117 @@ +/* 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 { shallow } = require("enzyme"); + +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const { ArrayRep, Rep } = REPS; +const { maxLengthMap } = ArrayRep; + +describe("Array", () => { + it("selects Array Rep as expected", () => { + const stub = []; + expect(getRep(stub, undefined, true)).toBe(ArrayRep.rep); + }); + + it("renders empty array as expected", () => { + const object = []; + const renderRep = props => shallow(Rep({ object, noGrip: true, ...props })); + + const defaultOutput = "[]"; + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: undefined }).prop("title")).toBe("Array"); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).prop("title")).toBe("Array"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); + + it("renders basic array as expected", () => { + const object = [1, "foo", {}]; + const renderRep = props => shallow(Rep({ object, noGrip: true, ...props })); + + const defaultOutput = '[ 1, "foo", {} ]'; + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: undefined }).prop("title")).toBe("Array"); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("[…]"); + expect(renderRep({ mode: MODE.TINY }).prop("title")).toBe("Array"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); + + it("renders array with more than SHORT mode max props as expected", () => { + const object = Array(maxLengthMap.get(MODE.SHORT) + 1).fill("foo"); + const renderRep = props => shallow(Rep({ object, noGrip: true, ...props })); + + const defaultShortOutput = `[ ${Array(maxLengthMap.get(MODE.SHORT)) + .fill('"foo"') + .join(", ")}, … ]`; + expect(renderRep({ mode: undefined }).text()).toBe(defaultShortOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("[…]"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultShortOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe( + `[ ${Array(maxLengthMap.get(MODE.SHORT) + 1) + .fill('"foo"') + .join(", ")} ]` + ); + }); + + it("renders array with more than LONG mode maximum props as expected", () => { + const object = Array(maxLengthMap.get(MODE.LONG) + 1).fill("foo"); + const renderRep = props => shallow(Rep({ object, noGrip: true, ...props })); + + const defaultShortOutput = `[ ${Array(maxLengthMap.get(MODE.SHORT)) + .fill('"foo"') + .join(", ")}, … ]`; + expect(renderRep({ mode: undefined }).text()).toBe(defaultShortOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("[…]"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultShortOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe( + `[ ${Array(maxLengthMap.get(MODE.LONG)).fill('"foo"').join(", ")}, … ]` + ); + }); + + it("renders recursive array as expected", () => { + const object = [1]; + object.push(object); + const renderRep = props => shallow(Rep({ object, noGrip: true, ...props })); + + const defaultOutput = "[ 1, […] ]"; + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: undefined }).prop("title")).toBe("Array"); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("[…]"); + expect(renderRep({ mode: MODE.TINY }).prop("title")).toBe("Array"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); + + it("renders array containing an object as expected", () => { + const object = [ + { + p1: "s1", + p2: ["a1", "a2", "a3"], + p3: "s3", + p4: "s4", + }, + ]; + const renderRep = props => shallow(Rep({ object, noGrip: true, ...props })); + + const defaultOutput = "[ {…} ]"; + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: undefined }).prop("title")).toBe("Array"); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("[…]"); + expect(renderRep({ mode: MODE.TINY }).prop("title")).toBe("Array"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/attribute.test.js b/devtools/client/shared/components/test/node/components/reps/attribute.test.js new file mode 100644 index 0000000000..c13ab59857 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/attribute.test.js @@ -0,0 +1,44 @@ +/* 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 { shallow } = require("enzyme"); + +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { + expectActorAttribute, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +const { Attribute, Rep } = REPS; + +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/attribute.js"); + +describe("Attribute", () => { + const stub = stubs.get("Attribute")._grip; + + it("Rep correctly selects Attribute Rep", () => { + expect(getRep(stub)).toBe(Attribute.rep); + }); + + it("Attribute rep has expected text content", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + expect(renderedComponent.text()).toEqual( + 'class="autocomplete-suggestions"' + ); + expect(renderedComponent.prop("title")).toBe( + 'class="autocomplete-suggestions"' + ); + expectActorAttribute(renderedComponent, stub.actor); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/big-int.test.js b/devtools/client/shared/components/test/node/components/reps/big-int.test.js new file mode 100644 index 0000000000..1fa6cd0ee1 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/big-int.test.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 { shallow } = require("enzyme"); +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const { BigInt, Rep } = REPS; +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/big-int.js"); + +describe("BigInt", () => { + describe("1n", () => { + const stub = stubs.get("1n"); + + it("correctly selects BigInt Rep for BigInt value", () => { + expect(getRep(stub)).toBe(BigInt.rep); + }); + + it("renders with expected text content for BigInt", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("1n"); + expect(renderedComponent.prop("title")).toBe("1n"); + }); + }); + + describe("-2n", () => { + const stub = stubs.get("-2n"); + + it("correctly selects BigInt Rep for negative BigInt value", () => { + expect(getRep(stub)).toBe(BigInt.rep); + }); + + it("renders with expected text content for negative BigInt", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("-2n"); + expect(renderedComponent.prop("title")).toBe("-2n"); + }); + }); + + describe("0n", () => { + const stub = stubs.get("0n"); + + it("correctly selects BigInt Rep for zero BigInt value", () => { + expect(getRep(stub)).toBe(BigInt.rep); + }); + + it("renders with expected text content for zero BigInt", () => { + const renderedComponent = shallow(Rep({ object: stub })); + expect(renderedComponent.text()).toEqual("0n"); + }); + }); + + describe("in objects", () => { + it("renders with expected text content in Array", () => { + const stub = stubs.get("[1n,-2n,0n]"); + const renderedComponent = shallow(Rep({ object: stub })); + expect(renderedComponent.text()).toEqual("Array(3) [ 1n, -2n, 0n ]"); + }); + + it("renders with expected text content in Set", () => { + const stub = stubs.get("new Set([1n,-2n,0n])"); + const renderedComponent = shallow(Rep({ object: stub })); + expect(renderedComponent.text()).toEqual("Set(3) [ 1n, -2n, 0n ]"); + }); + + it("renders with expected text content in Map", () => { + const stub = stubs.get("new Map([ [1n, -1n], [-2n, 0n], [0n, -2n]])"); + const renderedComponent = shallow(Rep({ object: stub })); + expect(renderedComponent.text()).toEqual( + "Map(3) { 1n → -1n, -2n → 0n, 0n → -2n }" + ); + }); + + it("renders with expected text content in Object", () => { + const stub = stubs.get("({simple: 1n, negative: -2n, zero: 0n})"); + const renderedComponent = shallow(Rep({ object: stub })); + expect(renderedComponent.text()).toEqual( + "Object { simple: 1n, negative: -2n, zero: 0n }" + ); + }); + + it("renders with expected text content in Promise", () => { + const stub = stubs.get("Promise.resolve(1n)"); + const renderedComponent = shallow(Rep({ object: stub })); + expect(renderedComponent.text()).toEqual( + 'Promise { <state>: "fulfilled", <value>: 1n }' + ); + }); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/comment-node.test.js b/devtools/client/shared/components/test/node/components/reps/comment-node.test.js new file mode 100644 index 0000000000..c46f7587aa --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/comment-node.test.js @@ -0,0 +1,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 { shallow } = require("enzyme"); + +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { + expectActorAttribute, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const { Rep, CommentNode } = REPS; +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/comment-node.js"); + +describe("CommentNode", () => { + const stub = stubs.get("Comment")._grip; + + it("selects CommentNode Rep correctly", () => { + expect(getRep(stub)).toEqual(CommentNode.rep); + }); + + it("renders with correct class names", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + }) + ); + + expect(renderedComponent.hasClass("objectBox theme-comment")).toBe(true); + }); + + it("renders with correct title tooltip", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.prop("title")).toBe( + "<!-- test\nand test\nand test\nand test\nand test\nand test\nand test -->" + ); + }); + + it("renders as expected", () => { + const object = stubs.get("Comment")._grip; + const renderRep = props => shallow(CommentNode.rep({ object, ...props })); + + let component = renderRep({ mode: undefined }); + expect(component.text()).toEqual( + "<!-- test\nand test\nand test\nan…d test\nand test\nand test -->" + ); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.TINY }); + expect(component.text()).toEqual( + "<!-- test\\nand test\\na… test\\nand test -->" + ); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.HEADER }); + expect(component.text()).toEqual( + "<!-- test\\nand test\\na… test\\nand test -->" + ); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.LONG }); + expect(component.text()).toEqual(`<!-- ${stub.preview.textContent} -->`); + expectActorAttribute(component, object.actor); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/date-time.test.js b/devtools/client/shared/components/test/node/components/reps/date-time.test.js new file mode 100644 index 0000000000..62a8557c3a --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/date-time.test.js @@ -0,0 +1,61 @@ +/* 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 { shallow } = require("enzyme"); + +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { + expectActorAttribute, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +const { DateTime, Rep } = REPS; + +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/date-time.js"); + +describe("test DateTime", () => { + const stub = stubs.get("DateTime")._grip; + + it("selects DateTime as expected", () => { + expect(getRep(stub)).toBe(DateTime.rep); + }); + + it("renders DateTime as expected", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + const expectedDate = new Date( + "Date Thu Mar 31 2016 00:17:24 GMT+0300 (EAT)" + ).toString(); + + expect(renderedComponent.text()).toEqual(`Date ${expectedDate}`); + expect(renderedComponent.prop("title")).toEqual(`Date ${expectedDate}`); + expectActorAttribute(renderedComponent, stub.actor); + }); +}); + +describe("test invalid DateTime", () => { + const stub = stubs.get("InvalidDateTime")._grip; + + it("renders expected text for invalid date", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("Invalid Date"); + expect(renderedComponent.prop("title")).toEqual("Invalid Date"); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/document-type.test.js b/devtools/client/shared/components/test/node/components/reps/document-type.test.js new file mode 100644 index 0000000000..f4709c6d5d --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/document-type.test.js @@ -0,0 +1,51 @@ +/* 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 { shallow } = require("enzyme"); +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { + expectActorAttribute, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +const { DocumentType } = REPS; +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/document-type.js"); + +describe("DocumentType", () => { + const stub = stubs.get("html"); + it("correctly selects DocumentType Rep", () => { + expect(getRep(stub)).toBe(DocumentType.rep); + }); + + it("renders with expected text content on html doctype", () => { + const renderedComponent = shallow( + DocumentType.rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("<!DOCTYPE html>"); + expect(renderedComponent.prop("title")).toEqual("<!DOCTYPE html>"); + expectActorAttribute(renderedComponent, stub.actor); + }); + + it("renders with expected text content on empty doctype", () => { + const unnamedStub = stubs.get("unnamed"); + const renderedComponent = shallow( + DocumentType.rep({ + object: unnamedStub, + shouldRenderTooltip: true, + }) + ); + expect(renderedComponent.text()).toEqual("<!DOCTYPE>"); + expect(renderedComponent.prop("title")).toEqual("<!DOCTYPE>"); + expectActorAttribute(renderedComponent, unnamedStub.actor); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/document.test.js b/devtools/client/shared/components/test/node/components/reps/document.test.js new file mode 100644 index 0000000000..36d2eced11 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/document.test.js @@ -0,0 +1,52 @@ +/* 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 { shallow } = require("enzyme"); +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { + expectActorAttribute, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +const { Document } = REPS; +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/document.js"); + +describe("Document", () => { + const stub = stubs.get("Document"); + it("correctly selects Document Rep", () => { + expect(getRep(stub)).toBe(Document.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + Document.rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual( + "HTMLDocument https://www.mozilla.org/en-US/firefox/new/" + ); + expect(renderedComponent.prop("title")).toEqual( + "HTMLDocument https://www.mozilla.org/en-US/firefox/new/" + ); + expectActorAttribute(renderedComponent, stub.actor); + }); + + it("renders location-less document with expected text content", () => { + const renderedComponent = shallow( + Document.rep({ + object: stubs.get("Location-less Document"), + }) + ); + + expect(renderedComponent.text()).toEqual("HTMLDocument"); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/element-node.test.js b/devtools/client/shared/components/test/node/components/reps/element-node.test.js new file mode 100644 index 0000000000..966965e46a --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/element-node.test.js @@ -0,0 +1,668 @@ +/* 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"; + +/* global jest, __dirname */ +const { mount, shallow } = require("enzyme"); +const { JSDOM } = require("jsdom"); +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const { + MAX_ATTRIBUTE_LENGTH, +} = require("resource://devtools/client/shared/components/reps/reps/element-node.js"); +const { ElementNode } = REPS; +const { + expectActorAttribute, + getSelectableInInspectorGrips, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); +const { + ELLIPSIS, +} = require("resource://devtools/client/shared/components/reps/reps/rep-utils.js"); +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/element-node.js"); + +describe("ElementNode - BodyNode", () => { + const stub = stubs.get("BodyNode"); + + it("selects ElementNode Rep", () => { + expect(getRep(stub)).toBe(ElementNode.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual( + '<body id="body-id" class="body-class">' + ); + expectActorAttribute(renderedComponent, stub.actor); + }); + + it("renders with expected text content in HEADER mode", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + mode: MODE.HEADER, + }) + ); + + expect(renderedComponent.text()).toEqual( + '<body id="body-id" class="body-class">' + ); + expectActorAttribute(renderedComponent, stub.actor); + }); + + it("renders with expected text content on tiny mode", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + mode: MODE.TINY, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("body#body-id.body-class"); + expect(renderedComponent.prop("title")).toEqual("body#body-id.body-class"); + expectActorAttribute(renderedComponent, stub.actor); + }); +}); + +describe("ElementNode - DocumentElement", () => { + const stub = stubs.get("DocumentElement"); + + it("selects ElementNode Rep", () => { + expect(getRep(stub)).toBe(ElementNode.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual('<html dir="ltr" lang="en-US">'); + }); + + it("renders with expected text content in tiny mode", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("html"); + }); +}); + +describe("ElementNode - Node", () => { + const stub = stubs.get("Node"); + const grips = getSelectableInInspectorGrips(stub); + + it("has one node grip", () => { + expect(grips).toHaveLength(1); + }); + + it("selects ElementNode Rep", () => { + expect(getRep(stub)).toBe(ElementNode.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual( + '<input id="newtab-customize-button" class="bar baz" dir="ltr" ' + + 'title="Customize your New Tab page" value="foo" type="button">' + ); + }); + + it("renders with expected text content in tiny mode", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual( + "input#newtab-customize-button.bar.baz" + ); + }); + + it("renders an inspect icon", () => { + const onInspectIconClick = jest.fn(); + const renderedComponent = shallow( + ElementNode.rep({ + object: stubs.get("Node"), + onInspectIconClick, + }) + ); + + const node = renderedComponent.find(".open-inspector"); + node.simulate("click", { type: "click" }); + + expect(node.exists()).toBeTruthy(); + expect(onInspectIconClick.mock.calls).toHaveLength(1); + expect(onInspectIconClick.mock.calls[0][0]).toEqual(stub); + expect(onInspectIconClick.mock.calls[0][1].type).toEqual("click"); + }); + + it("calls the expected function when click is fired on Rep", () => { + const onDOMNodeClick = jest.fn(); + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + onDOMNodeClick, + }) + ); + + renderedComponent.simulate("click"); + + expect(onDOMNodeClick.mock.calls).toHaveLength(1); + }); + + it("calls the expected function when mouseout is fired on Rep", () => { + const onDOMNodeMouseOut = jest.fn(); + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + onDOMNodeMouseOut, + }) + ); + + renderedComponent.simulate("mouseout"); + + expect(onDOMNodeMouseOut.mock.calls).toHaveLength(1); + expect(onDOMNodeMouseOut.mock.calls[0][0]).toEqual(stub); + }); + + it("calls the expected function when mouseover is fired on Rep", () => { + const onDOMNodeMouseOver = jest.fn(); + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + onDOMNodeMouseOver, + }) + ); + + renderedComponent.simulate("mouseover"); + + expect(onDOMNodeMouseOver.mock.calls).toHaveLength(1); + expect(onDOMNodeMouseOver.mock.calls[0][0]).toEqual(stub); + }); +}); + +describe("ElementNode - Leading and trailing spaces class name", () => { + const stub = stubs.get("NodeWithLeadingAndTrailingSpacesClassName"); + + it("selects ElementNode Rep", () => { + expect(getRep(stub)).toBe(ElementNode.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual( + '<body id="nightly-whatsnew" class=" html-ltr ">' + ); + }); + + it("renders with expected text content in tiny mode", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("body#nightly-whatsnew.html-ltr"); + }); +}); + +describe("ElementNode - Node with spaces in the class name", () => { + const stub = stubs.get("NodeWithSpacesInClassName"); + + it("selects ElementNode Rep", () => { + expect(getRep(stub)).toBe(ElementNode.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); + + it("renders with expected text content in tiny mode", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("body.a.b.c"); + }); +}); + +describe("ElementNode - Node without attributes", () => { + const stub = stubs.get("NodeWithoutAttributes"); + + it("selects ElementNode Rep", () => { + expect(getRep(stub)).toBe(ElementNode.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual("<p>"); + }); + + it("renders with expected text content in tiny mode", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("p"); + }); +}); + +describe("ElementNode - Node with many attributes", () => { + const stub = stubs.get("LotsOfAttributes"); + + it("selects ElementNode Rep", () => { + expect(getRep(stub)).toBe(ElementNode.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual( + '<p id="lots-of-attributes" a="" b="" c="" d="" e="" f="" g="" ' + + 'h="" i="" j="" k="" l="" m="" n="">' + ); + }); + + it("renders with expected text content in tiny mode", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("p#lots-of-attributes"); + }); +}); + +describe("ElementNode - SVG Node", () => { + const stub = stubs.get("SvgNode"); + + it("selects ElementNode Rep", () => { + expect(getRep(stub)).toBe(ElementNode.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual( + '<clipPath id="clip" class="svg-element">' + ); + }); + + it("renders with expected text content in tiny mode", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("clipPath#clip.svg-element"); + }); +}); + +describe("ElementNode - SVG Node in XHTML", () => { + const stub = stubs.get("SvgNodeInXHTML"); + + it("selects ElementNode Rep", () => { + expect(getRep(stub)).toBe(ElementNode.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual( + '<svg:circle class="svg-element" cx="0" cy="0" r="5">' + ); + }); + + it("renders with expected text content in tiny mode", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("svg:circle.svg-element"); + }); +}); + +describe("ElementNode - Disconnected node", () => { + const stub = stubs.get("DisconnectedNode"); + + it("renders no inspect icon when the node is not in the DOM tree", () => { + const onInspectIconClick = jest.fn(); + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + onInspectIconClick, + }) + ); + + expect(renderedComponent.find(".open-inspector").exists()).toBeFalsy(); + }); +}); + +describe("ElementNode - Element with longString attribute", () => { + const stub = stubs.get("NodeWithLongStringAttribute"); + + it("selects ElementNode Rep", () => { + expect(getRep(stub)).toBe(ElementNode.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual( + `<div data-test="${"a".repeat(MAX_ATTRIBUTE_LENGTH)}${ELLIPSIS}">` + ); + }); + + it("renders with expected text content in tiny mode", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("div"); + }); +}); + +describe("ElementNode - Element attribute cropping", () => { + it("renders no title attribute for short attribute", () => { + const stub = stubs.get("NodeWithSpacesInClassName"); + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + expect(renderedComponent.first().find("span.attrValue").prop("title")).toBe( + undefined + ); + }); + + it("renders partial value for long attribute", () => { + const stub = stubs.get("NodeWithLongAttribute"); + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual( + '<p data-test="aaaaaaaaaaaaaaaaaaaaaaaa…aaaaaaaaaaaaaaaaaaaaaaa">' + ); + expect(renderedComponent.first().find("span.attrValue").prop("title")).toBe( + "a".repeat(100) + ); + }); + + it("renders partial attribute for LongString", () => { + const stub = stubs.get("NodeWithLongStringAttribute"); + + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual( + '<div data-test="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…">' + ); + expect(renderedComponent.first().find("span.attrValue").prop("title")).toBe( + "a".repeat(1000) + ); + }); +}); + +describe("ElementNode - : Marker pseudo element", () => { + const stub = stubs.get("MarkerPseudoElement"); + + it("selects ElementNode Rep", () => { + expect(getRep(stub)).toBe(ElementNode.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("::marker"); + expect(renderedComponent.prop("title")).toEqual("::marker"); + }); + + it("renders with expected text content in tiny mode", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + mode: MODE.TINY, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("::marker"); + expect(renderedComponent.prop("title")).toEqual("::marker"); + }); +}); + +describe("ElementNode - : Before pseudo element", () => { + const stub = stubs.get("BeforePseudoElement"); + + it("selects ElementNode Rep", () => { + expect(getRep(stub)).toBe(ElementNode.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual("::before"); + }); + + it("renders with expected text content in tiny mode", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("::before"); + }); +}); + +describe("ElementNode - After pseudo element", () => { + const stub = stubs.get("AfterPseudoElement"); + + it("selects ElementNode Rep", () => { + expect(getRep(stub)).toBe(ElementNode.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual("::after"); + }); + + it("renders with expected text content in tiny mode", () => { + const renderedComponent = shallow( + ElementNode.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("::after"); + }); +}); + +describe("ElementNode - Inspect icon title", () => { + const stub = stubs.get("Node"); + + it("renders with expected title", () => { + const inspectIconTitle = "inspect icon title"; + + const renderedComponent = shallow( + ElementNode.rep({ + inspectIconTitle, + object: stub, + shouldRenderTooltip: true, + onInspectIconClick: jest.fn(), + }) + ); + + const iconNode = renderedComponent.find(".open-inspector"); + expect(iconNode.prop("title")).toEqual(inspectIconTitle); + }); +}); + +describe("ElementNode - Cursor style", () => { + const stub = stubs.get("Node"); + + it("renders with styled cursor", async () => { + const window = await createWindowForCursorTest(); + const attachTo = window.document.querySelector("#attach-to"); + const renderedComponent = mount( + ElementNode.rep({ + object: stub, + onDOMNodeClick: jest.fn(), + onInspectIconClick: jest.fn(), + }), + { + attachTo, + } + ); + + const objectNode = renderedComponent.getDOMNode(); + const iconNode = objectNode.querySelector(".open-inspector"); + expect(renderedComponent.hasClass("clickable")).toBeTruthy(); + expect(window.getComputedStyle(objectNode).cursor).toEqual("pointer"); + expect(window.getComputedStyle(iconNode).cursor).toEqual("pointer"); + }); + + it("renders with unstyled cursor", async () => { + const window = await createWindowForCursorTest(); + const attachTo = window.document.querySelector("#attach-to"); + const renderedComponent = mount( + ElementNode.rep({ + object: stub, + }), + { + attachTo, + } + ); + + const objectNode = renderedComponent.getDOMNode(); + expect(renderedComponent.hasClass("clickable")).toBeFalsy(); + expect(window.getComputedStyle(objectNode).cursor).toEqual(""); + }); +}); + +async function createWindowForCursorTest() { + const path = require("path"); + const css = await readTextFile( + path.resolve(__dirname, "../../../../reps/", "reps.css") + ); + const html = ` + <body> + <style>${css}</style> + <div id="attach-to"></div> + </body> + `; + + return new JSDOM(html).window; +} + +async function readTextFile(fileName) { + return new Promise((resolve, reject) => { + const fs = require("fs"); + fs.readFile(fileName, "utf8", (error, text) => { + if (error) { + reject(error); + } else { + resolve(text); + } + }); + }); +} diff --git a/devtools/client/shared/components/test/node/components/reps/error.test.js b/devtools/client/shared/components/test/node/components/reps/error.test.js new file mode 100644 index 0000000000..3486a6df44 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/error.test.js @@ -0,0 +1,869 @@ +/* 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"; + +/* global jest */ +const { shallow } = require("enzyme"); +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { + expectActorAttribute, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +const { ErrorRep } = REPS; +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/error.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +describe("Error - Simple error", () => { + // Test object = `new Error("Error message")` + const stub = stubs.get("SimpleError"); + + it("correctly selects Error Rep for Error object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text for simple error", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + shouldRenderTooltip: true, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + expect(renderedComponent.prop("title")).toBe('Error: "Error message"'); + expectActorAttribute(renderedComponent, stub.actor); + }); + + it("renders with expected text for simple error in tiny mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("Error"); + }); + + it("renders with expected text for simple error in HEADER mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.HEADER, + }) + ); + + expect(renderedComponent.text()).toEqual("Error"); + }); + + it("renders with error type and preview message when in short mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stubs.get("MultilineStackError"), + mode: MODE.SHORT, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); + + it("renders with error type only when customFormat prop isn't set", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stubs.get("MultilineStackError"), + mode: MODE.SHORT, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); + + it("renders with error type only when depth is > 0", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stubs.get("MultilineStackError"), + customFormat: true, + depth: 1, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); +}); + +describe("Error - Multi line stack error", () => { + /* + * Test object = ` + * function errorFoo() { + * errorBar(); + * } + * function errorBar() { + * console.log(new Error("bar")); + * } + * errorFoo();` + */ + const stub = stubs.get("MultilineStackError"); + + it("correctly selects the Error Rep for Error object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text for Error object", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); + + it("renders expected text for simple multiline error in tiny mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("Error"); + }); + + it("renders expected text for simple multiline error in HEADER mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.HEADER, + }) + ); + + expect(renderedComponent.text()).toEqual("Error"); + }); +}); + +describe("Error - Error without stacktrace", () => { + const stub = stubs.get("ErrorWithoutStacktrace"); + + it("correctly selects the Error Rep for Error object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text for Error object", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent.text()).toEqual("Error: Error message"); + }); + + it("renders expected text for error without stacktrace in tiny mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("Error"); + }); +}); + +describe("Error - Eval error", () => { + // Test object = `new EvalError("EvalError message")` + const stub = stubs.get("EvalError"); + + it("correctly selects the Error Rep for EvalError object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text for an EvalError", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); + + it("renders with expected text for an EvalError in tiny mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("EvalError"); + }); + + it("renders with expected text for an EvalError in HEADER mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.HEADER, + }) + ); + + expect(renderedComponent.text()).toEqual("EvalError"); + }); +}); + +describe("Error - Internal error", () => { + // Test object = `new InternalError("InternalError message")` + const stub = stubs.get("InternalError"); + + it("correctly selects the Error Rep for InternalError object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text for an InternalError", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); + + it("renders with expected text for an InternalError in tiny mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("InternalError"); + }); + + it("renders with expected text for an InternalError in HEADER mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.HEADER, + }) + ); + + expect(renderedComponent.text()).toEqual("InternalError"); + }); +}); + +describe("Error - Range error", () => { + // Test object = `new RangeError("RangeError message")` + const stub = stubs.get("RangeError"); + + it("correctly selects the Error Rep for RangeError object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text for RangeError", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); + + it("renders with expected text for RangeError in tiny mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("RangeError"); + }); + + it("renders with expected text for RangeError in HEADER mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.HEADER, + }) + ); + + expect(renderedComponent.text()).toEqual("RangeError"); + }); +}); + +describe("Error - Reference error", () => { + // Test object = `new ReferenceError("ReferenceError message"` + const stub = stubs.get("ReferenceError"); + + it("correctly selects the Error Rep for ReferenceError object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text for ReferenceError", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); + + it("renders with expected text for ReferenceError in tiny mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("ReferenceError"); + }); + + it("renders with expected text for ReferenceError in HEADER mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.HEADER, + }) + ); + + expect(renderedComponent.text()).toEqual("ReferenceError"); + }); +}); + +describe("Error - Syntax error", () => { + // Test object = `new SyntaxError("SyntaxError message"` + const stub = stubs.get("SyntaxError"); + + it("correctly selects the Error Rep for SyntaxError object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text for SyntaxError", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); + + it("renders with expected text for SyntaxError in tiny mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("SyntaxError"); + }); + + it("renders with expected text for SyntaxError in HEADER mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.HEADER, + }) + ); + + expect(renderedComponent.text()).toEqual("SyntaxError"); + }); +}); + +describe("Error - Type error", () => { + // Test object = `new TypeError("TypeError message"` + const stub = stubs.get("TypeError"); + + it("correctly selects the Error Rep for TypeError object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text for TypeError", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); + + it("renders with expected text for TypeError in tiny mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("TypeError"); + }); + + it("renders with expected text for TypeError in HEADER mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.HEADER, + }) + ); + + expect(renderedComponent.text()).toEqual("TypeError"); + }); +}); + +describe("Error - URI error", () => { + // Test object = `new URIError("URIError message")` + const stub = stubs.get("URIError"); + + it("correctly selects the Error Rep for URIError object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text for URIError", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); + + it("renders with expected text for URIError in tiny mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("URIError"); + }); + + it("renders with expected text for URIError in HEADER mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.HEADER, + }) + ); + + expect(renderedComponent.text()).toEqual("URIError"); + }); +}); + +describe("Error - DOMException", () => { + const stub = stubs.get("DOMException"); + + it("correctly selects Error Rep for Error object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text for DOMException", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent.text()).toEqual( + "DOMException: 'foo;()bar!' is not a valid selector" + ); + }); + + it("renders with expected text for DOMException in tiny mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("DOMException"); + }); + + it("renders with expected text for DOMException in HEADER mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.HEADER, + }) + ); + + expect(renderedComponent.text()).toEqual("DOMException"); + }); +}); + +describe("Error - base-loader.sys.mjs", () => { + const stub = stubs.get("base-loader Error"); + + it("renders as expected without mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); + + it("renders as expected in tiny mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); + + it("renders as expected in HEADER mode", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.HEADER, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); +}); + +describe("Error - longString stacktrace", () => { + const stub = stubs.get("longString stack Error"); + + it("renders as expected", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); +}); + +describe("Error - longString stacktrace - cut-off location", () => { + const stub = stubs.get("longString stack Error - cut-off location"); + + it("renders as expected", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); +}); + +describe("Error - stacktrace location click", () => { + it("Calls onViewSourceInDebugger with the expected arguments", () => { + const onViewSourceInDebugger = jest.fn(); + const object = stubs.get("base-loader Error"); + + const renderedComponent = shallow( + ErrorRep.rep({ + object, + onViewSourceInDebugger, + customFormat: true, + }) + ); + + const locations = renderedComponent.find(".objectBox-stackTrace-location"); + expect(locations.exists()).toBeTruthy(); + + expect(locations.first().prop("title")).toBe( + "View source in debugger → " + + "resource://devtools/client/debugger-client.js:856:9" + ); + locations.first().simulate("click", { + type: "click", + stopPropagation: () => {}, + }); + + expect(onViewSourceInDebugger.mock.calls).toHaveLength(1); + let mockCall = onViewSourceInDebugger.mock.calls[0][0]; + expect(mockCall.url).toEqual( + "resource://devtools/client/debugger-client.js" + ); + expect(mockCall.line).toEqual(856); + expect(mockCall.column).toEqual(9); + + expect(locations.last().prop("title")).toBe( + "View source in debugger → " + + "resource://devtools/shared/ThreadSafeDevToolsUtils.js:109:14" + ); + locations.last().simulate("click", { + type: "click", + stopPropagation: () => {}, + }); + + expect(onViewSourceInDebugger.mock.calls).toHaveLength(2); + mockCall = onViewSourceInDebugger.mock.calls[1][0]; + expect(mockCall.url).toEqual( + "resource://devtools/shared/ThreadSafeDevToolsUtils.js" + ); + expect(mockCall.line).toEqual(109); + expect(mockCall.column).toEqual(14); + }); + + it("Does not call onViewSourceInDebugger on excluded urls", () => { + const onViewSourceInDebugger = jest.fn(); + const object = stubs.get("URIError"); + + const renderedComponent = shallow( + ErrorRep.rep({ + object, + onViewSourceInDebugger, + customFormat: true, + }) + ); + + const locations = renderedComponent.find(".objectBox-stackTrace-location"); + expect(locations.exists()).toBeTruthy(); + expect(locations.first().prop("title")).toBe(undefined); + + locations.first().simulate("click", { + type: "click", + stopPropagation: () => {}, + }); + + expect(onViewSourceInDebugger.mock.calls).toHaveLength(0); + }); + + it("Does not throw when onViewSourceInDebugger props is not provided", () => { + const object = stubs.get("base-loader Error"); + + const renderedComponent = shallow( + ErrorRep.rep({ + object, + customFormat: true, + }) + ); + + const locations = renderedComponent.find(".objectBox-stackTrace-location"); + expect(locations.exists()).toBeTruthy(); + expect(locations.first().prop("title")).toBe(undefined); + + locations.first().simulate("click", { + type: "click", + stopPropagation: () => {}, + }); + }); +}); + +describe("Error - renderStacktrace prop", () => { + it("uses renderStacktrace prop when provided", () => { + const stub = stubs.get("MultilineStackError"); + + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + renderStacktrace: frames => { + return frames.map(frame => + dom.li( + { className: "frame" }, + `Function ${frame.functionName} called from ${frame.filename}:${frame.lineNumber}:${frame.columnNumber}\n` + ) + ); + }, + customFormat: true, + }) + ); + expect(renderedComponent).toMatchSnapshot(); + }); + + it("uses renderStacktrace with longString errors too", () => { + const stub = stubs.get("longString stack Error - cut-off location"); + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + renderStacktrace: frames => { + return frames.map(frame => + dom.li( + { className: "frame" }, + `Function ${frame.functionName} called from ${frame.filename}:${frame.lineNumber}:${frame.columnNumber}\n` + ) + ); + }, + customFormat: true, + }) + ); + expect(renderedComponent).toMatchSnapshot(); + }); +}); + +describe("Error - Error with V8-like stack", () => { + // Test object: + // x = new Error("BOOM"); + // x.stack = "Error: BOOM\ngetAccount@http://moz.com/script.js:1:2"; + const stub = stubs.get("Error with V8-like stack"); + + it("correctly selects Error Rep for Error object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + expectActorAttribute(renderedComponent, stub.actor); + }); +}); + +describe("Error - Error with invalid stack", () => { + // Test object: + // x = new Error("bad stack"); + // x.stack = "bar\nbaz\nfoo\n\n\n\n\n\n\n"; + const stub = stubs.get("Error with invalid stack"); + + it("correctly selects Error Rep for Error object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + expectActorAttribute(renderedComponent, stub.actor); + }); +}); + +describe("Error - Error with undefined-grip stack", () => { + // Test object: + // x = new Error("sd"); + // x.stack = undefined; + const stub = stubs.get("Error with undefined-grip stack"); + + it("correctly selects Error Rep for Error object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + expectActorAttribute(renderedComponent, stub.actor); + }); +}); + +describe("Error - Error with undefined-grip name", () => { + // Test object: + // x = new Error(""); + // x.name = undefined; + const stub = stubs.get("Error with undefined-grip name"); + + it("correctly selects Error Rep for Error object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + expect(renderedComponent).toMatchSnapshot(); + + const tinyRenderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(tinyRenderedComponent).toMatchSnapshot(); + }); +}); + +describe("Error - Error with undefined-grip message", () => { + // Test object: + // x = new Error(""); + // x.message = undefined; + const stub = stubs.get("Error with undefined-grip message"); + + it("correctly selects Error Rep for Error object", () => { + expect(getRep(stub)).toBe(ErrorRep.rep); + }); + + it("renders with expected text", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + expect(renderedComponent).toMatchSnapshot(); + + const tinyRenderedComponent = shallow( + ErrorRep.rep({ + object: stub, + mode: MODE.TINY, + }) + ); + + expect(tinyRenderedComponent).toMatchSnapshot(); + }); +}); + +describe("Error - Error with stack having frames with multiple @", () => { + const stub = stubs.get("Error with stack having frames with multiple @"); + + it("renders with expected text for Error object", () => { + const renderedComponent = shallow( + ErrorRep.rep({ + object: stub, + customFormat: true, + }) + ); + + expect(renderedComponent).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/event.test.js b/devtools/client/shared/components/test/node/components/reps/event.test.js new file mode 100644 index 0000000000..fe0f6b5601 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/event.test.js @@ -0,0 +1,160 @@ +/* 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"; + +/* global jest */ +const { shallow } = require("enzyme"); +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const { Event } = REPS; +const { + expectActorAttribute, + getSelectableInInspectorGrips, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/event.js"); + +describe("Event - beforeprint", () => { + const object = stubs.get("testEvent"); + + it("correctly selects Event Rep", () => { + expect(getRep(object)).toBe(Event.rep); + }); + + it("renders with expected text", () => { + const renderedComponent = shallow(Event.rep({ object })); + expect(renderedComponent.text()).toEqual( + "beforeprint { target: Window, isTrusted: true, currentTarget: Window, " + + "… }" + ); + expectActorAttribute(renderedComponent, object.actor); + }); +}); + +describe("Event - keyboard event", () => { + const object = stubs.get("testKeyboardEvent"); + + it("correctly selects Event Rep", () => { + expect(getRep(object)).toBe(Event.rep); + }); + + it("renders with expected text", () => { + const renderRep = props => shallow(Event.rep({ object, ...props })); + expect(renderRep().text()).toEqual( + 'keyup { target: body, key: "Control", charCode: 0, … }' + ); + expect(renderRep({ mode: MODE.LONG }).text()).toEqual( + 'keyup { target: body, key: "Control", charCode: 0, keyCode: 17 }' + ); + }); +}); + +describe("Event - keyboard event with modifiers", () => { + const object = stubs.get("testKeyboardEventWithModifiers"); + + it("correctly selects Event Rep", () => { + expect(getRep(object)).toBe(Event.rep); + }); + + it("renders with expected text", () => { + const renderRep = props => shallow(Event.rep({ object, ...props })); + expect(renderRep({ mode: MODE.LONG }).text()).toEqual( + 'keyup Meta-Shift { target: body, key: "M", charCode: 0, keyCode: 77 }' + ); + }); +}); + +describe("Event - message event", () => { + const object = stubs.get("testMessageEvent"); + + it("correctly selects Event Rep", () => { + expect(getRep(object)).toBe(Event.rep); + }); + + it("renders with expected text", () => { + const renderRep = props => shallow(Event.rep({ object, ...props })); + expect(renderRep().text()).toEqual( + 'message { target: Window, isTrusted: false, data: "test data", … }' + ); + expect(renderRep({ mode: MODE.LONG }).text()).toEqual( + 'message { target: Window, isTrusted: false, data: "test data", ' + + 'origin: "null", lastEventId: "", source: Window, ports: Array, ' + + "currentTarget: Window, eventPhase: 2, bubbles: false, … }" + ); + }); +}); + +describe("Event - mouse event", () => { + const object = stubs.get("testMouseEvent"); + const renderRep = props => shallow(Event.rep({ object, ...props })); + + const grips = getSelectableInInspectorGrips(object); + + it("has stub with one node grip", () => { + expect(grips).toHaveLength(1); + }); + + it("correctly selects Event Rep", () => { + expect(getRep(object)).toBe(Event.rep); + }); + + it("renders with expected text", () => { + expect(renderRep({ shouldRenderTooltip: true }).text()).toEqual( + "click { target: div#test, clientX: 62, clientY: 18, … }" + ); + expect(renderRep({ shouldRenderTooltip: true }).prop("title")).toEqual( + "click" + ); + expect( + renderRep({ mode: MODE.LONG, shouldRenderTooltip: true }).text() + ).toEqual( + "click { target: div#test, buttons: 0, clientX: 62, clientY: 18, " + + "layerX: 0, layerY: 0 }" + ); + expect( + renderRep({ mode: MODE.LONG, shouldRenderTooltip: true }).prop("title") + ).toEqual("click"); + }); + + it("renders an inspect icon", () => { + const onInspectIconClick = jest.fn(); + const renderedComponent = renderRep({ onInspectIconClick }); + + const node = renderedComponent.find(".open-inspector"); + node.simulate("click", { type: "click" }); + + expect(node.exists()).toBeTruthy(); + expect(onInspectIconClick.mock.calls).toHaveLength(1); + expect(onInspectIconClick.mock.calls[0][0]).toEqual(grips[0]); + expect(onInspectIconClick.mock.calls[0][1].type).toEqual("click"); + }); + + it("calls the expected function when mouseout is fired on Rep", () => { + const onDOMNodeMouseOut = jest.fn(); + const wrapper = renderRep({ onDOMNodeMouseOut }); + + const node = wrapper.find(".objectBox-node"); + node.simulate("mouseout"); + + expect(onDOMNodeMouseOut.mock.calls).toHaveLength(1); + expect(onDOMNodeMouseOut.mock.calls[0][0]).toEqual(grips[0]); + }); + + it("calls the expected function when mouseover is fired on Rep", () => { + const onDOMNodeMouseOver = jest.fn(); + const wrapper = renderRep({ onDOMNodeMouseOver }); + + const node = wrapper.find(".objectBox-node"); + node.simulate("mouseover"); + + expect(onDOMNodeMouseOver.mock.calls).toHaveLength(1); + expect(onDOMNodeMouseOver.mock.calls[0][0]).toEqual(grips[0]); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/failure.test.js b/devtools/client/shared/components/test/node/components/reps/failure.test.js new file mode 100644 index 0000000000..6f31ae5687 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/failure.test.js @@ -0,0 +1,66 @@ +/* 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"; + +/* global beforeAll, afterAll */ +const { shallow } = require("enzyme"); + +const { + REPS, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { Rep } = REPS; + +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/failure.js"); + +let originalConsoleError; +beforeAll(() => { + // Let's override the console.error function so we don't get an error message + // in the jest output for the expected exception. + originalConsoleError = window.console.error; + window.console.error = () => {}; +}); + +describe("test Failure", () => { + const stub = stubs.get("Failure"); + + it("Fallback rendering has expected text content", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + }) + ); + expect(renderedComponent.text()).toEqual("Invalid object"); + }); + + it("Fallback array rendering has expected text content", () => { + const renderedComponent = shallow( + Rep({ + object: { + type: "object", + class: "Array", + actor: "server1.conn0.obj337", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "ArrayLike", + length: 3, + items: [1, stub, 2], + }, + }, + }) + ); + expect(renderedComponent.text()).toEqual( + "Array(3) [ 1, Invalid object, 2 ]" + ); + }); +}); + +afterAll(() => { + // Reverting the override. + window.console.error = originalConsoleError; +}); diff --git a/devtools/client/shared/components/test/node/components/reps/function.test.js b/devtools/client/shared/components/test/node/components/reps/function.test.js new file mode 100644 index 0000000000..b9ad70dd26 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/function.test.js @@ -0,0 +1,584 @@ +/* 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"; + +/* global jest */ +const { shallow } = require("enzyme"); +const { + REPS, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const { Func } = REPS; +const { getFunctionName } = Func; +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/function.js"); +const { + expectActorAttribute, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); +const renderRep = (object, props) => { + return shallow(Func.rep({ object, ...props })); +}; + +describe("Function - Named", () => { + // Test declaration: `function testName() { let innerVar = "foo" }` + const object = stubs.get("Named"); + + it("renders named function as expected", () => { + expect( + renderRep(object, { mode: undefined, shouldRenderTooltip: true }).text() + ).toBe("function testName()"); + expect( + renderRep(object, { mode: undefined, shouldRenderTooltip: true }).prop( + "title" + ) + ).toBe("function testName()"); + expect( + renderRep( + { ...object, parameterNames: [] }, + { shouldRenderTooltip: true } + ).text() + ).toBe("function testName()"); + expect( + renderRep( + { ...object, parameterNames: [] }, + { shouldRenderTooltip: true } + ).prop("title") + ).toBe("function testName()"); + expect( + renderRep( + { ...object, parameterNames: ["a"] }, + { shouldRenderTooltip: true } + ).text() + ).toBe("function testName(a)"); + expect( + renderRep( + { ...object, parameterNames: ["a"] }, + { shouldRenderTooltip: true } + ).prop("title") + ).toBe("function testName(a)"); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { shouldRenderTooltip: true } + ).text() + ).toBe("function testName(a, b, c)"); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { shouldRenderTooltip: true } + ).prop("title") + ).toBe("function testName(a, b, c)"); + expect( + renderRep(object, { mode: MODE.TINY, shouldRenderTooltip: true }).text() + ).toBe("testName()"); + expect( + renderRep(object, { mode: MODE.TINY, shouldRenderTooltip: true }).prop( + "title" + ) + ).toBe("testName()"); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { mode: MODE.TINY, shouldRenderTooltip: true } + ).text() + ).toBe("testName(a, b, c)"); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { mode: MODE.TINY, shouldRenderTooltip: true } + ).prop("title") + ).toBe("testName(a, b, c)"); + + expectActorAttribute(renderRep(object), object.actor); + }); +}); + +describe("Function - User named", () => { + // Test declaration: `function testName() { let innerVar = "foo" }` + const object = stubs.get("UserNamed"); + + it("renders user named function as expected", () => { + expect(renderRep(object, { mode: undefined }).text()).toBe( + "function testUserName()" + ); + expect(renderRep({ ...object, parameterNames: [] }).text()).toBe( + "function testUserName()" + ); + expect(renderRep({ ...object, parameterNames: ["a"] }).text()).toBe( + "function testUserName(a)" + ); + expect( + renderRep({ ...object, parameterNames: ["a", "b", "c"] }).text() + ).toBe("function testUserName(a, b, c)"); + expect( + renderRep(object, { + mode: MODE.TINY, + }).text() + ).toBe("testUserName()"); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { + mode: MODE.TINY, + } + ).text() + ).toBe("testUserName(a, b, c)"); + }); +}); + +describe("Function - Var named", () => { + // Test declaration: `let testVarName = function() { }` + const object = stubs.get("VarNamed"); + + it("renders var named function as expected", () => { + expect(renderRep(object, { mode: undefined }).text()).toBe( + "function testVarName()" + ); + expect(renderRep({ ...object, parameterNames: [] }).text()).toBe( + "function testVarName()" + ); + expect(renderRep({ ...object, parameterNames: ["a"] }).text()).toBe( + "function testVarName(a)" + ); + expect( + renderRep({ ...object, parameterNames: ["a", "b", "c"] }).text() + ).toBe("function testVarName(a, b, c)"); + expect( + renderRep(object, { + mode: MODE.TINY, + }).text() + ).toBe("testVarName()"); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { + mode: MODE.TINY, + } + ).text() + ).toBe("testVarName(a, b, c)"); + }); +}); + +describe("Function - Anonymous", () => { + // Test declaration: `() => {}` + const object = stubs.get("Anon"); + + it("renders anonymous function as expected", () => { + expect(renderRep(object, { mode: undefined }).text()).toBe("function ()"); + expect(renderRep({ ...object, parameterNames: [] }).text()).toBe( + "function ()" + ); + expect(renderRep({ ...object, parameterNames: ["a"] }).text()).toBe( + "function (a)" + ); + expect( + renderRep({ ...object, parameterNames: ["a", "b", "c"] }).text() + ).toBe("function (a, b, c)"); + expect( + renderRep(object, { + mode: MODE.TINY, + }).text() + ).toBe("()"); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { + mode: MODE.TINY, + } + ).text() + ).toBe("(a, b, c)"); + }); +}); + +describe("Function - Long name", () => { + // eslint-disable-next-line max-len + // Test declaration: `let f = function loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong() { }` + const object = stubs.get("LongName"); + const functionName = + "looooooooooooooooooooooooooooooooooooooooooooooo" + + "oo\u2026ooooooooooooooooooooooooooooooooooooooooooooooong"; + + it("renders long name function as expected", () => { + expect(renderRep(object, { mode: undefined }).text()).toBe( + `function ${functionName}()` + ); + expect(renderRep({ ...object, parameterNames: [] }).text()).toBe( + `function ${functionName}()` + ); + expect(renderRep({ ...object, parameterNames: ["a"] }).text()).toBe( + `function ${functionName}(a)` + ); + expect( + renderRep({ ...object, parameterNames: ["a", "b", "c"] }).text() + ).toBe(`function ${functionName}(a, b, c)`); + expect( + renderRep(object, { + mode: MODE.TINY, + }).text() + ).toBe(`${functionName}()`); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { + mode: MODE.TINY, + } + ).text() + ).toBe(`${functionName}(a, b, c)`); + }); +}); + +describe("Function - Async function", () => { + const object = stubs.get("AsyncFunction"); + + it("renders async function as expected", () => { + expect(renderRep(object, { mode: undefined }).text()).toBe( + "async function waitUntil2017()" + ); + expect(renderRep(object, { mode: MODE.TINY }).text()).toBe( + "async waitUntil2017()" + ); + expect(renderRep(object, { mode: MODE.SHORT }).text()).toBe( + "async function waitUntil2017()" + ); + expect(renderRep(object, { mode: MODE.LONG }).text()).toBe( + "async function waitUntil2017()" + ); + expect(renderRep({ ...object, parameterNames: [] }).text()).toBe( + "async function waitUntil2017()" + ); + expect(renderRep({ ...object, parameterNames: ["a"] }).text()).toBe( + "async function waitUntil2017(a)" + ); + expect( + renderRep({ ...object, parameterNames: ["a", "b", "c"] }).text() + ).toBe("async function waitUntil2017(a, b, c)"); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { + mode: MODE.TINY, + } + ).text() + ).toBe("async waitUntil2017(a, b, c)"); + }); +}); + +describe("Function - Anonymous async function", () => { + const object = stubs.get("AnonAsyncFunction"); + + it("renders anonymous async function as expected", () => { + expect(renderRep(object, { mode: undefined }).text()).toBe( + "async function ()" + ); + expect(renderRep(object, { mode: MODE.TINY }).text()).toBe("async ()"); + expect(renderRep(object, { mode: MODE.SHORT }).text()).toBe( + "async function ()" + ); + expect(renderRep(object, { mode: MODE.LONG }).text()).toBe( + "async function ()" + ); + expect(renderRep({ ...object, parameterNames: [] }).text()).toBe( + "async function ()" + ); + expect(renderRep({ ...object, parameterNames: ["a"] }).text()).toBe( + "async function (a)" + ); + expect( + renderRep({ ...object, parameterNames: ["a", "b", "c"] }).text() + ).toBe("async function (a, b, c)"); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { + mode: MODE.TINY, + } + ).text() + ).toBe("async (a, b, c)"); + }); +}); + +describe("Function - Generator function", () => { + const object = stubs.get("GeneratorFunction"); + + it("renders generator function as expected", () => { + expect(renderRep(object, { mode: undefined }).text()).toBe( + "function* fib()" + ); + expect(renderRep(object, { mode: MODE.TINY }).text()).toBe("* fib()"); + expect(renderRep(object, { mode: MODE.SHORT }).text()).toBe( + "function* fib()" + ); + expect(renderRep(object, { mode: MODE.LONG }).text()).toBe( + "function* fib()" + ); + expect(renderRep({ ...object, parameterNames: [] }).text()).toBe( + "function* fib()" + ); + expect(renderRep({ ...object, parameterNames: ["a"] }).text()).toBe( + "function* fib(a)" + ); + expect( + renderRep({ ...object, parameterNames: ["a", "b", "c"] }).text() + ).toBe("function* fib(a, b, c)"); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { + mode: MODE.TINY, + } + ).text() + ).toBe("* fib(a, b, c)"); + }); +}); + +describe("Function - Anonymous generator function", () => { + const object = stubs.get("AnonGeneratorFunction"); + + it("renders anonymous generator function as expected", () => { + expect(renderRep(object, { mode: undefined }).text()).toBe("function* ()"); + expect(renderRep(object, { mode: MODE.TINY }).text()).toBe("* ()"); + expect(renderRep(object, { mode: MODE.SHORT }).text()).toBe("function* ()"); + expect(renderRep(object, { mode: MODE.LONG }).text()).toBe("function* ()"); + expect(renderRep({ ...object, parameterNames: [] }).text()).toBe( + "function* ()" + ); + expect(renderRep({ ...object, parameterNames: ["a"] }).text()).toBe( + "function* (a)" + ); + expect( + renderRep({ ...object, parameterNames: ["a", "b", "c"] }).text() + ).toBe("function* (a, b, c)"); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { + mode: MODE.TINY, + } + ).text() + ).toBe("* (a, b, c)"); + }); +}); + +describe("Function - Jump to definition", () => { + it("renders an icon when onViewSourceInDebugger props is provided", async () => { + let onViewSourceInDebugger; + const onViewSourceCalled = new Promise(resolve => { + onViewSourceInDebugger = jest.fn(resolve); + }); + const object = stubs.get("getRandom"); + const renderedComponent = renderRep(object, { + onViewSourceInDebugger, + }); + + const node = renderedComponent.find(".jump-definition"); + node.simulate("click", { + type: "click", + stopPropagation: () => {}, + }); + await onViewSourceCalled; + + expect(node.exists()).toBeTruthy(); + expect(onViewSourceInDebugger.mock.calls).toHaveLength(1); + expect(onViewSourceInDebugger.mock.calls[0][0]).toEqual(object.location); + }); + + it("calls recordTelemetryEvent when jump to definition icon clicked", () => { + const onViewSourceInDebugger = jest.fn(); + const recordTelemetryEvent = jest.fn(); + const object = stubs.get("getRandom"); + const renderedComponent = renderRep(object, { + onViewSourceInDebugger, + recordTelemetryEvent, + }); + + const node = renderedComponent.find(".jump-definition"); + node.simulate("click", { + type: "click", + stopPropagation: () => {}, + }); + + expect(node.exists()).toBeTruthy(); + expect(recordTelemetryEvent.mock.calls).toHaveLength(1); + expect(recordTelemetryEvent.mock.calls[0][0]).toEqual("jump_to_definition"); + }); + + it("no icon when onViewSourceInDebugger props not provided", () => { + const object = stubs.get("getRandom"); + const renderedComponent = renderRep(object); + + const node = renderedComponent.find(".jump-definition"); + expect(node.exists()).toBeFalsy(); + }); + + it("does not render an icon when the object has no location", () => { + const object = { + ...stubs.get("getRandom"), + location: null, + }; + + const renderedComponent = renderRep(object, { + onViewSourceInDebugger: () => {}, + }); + + const node = renderedComponent.find(".jump-definition"); + expect(node.exists()).toBeFalsy(); + }); + + it("does not render an icon when the object has no url location", () => { + const object = { + ...stubs.get("getRandom"), + }; + object.location = { ...object.location, url: null }; + const renderedComponent = renderRep(object, { + onViewSourceInDebugger: () => {}, + }); + + const node = renderedComponent.find(".jump-definition"); + expect(node.exists()).toBeFalsy(); + }); + + it("no icon when function was declared in console input", () => { + const object = stubs.get("EvaledInDebuggerFunction"); + const renderedComponent = renderRep(object, { + onViewSourceInDebugger: () => {}, + }); + + const node = renderedComponent.find(".jump-definition"); + expect(node.exists()).toBeFalsy(); + }); +}); + +describe("Function - Simplify name", () => { + const cases = { + defaultCase: [["define", "define"]], + + objectProperty: [ + ["z.foz", "foz"], + ["z.foz/baz", "baz"], + ["z.foz/baz/y.bay", "bay"], + ["outer/x.fox.bax.nx", "nx"], + ["outer/fow.baw", "baw"], + ["fromYUI._attach", "_attach"], + ["Y.ClassNameManager</getClassName", "getClassName"], + ["orion.textview.TextView</addHandler", "addHandler"], + ["this.eventPool_.createObject", "createObject"], + ], + + arrayProperty: [ + ["this.eventPool_[createObject]", "createObject"], + ["jQuery.each(^)/jQuery.fn[o]", "o"], + ["viewport[get+D]", "get+D"], + ["arr[0]", "0"], + ], + + functionProperty: [ + ["fromYUI._attach/<.", "_attach"], + ["Y.ClassNameManager<", "ClassNameManager"], + ["fromExtJS.setVisible/cb<", "cb"], + ["fromDojo.registerWin/<", "registerWin"], + ], + + annonymousProperty: [["jQuery.each(^)", "each"]], + }; + + Object.keys(cases).forEach(type => { + for (const [kase, expected] of cases[type]) { + it(`${type} - ${kase}`, () => + expect(getFunctionName({ displayName: kase })).toEqual(expected)); + } + }); +}); +describe("Function - Two properties with same displayName", () => { + const object = stubs.get("ObjectProperty"); + + it("renders object properties as expected", () => { + expect( + renderRep(object, { mode: undefined, functionName: "$" }).text() + ).toBe("function $:jQuery()"); + expect( + renderRep({ ...object, parameterNames: [] }, { functionName: "$" }).text() + ).toBe("function $:jQuery()"); + expect( + renderRep( + { ...object, parameterNames: ["a"] }, + { functionName: "$" } + ).text() + ).toBe("function $:jQuery(a)"); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { + functionName: "$", + } + ).text() + ).toBe("function $:jQuery(a, b, c)"); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { + mode: MODE.TINY, + functionName: "$", + } + ).text() + ).toBe("$:jQuery(a, b, c)"); + }); +}); + +describe("Function - Class constructor", () => { + const object = stubs.get("EmptyClass"); + + it("renders empty class as expected", () => { + expect( + renderRep(object, { mode: undefined, shouldRenderTooltip: true }).text() + ).toBe("class EmptyClass {}"); + expect( + renderRep(object, { mode: undefined, shouldRenderTooltip: true }).prop( + "title" + ) + ).toBe("class EmptyClass {}"); + }); + + it("renders empty class in MODE.TINY as expected", () => { + expect( + renderRep(object, { mode: MODE.TINY, shouldRenderTooltip: true }).text() + ).toBe("class EmptyClass"); + expect( + renderRep(object, { mode: MODE.TINY, shouldRenderTooltip: true }).prop( + "title" + ) + ).toBe("class EmptyClass"); + }); + + it("renders class with constructor as expected", () => { + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { shouldRenderTooltip: true } + ).text() + ).toBe("class EmptyClass { constructor(a, b, c) }"); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { shouldRenderTooltip: true } + ).prop("title") + ).toBe("class EmptyClass { constructor(a, b, c) }"); + }); + + it("renders class with constructor in MODE.TINY as expected", () => { + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { mode: MODE.TINY, shouldRenderTooltip: true } + ).text() + ).toBe("class EmptyClass"); + expect( + renderRep( + { ...object, parameterNames: ["a", "b", "c"] }, + { mode: MODE.TINY, shouldRenderTooltip: true } + ).prop("title") + ).toBe("class EmptyClass"); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/grip-array.test.js b/devtools/client/shared/components/test/node/components/reps/grip-array.test.js new file mode 100644 index 0000000000..b69bcb1dc5 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/grip-array.test.js @@ -0,0 +1,755 @@ +/* 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"; + +/* global jest */ +const { shallow } = require("enzyme"); +const { + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const GripArray = require("resource://devtools/client/shared/components/reps/reps/grip-array.js"); +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); +const { + expectActorAttribute, + getSelectableInInspectorGrips, + getGripLengthBubbleText, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); +const { maxLengthMap } = GripArray; + +function shallowRenderRep(object, props = {}) { + return shallow( + GripArray.rep({ + object, + ...props, + }) + ); +} + +describe("GripArray - basic", () => { + const object = stubs.get("testBasic"); + + it("correctly selects GripArray Rep", () => { + expect(getRep(object)).toBe(GripArray.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const length = getGripLengthBubbleText(object); + const defaultOutput = `Array${length} []`; + + let component = renderRep({ mode: undefined, shouldRenderTooltip: true }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe("Array"); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.TINY, shouldRenderTooltip: true }); + expect(component.text()).toBe("[]"); + expect(component.prop("title")).toBe("Array"); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.HEADER, shouldRenderTooltip: true }); + expect(component.text()).toBe("Array"); + expect(component.prop("title")).toBe("Array"); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.SHORT, shouldRenderTooltip: true }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe("Array"); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.LONG, shouldRenderTooltip: true }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe("Array"); + expectActorAttribute(component, object.actor); + }); +}); + +describe("GripArray - max props", () => { + const object = stubs.get("testMaxProps"); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + + let length = getGripLengthBubbleText(object); + const defaultOutput = `Array${length} [ 1, "foo", {} ]`; + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.TINY }); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.LONG }); + // Check the custom title with nested objects to make sure nested objects + // are not displayed with their parent's title. + expect( + renderRep({ + mode: MODE.LONG, + title: "CustomTitle", + }).text() + ).toBe(`CustomTitle${length} [ 1, "foo", {} ]`); + }); +}); + +describe("GripArray - more than short mode max props", () => { + const object = stubs.get("testMoreThanShortMaxProps"); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + let length = getGripLengthBubbleText(object); + + const shortLength = maxLengthMap.get(MODE.SHORT); + const shortContent = Array(shortLength).fill('"test string"').join(", "); + const longContent = Array(shortLength + 1) + .fill('"test string"') + .join(", "); + const defaultOutput = `Array${length} [ ${shortContent}, … ]`; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.TINY }); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.LONG }); + expect(renderRep({ mode: MODE.LONG }).text()).toBe( + `Array${length} [ ${longContent} ]` + ); + }); +}); + +describe("GripArray - more than long mode max props", () => { + const object = stubs.get("testMoreThanLongMaxProps"); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const length = getGripLengthBubbleText(object); + + const shortLength = maxLengthMap.get(MODE.SHORT); + const longLength = maxLengthMap.get(MODE.LONG); + const shortContent = Array(shortLength).fill('"test string"').join(", "); + const defaultOutput = `Array${length} [ ${shortContent}, … ]`; + const longContent = Array(longLength).fill('"test string"').join(", "); + + expect( + renderRep({ mode: undefined, shouldRenderTooltip: true }).text() + ).toBe(defaultOutput); + expect( + renderRep({ mode: undefined, shouldRenderTooltip: true }).prop("title") + ).toBe("Array"); + expect( + renderRep({ mode: MODE.TINY, shouldRenderTooltip: true }).text() + ).toBe(`${length} […]`); + expect( + renderRep({ mode: MODE.TINY, shouldRenderTooltip: true }).prop("title") + ).toBe("Array"); + expect( + renderRep({ mode: MODE.HEADER, shouldRenderTooltip: true }).text() + ).toBe(`Array${length}`); + expect( + renderRep({ mode: MODE.HEADER, shouldRenderTooltip: true }).prop("title") + ).toBe("Array"); + expect( + renderRep({ mode: MODE.SHORT, shouldRenderTooltip: true }).text() + ).toBe(defaultOutput); + expect( + renderRep({ mode: MODE.SHORT, shouldRenderTooltip: true }).prop("title") + ).toBe("Array"); + expect( + renderRep({ mode: MODE.LONG, shouldRenderTooltip: true }).text() + ).toBe(`Array${length} [ ${longContent}, … ]`); + expect( + renderRep({ mode: MODE.LONG, shouldRenderTooltip: true }).prop("title") + ).toBe("Array"); + }); +}); + +describe("GripArray - recursive array", () => { + const object = stubs.get("testRecursiveArray"); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + let length = getGripLengthBubbleText(object); + const childArrayLength = getGripLengthBubbleText(object.preview.items[0], { + mode: MODE.TINY, + }); + + const defaultOutput = `Array${length} [ ${childArrayLength} […] ]`; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.TINY }); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +describe("GripArray - preview limit", () => { + const object = stubs.get("testPreviewLimit"); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const length = getGripLengthBubbleText(object); + + const shortOutput = `Array${length} [ 0, 1, 2, … ]`; + const longOutput = `Array${length} [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, … ]`; + + expect(renderRep({ mode: undefined }).text()).toBe(shortOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(shortOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(longOutput); + }); +}); + +describe("GripArray - empty slots", () => { + it("renders an array with empty slots only as expected", () => { + const object = stubs.get("Array(5)"); + const renderRep = props => shallowRenderRep(object, props); + let length = getGripLengthBubbleText(object); + const defaultOutput = `Array${length} [ <5 empty slots> ]`; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.TINY }); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.LONG }); + const longOutput = `Array${length} [ <5 empty slots> ]`; + expect(renderRep({ mode: MODE.LONG }).text()).toBe(longOutput); + }); + + it("one empty slot at the beginning as expected", () => { + const object = stubs.get("[,1,2,3]"); + const renderRep = props => shallowRenderRep(object, props); + let length = getGripLengthBubbleText(object); + + const defaultOutput = `Array${length} [ <1 empty slot>, 1, 2, … ]`; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.TINY }); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.LONG }); + const longOutput = `Array${length} [ <1 empty slot>, 1, 2, 3 ]`; + expect(renderRep({ mode: MODE.LONG }).text()).toBe(longOutput); + }); + + it("multiple consecutive empty slots at the beginning as expected", () => { + const object = stubs.get("[,,,3,4,5]"); + const renderRep = props => shallowRenderRep(object, props); + const length = getGripLengthBubbleText(object); + + const defaultOutput = `Array${length} [ <3 empty slots>, 3, 4, … ]`; + const longOutput = `Array${length} [ <3 empty slots>, 3, 4, 5 ]`; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(longOutput); + }); + + it("one empty slot in the middle as expected", () => { + const object = stubs.get("[0,1,,3,4,5]"); + const renderRep = props => shallowRenderRep(object, props); + const length = getGripLengthBubbleText(object); + + const defaultOutput = `Array${length} [ 0, 1, <1 empty slot>, … ]`; + const longOutput = `Array${length} [ 0, 1, <1 empty slot>, 3, 4, 5 ]`; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(longOutput); + }); + + it("successive empty slots in the middle as expected", () => { + const object = stubs.get("[0,1,,,,5]"); + const renderRep = props => shallowRenderRep(object, props); + const length = getGripLengthBubbleText(object); + + const defaultOutput = `Array${length} [ 0, 1, <3 empty slots>, … ]`; + const longOutput = `Array${length} [ 0, 1, <3 empty slots>, 5 ]`; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(longOutput); + }); + + it("non successive single empty slots as expected", () => { + const object = stubs.get("[0,,2,,4,5]"); + const renderRep = props => shallowRenderRep(object, props); + const length = getGripLengthBubbleText(object); + + const defaultOutput = `Array${length} [ 0, <1 empty slot>, 2, … ]`; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe( + `Array${length} [ 0, <1 empty slot>, 2, <1 empty slot>, 4, 5 ]` + ); + }); + + it("multiple multi-slot holes as expected", () => { + const object = stubs.get("[0,,,3,,,,7,8]"); + const renderRep = props => shallowRenderRep(object, props); + const length = getGripLengthBubbleText(object); + + const defaultOutput = `Array${length} [ 0, <2 empty slots>, 3, … ]`; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe( + `Array${length} [ 0, <2 empty slots>, 3, <3 empty slots>, 7, 8 ]` + ); + }); + + it("a single slot hole at the end as expected", () => { + const object = stubs.get("[0,1,2,3,4,,]"); + const renderRep = props => shallowRenderRep(object, props); + const length = getGripLengthBubbleText(object); + + const defaultOutput = `Array${length} [ 0, 1, 2, … ]`; + const longOutput = `Array${length} [ 0, 1, 2, 3, 4, <1 empty slot> ]`; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(longOutput); + }); + + it("multiple consecutive empty slots at the end as expected", () => { + const object = stubs.get("[0,1,2,,,,]"); + const renderRep = props => shallowRenderRep(object, props); + const length = getGripLengthBubbleText(object); + + const defaultOutput = `Array${length} [ 0, 1, 2, … ]`; + const longOutput = `Array${length} [ 0, 1, 2, <3 empty slots> ]`; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(longOutput); + }); +}); + +describe("GripArray - NamedNodeMap", () => { + const object = stubs.get("testNamedNodeMap"); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + let length = getGripLengthBubbleText(object); + + const defaultOutput = + `NamedNodeMap${length} ` + + '[ class="myclass", cellpadding="7", border="3" ]'; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.TINY }); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`NamedNodeMap${length}`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe( + `NamedNodeMap${length}` + ); + + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +describe("GripArray - NodeList", () => { + const object = stubs.get("testNodeList"); + const grips = getSelectableInInspectorGrips(object); + const renderRep = props => shallowRenderRep(object, props); + let length = getGripLengthBubbleText(object); + + it("renders as expected", () => { + const defaultOutput = + `NodeList${length} [ button#btn-1.btn.btn-log, ` + + "button#btn-2.btn.btn-err, button#btn-3.btn.btn-count ]"; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.TINY }); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`NodeList${length}`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`NodeList${length}`); + + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); + + it("has 3 node grip", () => { + expect(grips).toHaveLength(3); + }); + + it("calls the expected function on mouseover", () => { + const onDOMNodeMouseOver = jest.fn(); + const wrapper = renderRep({ onDOMNodeMouseOver }); + const node = wrapper.find(".objectBox-node"); + + node.at(0).simulate("mouseover"); + node.at(1).simulate("mouseover"); + node.at(2).simulate("mouseover"); + + expect(onDOMNodeMouseOver.mock.calls).toHaveLength(3); + expect(onDOMNodeMouseOver.mock.calls[0][0]).toBe(grips[0]); + expect(onDOMNodeMouseOver.mock.calls[1][0]).toBe(grips[1]); + expect(onDOMNodeMouseOver.mock.calls[2][0]).toBe(grips[2]); + }); + + it("calls the expected function on mouseout", () => { + const onDOMNodeMouseOut = jest.fn(); + const wrapper = renderRep({ onDOMNodeMouseOut }); + const node = wrapper.find(".objectBox-node"); + + node.at(0).simulate("mouseout"); + node.at(1).simulate("mouseout"); + node.at(2).simulate("mouseout"); + + expect(onDOMNodeMouseOut.mock.calls).toHaveLength(3); + expect(onDOMNodeMouseOut.mock.calls[0][0]).toBe(grips[0]); + expect(onDOMNodeMouseOut.mock.calls[1][0]).toBe(grips[1]); + expect(onDOMNodeMouseOut.mock.calls[2][0]).toBe(grips[2]); + }); + + it("calls the expected function on click", () => { + const onInspectIconClick = jest.fn(); + const wrapper = renderRep({ onInspectIconClick }); + const node = wrapper.find(".open-inspector"); + + node.at(0).simulate("click"); + node.at(1).simulate("click"); + node.at(2).simulate("click"); + + expect(onInspectIconClick.mock.calls).toHaveLength(3); + expect(onInspectIconClick.mock.calls[0][0]).toBe(grips[0]); + expect(onInspectIconClick.mock.calls[1][0]).toBe(grips[1]); + expect(onInspectIconClick.mock.calls[2][0]).toBe(grips[2]); + }); + + it("no inspect icon when nodes are not connected to the DOM tree", () => { + const renderedComponentWithoutInspectIcon = shallowRenderRep( + stubs.get("testDisconnectedNodeList") + ); + const node = renderedComponentWithoutInspectIcon.find(".open-inspector"); + expect(node.exists()).toBe(false); + }); +}); + +describe("GripArray - DocumentFragment", () => { + const object = stubs.get("testDocumentFragment"); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + + let length = getGripLengthBubbleText(object); + const defaultOutput = + `DocumentFragment${length} [ li#li-0.list-element, ` + + "li#li-1.list-element, li#li-2.list-element, … ]"; + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.TINY }); + expect(renderRep({ mode: MODE.TINY }).text()).toBe( + `DocumentFragment${length}` + ); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe( + `DocumentFragment${length}` + ); + + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.LONG }); + const longOutput = + `DocumentFragment${length} [ ` + + "li#li-0.list-element, li#li-1.list-element, li#li-2.list-element, " + + "li#li-3.list-element, li#li-4.list-element ]"; + expect(renderRep({ mode: MODE.LONG }).text()).toBe(longOutput); + }); +}); + +describe("GripArray - Items not in preview", () => { + const object = stubs.get("testItemsNotInPreview"); + + it("correctly selects GripArray Rep", () => { + expect(getRep(object)).toBe(GripArray.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + let length = getGripLengthBubbleText(object); + const defaultOutput = `Array${length} [ … ]`; + + let component = renderRep({ mode: undefined }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + length = getGripLengthBubbleText(object, { mode: MODE.TINY }); + component = renderRep({ mode: MODE.TINY }); + expect(component.text()).toBe(`${length} […]`); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.HEADER }); + expect(component.text()).toBe(`Array${length}`); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.SHORT }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.LONG }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + }); +}); + +describe("GripArray - Set", () => { + it("correctly selects GripArray Rep", () => { + const object = stubs.get("new Set([1,2,3,4])"); + expect(getRep(object)).toBe(GripArray.rep); + }); + + it("renders short sets as expected", () => { + const object = stubs.get("new Set([1,2,3,4])"); + const renderRep = props => shallowRenderRep(object, props); + let length = getGripLengthBubbleText(object); + const defaultOutput = `Set${length} [ 1, 2, 3, … ]`; + + let component = renderRep({ mode: undefined }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.TINY }); + expect(component.text()).toBe(`Set${length}`); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.HEADER }); + expect(component.text()).toBe(`Set${length}`); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.SHORT }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + length = getGripLengthBubbleText(object, { mode: MODE.LONG }); + component = renderRep({ mode: MODE.LONG }); + expect(component.text()).toBe(`Set${length} [ 1, 2, 3, 4 ]`); + expectActorAttribute(component, object.actor); + }); + + it("renders larger sets as expected", () => { + const object = stubs.get("new Set([0,1,2,…,19])"); + const renderRep = props => shallowRenderRep(object, props); + let length = getGripLengthBubbleText(object); + const defaultOutput = `Set${length} [ 0, 1, 2, … ]`; + + let component = renderRep({ mode: undefined }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.TINY }); + expect(component.text()).toBe(`Set${length}`); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.HEADER }); + expect(component.text()).toBe(`Set${length}`); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.SHORT }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + length = getGripLengthBubbleText(object, { mode: MODE.LONG }); + component = renderRep({ mode: MODE.LONG }); + expect(component.text()).toBe( + `Set${length} [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, … ]` + ); + expectActorAttribute(component, object.actor); + }); +}); + +describe("GripArray - WeakSet", () => { + it("correctly selects GripArray Rep", () => { + const object = stubs.get( + "new WeakSet(document.querySelectorAll('button:nth-child(3n)'))" + ); + expect(getRep(object)).toBe(GripArray.rep); + }); + + it("renders short WeakSets as expected", () => { + const object = stubs.get( + "new WeakSet(document.querySelectorAll('button:nth-child(3n)'))" + ); + const renderRep = props => shallowRenderRep(object, props); + let length = getGripLengthBubbleText(object); + const defaultOutput = `WeakSet${length} [ button, button, button, … ]`; + + let component = renderRep({ mode: undefined }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.TINY }); + expect(component.text()).toBe(`WeakSet${length}`); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.HEADER }); + expect(component.text()).toBe(`WeakSet${length}`); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.SHORT }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + length = getGripLengthBubbleText(object, { mode: MODE.LONG }); + component = renderRep({ mode: MODE.LONG }); + expect(component.text()).toBe( + `WeakSet${length} [ button, button, button, button ]` + ); + expectActorAttribute(component, object.actor); + }); + + it("renders larger WeakSets as expected", () => { + const object = stubs.get( + "new WeakSet(document.querySelectorAll('div, button'))" + ); + const renderRep = props => shallowRenderRep(object, props); + const length = getGripLengthBubbleText(object); + const defaultOutput = `WeakSet${length} [ button, button, button, … ]`; + + let component = renderRep({ mode: undefined }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.TINY }); + expect(component.text()).toBe(`WeakSet${length}`); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.HEADER }); + expect(component.text()).toBe(`WeakSet${length}`); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.SHORT }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.LONG }); + expect(component.text()).toBe(`WeakSet(12) [ ${"button, ".repeat(10)}… ]`); + expectActorAttribute(component, object.actor); + }); +}); + +describe("GripArray - DOMTokenList", () => { + const object = stubs.get("DOMTokenList"); + + it("correctly selects GripArray Rep", () => { + expect(getRep(object)).toBe(GripArray.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const length = getGripLengthBubbleText(object); + const defaultOutput = `DOMTokenList${length} []`; + + let component = renderRep({ mode: undefined }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.TINY }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.HEADER }); + expect(component.text()).toBe(`DOMTokenList${length}`); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.SHORT }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.LONG }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + }); +}); + +describe("GripArray - accessor", () => { + it("renders an array with getter as expected", () => { + const object = stubs.get("TestArrayWithGetter"); + const renderRep = props => shallowRenderRep(object, props); + let length = getGripLengthBubbleText(object); + + const defaultOutput = `Array${length} [ Getter ]`; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.TINY }); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.LONG }); + const longOutput = `Array${length} [ Getter ]`; + expect(renderRep({ mode: MODE.LONG }).text()).toBe(longOutput); + }); + + it("renders an array with setter as expected", () => { + const object = stubs.get("TestArrayWithSetter"); + const renderRep = props => shallowRenderRep(object, props); + let length = getGripLengthBubbleText(object); + + const defaultOutput = `Array${length} [ Setter ]`; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.TINY }); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.LONG }); + const longOutput = `Array${length} [ Setter ]`; + expect(renderRep({ mode: MODE.LONG }).text()).toBe(longOutput); + }); + + it("renders an array with getter and setter as expected", () => { + const object = stubs.get("TestArrayWithGetterAndSetter"); + const renderRep = props => shallowRenderRep(object, props); + let length = getGripLengthBubbleText(object); + + const defaultOutput = `Array${length} [ Getter & Setter ]`; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.TINY }); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`${length} […]`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Array${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + + length = getGripLengthBubbleText(object, { mode: MODE.LONG }); + const longOutput = `Array${length} [ Getter & Setter ]`; + expect(renderRep({ mode: MODE.LONG }).text()).toBe(longOutput); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/grip-entry.test.js b/devtools/client/shared/components/test/node/components/reps/grip-entry.test.js new file mode 100644 index 0000000000..cdfcab755e --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/grip-entry.test.js @@ -0,0 +1,191 @@ +/* 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"; + +/* global jest */ +const { shallow } = require("enzyme"); + +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { GripEntry } = REPS; +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const { + createGripMapEntry, + getGripLengthBubbleText, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-entry.js"); +const nodeStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/element-node.js"); +const gripArrayStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); + +const renderRep = (object, mode, props) => { + return shallow( + GripEntry.rep({ + object, + mode, + ...props, + }) + ); +}; + +describe("GripEntry - simple", () => { + const stub = stubs.get("A → 0"); + + it("Rep correctly selects GripEntry Rep", () => { + expect(getRep(stub)).toBe(GripEntry.rep); + }); + + it("GripEntry rep has expected text content", () => { + const renderedComponent = renderRep(stub); + expect(renderedComponent.text()).toEqual("A → 0"); + }); +}); + +describe("GripEntry - createGripMapEntry", () => { + it("return the expected object", () => { + const entry = createGripMapEntry("A", 0); + expect(entry).toEqual(stubs.get("A → 0")); + }); +}); + +describe("GripEntry - complex", () => { + it("Handles complex objects as key and value", () => { + let stub = gripArrayStubs.get("testBasic"); + let length = getGripLengthBubbleText(stub); + let entry = createGripMapEntry("A", stub); + expect(renderRep(entry, MODE.TINY).text()).toEqual("A → []"); + expect(renderRep(entry, MODE.SHORT).text()).toEqual( + `A → Array${length} []` + ); + expect(renderRep(entry, MODE.LONG).text()).toEqual(`A → Array${length} []`); + + entry = createGripMapEntry(stub, "A"); + expect(renderRep(entry, MODE.TINY).text()).toEqual('[] → "A"'); + expect(renderRep(entry, MODE.SHORT).text()).toEqual( + `Array${length} [] → "A"` + ); + expect(renderRep(entry, MODE.LONG).text()).toEqual( + `Array${length} [] → "A"` + ); + + stub = gripArrayStubs.get("testMaxProps"); + length = getGripLengthBubbleText(stub, { mode: MODE.TINY }); + entry = createGripMapEntry("A", stub); + expect(renderRep(entry, MODE.TINY).text()).toEqual(`A → ${length} […]`); + length = getGripLengthBubbleText(stub); + expect(renderRep(entry, MODE.SHORT).text()).toEqual( + `A → Array${length} [ 1, "foo", {} ]` + ); + length = getGripLengthBubbleText(stub, { mode: MODE.LONG }); + expect(renderRep(entry, MODE.LONG).text()).toEqual( + `A → Array${length} [ 1, "foo", {} ]` + ); + + entry = createGripMapEntry(stub, "A"); + length = getGripLengthBubbleText(stub, { mode: MODE.TINY }); + expect(renderRep(entry, MODE.TINY).text()).toEqual(`${length} […] → "A"`); + length = getGripLengthBubbleText(stub, { mode: MODE.SHORT }); + expect(renderRep(entry, MODE.SHORT).text()).toEqual( + `Array${length} [ 1, "foo", {} ] → "A"` + ); + length = getGripLengthBubbleText(stub, { mode: MODE.LONG }); + expect(renderRep(entry, MODE.LONG).text()).toEqual( + `Array${length} [ 1, "foo", {} ] → "A"` + ); + + stub = gripArrayStubs.get("testMoreThanShortMaxProps"); + length = getGripLengthBubbleText(stub); + entry = createGripMapEntry("A", stub); + length = getGripLengthBubbleText(stub, { mode: MODE.TINY }); + expect(renderRep(entry, MODE.TINY).text()).toEqual(`A → ${length} […]`); + length = getGripLengthBubbleText(stub, { mode: MODE.SHORT }); + expect(renderRep(entry, MODE.SHORT).text()).toEqual( + `A → Array${length} [ "test string", "test string", "test string", … ]` + ); + length = getGripLengthBubbleText(stub, { mode: MODE.LONG }); + expect(renderRep(entry, MODE.LONG).text()).toEqual( + `A → Array${length} [ "test string", "test string", "test string",\ + "test string" ]` + ); + + entry = createGripMapEntry(stub, "A"); + length = getGripLengthBubbleText(stub, { mode: MODE.TINY }); + expect(renderRep(entry, MODE.TINY).text()).toEqual(`${length} […] → "A"`); + length = getGripLengthBubbleText(stub, { mode: MODE.SHORT }); + expect(renderRep(entry, MODE.SHORT).text()).toEqual( + `Array${length} [ "test string", "test string", "test string", … ] → "A"` + ); + length = getGripLengthBubbleText(stub, { mode: MODE.LONG }); + expect(renderRep(entry, MODE.LONG).text()).toEqual( + `Array${length} [ "test string", "test string", "test string", ` + + '"test string" ] → "A"' + ); + }); + + it("Handles Element Nodes as key and value", () => { + const stub = nodeStubs.get("Node"); + + const onInspectIconClick = jest.fn(); + const onDOMNodeMouseOut = jest.fn(); + const onDOMNodeMouseOver = jest.fn(); + + let entry = createGripMapEntry("A", stub); + let renderedComponent = renderRep(entry, MODE.TINY, { + onInspectIconClick, + onDOMNodeMouseOut, + onDOMNodeMouseOver, + }); + expect(renderRep(entry, MODE.TINY).text()).toEqual( + "A → input#newtab-customize-button.bar.baz" + ); + + let node = renderedComponent.find(".objectBox-node"); + let icon = node.find(".open-inspector"); + icon.simulate("click", { type: "click" }); + expect(icon.exists()).toBeTruthy(); + expect(onInspectIconClick.mock.calls).toHaveLength(1); + expect(onInspectIconClick.mock.calls[0][0]).toEqual(stub); + expect(onInspectIconClick.mock.calls[0][1].type).toEqual("click"); + + node.simulate("mouseout"); + expect(onDOMNodeMouseOut.mock.calls).toHaveLength(1); + expect(onDOMNodeMouseOut.mock.calls[0][0]).toEqual(stub); + + node.simulate("mouseover"); + expect(onDOMNodeMouseOver.mock.calls).toHaveLength(1); + expect(onDOMNodeMouseOver.mock.calls[0][0]).toEqual(stub); + + entry = createGripMapEntry(stub, "A"); + renderedComponent = renderRep(entry, MODE.TINY, { + onInspectIconClick, + onDOMNodeMouseOut, + onDOMNodeMouseOver, + }); + expect(renderRep(entry, MODE.TINY).text()).toEqual( + 'input#newtab-customize-button.bar.baz → "A"' + ); + + node = renderedComponent.find(".objectBox-node"); + icon = node.find(".open-inspector"); + icon.simulate("click", { type: "click" }); + expect(node.exists()).toBeTruthy(); + expect(onInspectIconClick.mock.calls).toHaveLength(2); + expect(onInspectIconClick.mock.calls[1][0]).toEqual(stub); + expect(onInspectIconClick.mock.calls[1][1].type).toEqual("click"); + + node.simulate("mouseout"); + expect(onDOMNodeMouseOut.mock.calls).toHaveLength(2); + expect(onDOMNodeMouseOut.mock.calls[1][0]).toEqual(stub); + + node.simulate("mouseover"); + expect(onDOMNodeMouseOver.mock.calls).toHaveLength(2); + expect(onDOMNodeMouseOver.mock.calls[1][0]).toEqual(stub); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/grip-map.test.js b/devtools/client/shared/components/test/node/components/reps/grip-map.test.js new file mode 100644 index 0000000000..729d912a21 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/grip-map.test.js @@ -0,0 +1,390 @@ +/* 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"; + +/* global jest */ + +const { shallow } = require("enzyme"); +const { + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const GripMap = require("resource://devtools/client/shared/components/reps/reps/grip-map.js"); +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-map.js"); +const { + expectActorAttribute, + getSelectableInInspectorGrips, + getMapLengthBubbleText, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); +const { maxLengthMap, getLength } = GripMap; + +function shallowRenderRep(object, props = {}) { + return shallow( + GripMap.rep({ + object, + ...props, + }) + ); +} + +describe("GripMap - empty map", () => { + const object = stubs.get("testEmptyMap"); + + it("correctly selects GripMap Rep", () => { + expect(getRep(object)).toBe(GripMap.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const length = getMapLengthBubbleText(object); + const defaultOutput = `Map${length}`; + + let component = renderRep({ mode: undefined, shouldRenderTooltip: true }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(`Map(${getLength(object)})`); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.TINY, shouldRenderTooltip: true }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(`Map(${getLength(object)})`); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.HEADER, shouldRenderTooltip: true }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(`Map(${getLength(object)})`); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.SHORT, shouldRenderTooltip: true }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(`Map(${getLength(object)})`); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.LONG, shouldRenderTooltip: true }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(`Map(${getLength(object)})`); + expectActorAttribute(component, object.actor); + }); +}); + +describe("GripMap - Symbol-keyed Map", () => { + // Test object: + // `new Map([[Symbol("a"), "value-a"], [Symbol("b"), "value-b"]])` + const object = stubs.get("testSymbolKeyedMap"); + + it("correctly selects GripMap Rep", () => { + expect(getRep(object)).toBe(GripMap.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + let length = getMapLengthBubbleText(object); + const out = `Map${length} { Symbol("a") → "value-a", Symbol("b") → "value-b" }`; + + expect(renderRep({ mode: undefined }).text()).toBe(out); + + length = getMapLengthBubbleText(object, { mode: MODE.TINY }); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`Map${length}`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Map${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(out); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(out); + }); +}); + +describe("GripMap - WeakMap", () => { + // Test object: `new WeakMap([[{a: "key-a"}, "value-a"]])` + const object = stubs.get("testWeakMap"); + + it("correctly selects GripMap Rep", () => { + expect(getRep(object)).toBe(GripMap.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + let length = getMapLengthBubbleText(object); + const defaultOutput = `WeakMap${length} { {…} → "value-a" }`; + expect( + renderRep({ mode: undefined, shouldRenderTooltip: true }).text() + ).toBe(defaultOutput); + expect( + renderRep({ mode: undefined, shouldRenderTooltip: true }).prop("title") + ).toBe(`WeakMap(${getLength(object)})`); + + length = getMapLengthBubbleText(object, { mode: MODE.TINY }); + expect( + renderRep({ mode: MODE.TINY, shouldRenderTooltip: true }).text() + ).toBe(`WeakMap${length}`); + expect( + renderRep({ mode: MODE.TINY, shouldRenderTooltip: true }).prop("title") + ).toBe(`WeakMap(${getLength(object)})`); + + length = getMapLengthBubbleText(object, { mode: MODE.HEADER }); + expect( + renderRep({ mode: MODE.HEADER, shouldRenderTooltip: true }).text() + ).toBe(`WeakMap${length}`); + expect( + renderRep({ mode: MODE.HEADER, shouldRenderTooltip: true }).prop("title") + ).toBe(`WeakMap(${getLength(object)})`); + + expect( + renderRep({ mode: MODE.SHORT, shouldRenderTooltip: true }).text() + ).toBe(defaultOutput); + expect( + renderRep({ mode: MODE.SHORT, shouldRenderTooltip: true }).prop("title") + ).toBe(`WeakMap(${getLength(object)})`); + expect( + renderRep({ mode: MODE.LONG, shouldRenderTooltip: true }).text() + ).toBe(defaultOutput); + expect( + renderRep({ mode: MODE.LONG, shouldRenderTooltip: true }).prop("title") + ).toBe(`WeakMap(${getLength(object)})`); + + length = getMapLengthBubbleText(object, { mode: MODE.LONG }); + + expect( + renderRep({ + mode: MODE.LONG, + title: "CustomTitle", + }).text() + ).toBe(`CustomTitle${length} { {…} → "value-a" }`); + expect( + renderRep({ + mode: MODE.LONG, + title: "CustomTitle", + shouldRenderTooltip: true, + }).prop("title") + ).toBe(`CustomTitle(${getLength(object)})`); + }); +}); + +describe("GripMap - max entries", () => { + // Test object: + // `new Map([["key-a","value-a"], ["key-b","value-b"], ["key-c","value-c"]])` + const object = stubs.get("testMaxEntries"); + + it("correctly selects GripMap Rep", () => { + expect(getRep(object)).toBe(GripMap.rep); + }); + + it("renders as expected", () => { + let length = getMapLengthBubbleText(object); + const renderRep = props => shallowRenderRep(object, props); + const out = + `Map${length} { ` + + '"key-a" → "value-a", "key-b" → "value-b", "key-c" → "value-c" }'; + + expect(renderRep({ mode: undefined }).text()).toBe(out); + + length = getMapLengthBubbleText(object, { mode: MODE.TINY }); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`Map${length}`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Map${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(out); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(out); + }); +}); + +describe("GripMap - more than max entries", () => { + // Test object = `new Map( + // [["key-0", "value-0"], …, ["key-100", "value-100"]]}` + const object = stubs.get("testMoreThanMaxEntries"); + + it("correctly selects GripMap Rep", () => { + expect(getRep(object)).toBe(GripMap.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + let length = getMapLengthBubbleText(object); + const defaultOutput = + `Map${length} { "key-0" → "value-0", ` + + '"key-1" → "value-1", "key-2" → "value-2", … }'; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + + length = getMapLengthBubbleText(object, { mode: MODE.TINY }); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`Map${length}`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Map${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + + const longString = Array.from({ length: maxLengthMap.get(MODE.LONG) }).map( + (_, i) => `"key-${i}" → "value-${i}"` + ); + expect(renderRep({ mode: MODE.LONG }).text()).toBe( + `Map(${maxLengthMap.get(MODE.LONG) + 1}) { ${longString.join(", ")}, … }` + ); + }); +}); + +describe("GripMap - uninteresting entries", () => { + // Test object: + // `new Map([["key-a",null], ["key-b",undefined], ["key-c","value-c"], + // ["key-d",4]])` + const object = stubs.get("testUninterestingEntries"); + + it("correctly selects GripMap Rep", () => { + expect(getRep(object)).toBe(GripMap.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + let length = getMapLengthBubbleText(object); + const defaultOutput = + `Map${length} { "key-a" → null, ` + + '"key-c" → "value-c", "key-d" → 4, … }'; + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + + length = getMapLengthBubbleText(object, { mode: MODE.TINY }); + expect(renderRep({ mode: MODE.TINY }).text()).toBe(`Map${length}`); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe(`Map${length}`); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + + length = getMapLengthBubbleText(object, { mode: MODE.LONG }); + const longOutput = + `Map${length} { "key-a" → null, "key-b" → undefined, ` + + '"key-c" → "value-c", "key-d" → 4 }'; + expect(renderRep({ mode: MODE.LONG }).text()).toBe(longOutput); + }); +}); + +describe("GripMap - Node-keyed entries", () => { + const object = stubs.get("testNodeKeyedMap"); + const renderRep = props => shallowRenderRep(object, props); + const grips = getSelectableInInspectorGrips(object); + + it("correctly selects GripMap Rep", () => { + expect(getRep(object)).toBe(GripMap.rep); + }); + + it("has the expected number of grips", () => { + expect(grips).toHaveLength(3); + }); + + it("calls the expected function on mouseover", () => { + const onDOMNodeMouseOver = jest.fn(); + const wrapper = renderRep({ onDOMNodeMouseOver }); + const node = wrapper.find(".objectBox-node"); + + node.at(0).simulate("mouseover"); + node.at(1).simulate("mouseover"); + node.at(2).simulate("mouseover"); + + expect(onDOMNodeMouseOver.mock.calls).toHaveLength(3); + expect(onDOMNodeMouseOver.mock.calls[0][0]).toBe(grips[0]); + expect(onDOMNodeMouseOver.mock.calls[1][0]).toBe(grips[1]); + expect(onDOMNodeMouseOver.mock.calls[2][0]).toBe(grips[2]); + }); + + it("calls the expected function on mouseout", () => { + const onDOMNodeMouseOut = jest.fn(); + const wrapper = renderRep({ onDOMNodeMouseOut }); + const node = wrapper.find(".objectBox-node"); + + node.at(0).simulate("mouseout"); + node.at(1).simulate("mouseout"); + node.at(2).simulate("mouseout"); + + expect(onDOMNodeMouseOut.mock.calls).toHaveLength(3); + expect(onDOMNodeMouseOut.mock.calls[0][0]).toBe(grips[0]); + expect(onDOMNodeMouseOut.mock.calls[1][0]).toBe(grips[1]); + expect(onDOMNodeMouseOut.mock.calls[2][0]).toBe(grips[2]); + }); + + it("calls the expected function on click", () => { + const onInspectIconClick = jest.fn(); + const wrapper = renderRep({ onInspectIconClick }); + const node = wrapper.find(".open-inspector"); + + node.at(0).simulate("click"); + node.at(1).simulate("click"); + node.at(2).simulate("click"); + + expect(onInspectIconClick.mock.calls).toHaveLength(3); + expect(onInspectIconClick.mock.calls[0][0]).toBe(grips[0]); + expect(onInspectIconClick.mock.calls[1][0]).toBe(grips[1]); + expect(onInspectIconClick.mock.calls[2][0]).toBe(grips[2]); + }); +}); + +describe("GripMap - Node-valued entries", () => { + const object = stubs.get("testNodeValuedMap"); + const renderRep = props => shallowRenderRep(object, props); + const grips = getSelectableInInspectorGrips(object); + + it("correctly selects GripMap Rep", () => { + expect(getRep(object)).toBe(GripMap.rep); + }); + + it("has the expected number of grips", () => { + expect(grips).toHaveLength(3); + }); + + it("calls the expected function on mouseover", () => { + const onDOMNodeMouseOver = jest.fn(); + const wrapper = renderRep({ onDOMNodeMouseOver }); + const node = wrapper.find(".objectBox-node"); + + node.at(0).simulate("mouseover"); + node.at(1).simulate("mouseover"); + node.at(2).simulate("mouseover"); + + expect(onDOMNodeMouseOver.mock.calls).toHaveLength(3); + expect(onDOMNodeMouseOver.mock.calls[0][0]).toBe(grips[0]); + expect(onDOMNodeMouseOver.mock.calls[1][0]).toBe(grips[1]); + expect(onDOMNodeMouseOver.mock.calls[2][0]).toBe(grips[2]); + }); + + it("calls the expected function on mouseout", () => { + const onDOMNodeMouseOut = jest.fn(); + const wrapper = renderRep({ onDOMNodeMouseOut }); + const node = wrapper.find(".objectBox-node"); + + node.at(0).simulate("mouseout"); + node.at(1).simulate("mouseout"); + node.at(2).simulate("mouseout"); + + expect(onDOMNodeMouseOut.mock.calls).toHaveLength(3); + expect(onDOMNodeMouseOut.mock.calls[0][0]).toBe(grips[0]); + expect(onDOMNodeMouseOut.mock.calls[1][0]).toBe(grips[1]); + expect(onDOMNodeMouseOut.mock.calls[2][0]).toBe(grips[2]); + }); + + it("calls the expected function on click", () => { + const onInspectIconClick = jest.fn(); + const wrapper = renderRep({ onInspectIconClick }); + const node = wrapper.find(".open-inspector"); + + node.at(0).simulate("click"); + node.at(1).simulate("click"); + node.at(2).simulate("click"); + + expect(onInspectIconClick.mock.calls).toHaveLength(3); + expect(onInspectIconClick.mock.calls[0][0]).toBe(grips[0]); + expect(onInspectIconClick.mock.calls[1][0]).toBe(grips[1]); + expect(onInspectIconClick.mock.calls[2][0]).toBe(grips[2]); + }); +}); + +describe("GripMap - Disconnected node-valued entries", () => { + const object = stubs.get("testDisconnectedNodeValuedMap"); + const renderRep = props => shallowRenderRep(object, props); + const grips = getSelectableInInspectorGrips(object); + + it("correctly selects GripMap Rep", () => { + expect(getRep(object)).toBe(GripMap.rep); + }); + + it("has the expected number of grips", () => { + expect(grips).toHaveLength(3); + }); + + it("no inspect icon when nodes are not connected to the DOM tree", () => { + const onInspectIconClick = jest.fn(); + const wrapper = renderRep({ onInspectIconClick }); + + const node = wrapper.find(".open-inspector"); + expect(node.exists()).toBe(false); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/grip.test.js b/devtools/client/shared/components/test/node/components/reps/grip.test.js new file mode 100644 index 0000000000..3ef69ee052 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/grip.test.js @@ -0,0 +1,750 @@ +/* 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"; + +/* global jest */ + +const { shallow } = require("enzyme"); +const { + getRep, + Rep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const Grip = require("resource://devtools/client/shared/components/reps/reps/grip.js"); +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip.js"); +const gripArrayStubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); + +const { + expectActorAttribute, + getSelectableInInspectorGrips, + getGripLengthBubbleText, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); +const { maxLengthMap } = Grip; + +function shallowRenderRep(object, props = {}) { + return shallow( + Grip.rep({ + object, + ...props, + }) + ); +} + +describe("Grip - empty object", () => { + // Test object: `{}` + const object = stubs.get("testBasic"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = "Object { }"; + + let component = renderRep({ mode: undefined }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.TINY }); + expect(component.text()).toBe("{}"); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.HEADER }); + expect(component.text()).toBe("Object"); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.SHORT }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.LONG }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + }); +}); + +describe("Grip - Boolean object", () => { + // Test object: `new Boolean(true)` + const object = stubs.get("testBooleanObject"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = "Boolean { true }"; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("Boolean"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Boolean"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +describe("Grip - Number object", () => { + // Test object: `new Number(42)` + const object = stubs.get("testNumberObject"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = "Number { 42 }"; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("Number"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Number"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +describe("Grip - String object", () => { + // Test object: `new String("foo")` + const object = stubs.get("testStringObject"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = 'String { "foo" }'; + + expect( + renderRep({ mode: undefined, shouldRenderTooltip: true }).text() + ).toBe(defaultOutput); + expect( + renderRep({ mode: undefined, shouldRenderTooltip: true }).prop("title") + ).toBe("String"); + expect( + renderRep({ mode: MODE.TINY, shouldRenderTooltip: true }).text() + ).toBe("String"); + expect( + renderRep({ mode: MODE.TINY, shouldRenderTooltip: true }).prop("title") + ).toBe("String"); + expect( + renderRep({ mode: MODE.HEADER, shouldRenderTooltip: true }).text() + ).toBe("String"); + expect( + renderRep({ mode: MODE.HEADER, shouldRenderTooltip: true }).prop("title") + ).toBe("String"); + expect( + renderRep({ mode: MODE.SHORT, shouldRenderTooltip: true }).text() + ).toBe(defaultOutput); + expect( + renderRep({ mode: MODE.SHORT, shouldRenderTooltip: true }).prop("title") + ).toBe("String"); + expect( + renderRep({ mode: MODE.LONG, shouldRenderTooltip: true }).text() + ).toBe(defaultOutput); + expect( + renderRep({ mode: MODE.LONG, shouldRenderTooltip: true }).prop("title") + ).toBe("String"); + }); +}); + +describe("Grip - Proxy", () => { + // Test object: `new Proxy({a:1},[1,2,3])` + const object = stubs.get("testProxy"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const handler = object.preview.ownProperties["<handler>"].value; + const handlerLength = getGripLengthBubbleText(handler, { + mode: MODE.TINY, + }); + const out = `Proxy { <target>: {…}, <handler>: ${handlerLength} […] }`; + + expect(renderRep({ mode: undefined }).text()).toBe(out); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("Proxy"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Proxy"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(out); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(out); + }); +}); + +describe("Grip - ArrayBuffer", () => { + // Test object: `new ArrayBuffer(10)` + const object = stubs.get("testArrayBuffer"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = "ArrayBuffer { byteLength: 10 }"; + + expect( + renderRep({ mode: undefined, shouldRenderTooltip: true }).text() + ).toBe(defaultOutput); + expect( + renderRep({ mode: undefined, shouldRenderTooltip: true }).prop("title") + ).toBe("ArrayBuffer"); + expect( + renderRep({ mode: MODE.TINY, shouldRenderTooltip: true }).text() + ).toBe("ArrayBuffer"); + expect( + renderRep({ mode: MODE.TINY, shouldRenderTooltip: true }).prop("title") + ).toBe("ArrayBuffer"); + expect( + renderRep({ mode: MODE.HEADER, shouldRenderTooltip: true }).text() + ).toBe("ArrayBuffer"); + expect( + renderRep({ mode: MODE.HEADER, shouldRenderTooltip: true }).prop("title") + ).toBe("ArrayBuffer"); + expect( + renderRep({ mode: MODE.SHORT, shouldRenderTooltip: true }).text() + ).toBe(defaultOutput); + expect( + renderRep({ mode: MODE.SHORT, shouldRenderTooltip: true }).prop("title") + ).toBe("ArrayBuffer"); + expect( + renderRep({ mode: MODE.LONG, shouldRenderTooltip: true }).text() + ).toBe(defaultOutput); + expect( + renderRep({ mode: MODE.LONG, shouldRenderTooltip: true }).prop("title") + ).toBe("ArrayBuffer"); + }); +}); + +describe("Grip - SharedArrayBuffer", () => { + // Test object: `new SharedArrayBuffer(5)` + const object = stubs.get("testSharedArrayBuffer"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = "SharedArrayBuffer { byteLength: 5 }"; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("SharedArrayBuffer"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("SharedArrayBuffer"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +describe("Grip - ApplicationCache", () => { + // Test object: `window.applicationCache` + const object = stubs.get("testApplicationCache"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = + "OfflineResourceList { status: 0, onchecking: null, onerror: null, … }"; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("OfflineResourceList"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("OfflineResourceList"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + + const longOutput = + "OfflineResourceList { status: 0, onchecking: null, " + + "onerror: null, onnoupdate: null, ondownloading: null, " + + "onprogress: null, onupdateready: null, oncached: null, " + + "onobsolete: null, mozItems: DOMStringList [] }"; + expect(renderRep({ mode: MODE.LONG }).text()).toBe(longOutput); + }); +}); + +describe("Grip - Object with max props", () => { + // Test object: `{a: "a", b: "b", c: "c"}` + const object = stubs.get("testMaxProps"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = 'Object { a: "a", b: "b", c: "c" }'; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("{…}"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Object"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +describe("Grip - Object with more than short mode max props", () => { + // Test object: `{a: undefined, b: 1, more: 2, d: 3}`; + const object = stubs.get("testMoreProp"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = "Object { b: 1, more: 2, d: 3, … }"; + + expect( + renderRep({ mode: undefined, shouldRenderTooltip: true }).text() + ).toBe(defaultOutput); + expect( + renderRep({ mode: undefined, shouldRenderTooltip: true }).prop("title") + ).toBe("Object"); + expect( + renderRep({ mode: MODE.TINY, shouldRenderTooltip: true }).text() + ).toBe("{…}"); + expect( + renderRep({ mode: MODE.TINY, shouldRenderTooltip: true }).prop("title") + ).toBe("Object"); + expect( + renderRep({ mode: MODE.HEADER, shouldRenderTooltip: true }).text() + ).toBe("Object"); + expect( + renderRep({ mode: MODE.HEADER, shouldRenderTooltip: true }).prop("title") + ).toBe("Object"); + expect( + renderRep({ mode: MODE.SHORT, shouldRenderTooltip: true }).text() + ).toBe(defaultOutput); + expect( + renderRep({ mode: MODE.SHORT, shouldRenderTooltip: true }).prop("title") + ).toBe("Object"); + + const longOutput = "Object { a: undefined, b: 1, more: 2, d: 3 }"; + expect( + renderRep({ mode: MODE.LONG, shouldRenderTooltip: true }).text() + ).toBe(longOutput); + expect( + renderRep({ mode: MODE.LONG, shouldRenderTooltip: true }).prop("title") + ).toBe("Object"); + }); +}); + +describe("Grip - Object with more than long mode max props", () => { + // Test object = `{p0: "0", p1: "1", p2: "2", …, p100: "100"}` + const object = stubs.get("testMoreThanMaxProps"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = 'Object { p0: "0", p1: "1", p2: "2", … }'; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("{…}"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Object"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + + const props = Array.from({ length: maxLengthMap.get(MODE.LONG) }).map( + (item, i) => `p${i}: "${i}"` + ); + const longOutput = `Object { ${props.join(", ")}, … }`; + expect(renderRep({ mode: MODE.LONG }).text()).toBe(longOutput); + }); +}); + +describe("Grip - Object with non-enumerable properties", () => { + // Test object: `Object.defineProperty({}, "foo", {enumerable : false});` + const object = stubs.get("testNonEnumerableProps"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = "Object { … }"; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("{…}"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Object"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +describe("Grip - Object with nested object", () => { + // Test object: `{objProp: {id: 1}, strProp: "test string"}` + const object = stubs.get("testNestedObject"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = 'Object { objProp: {…}, strProp: "test string" }'; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("{…}"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Object"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + + // Check the custom title with nested objects to make sure nested objects + // are not displayed with their parent's title. + expect( + renderRep({ + mode: MODE.LONG, + title: "CustomTitle", + }).text() + ).toBe('CustomTitle { objProp: {…}, strProp: "test string" }'); + }); +}); + +describe("Grip - Object with nested array", () => { + // Test object: `{arrProp: ["foo", "bar", "baz"]}` + const object = stubs.get("testNestedArray"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const propLength = getGripLengthBubbleText( + object.preview.ownProperties.arrProp.value, + { mode: MODE.TINY } + ); + const defaultOutput = `Object { arrProp: ${propLength} […] }`; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("{…}"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Object"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +describe("Grip - Object with connected nodes", () => { + const object = stubs.get("testObjectWithNodes"); + const grips = getSelectableInInspectorGrips(object); + const renderRep = props => shallowRenderRep(object, props); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("has the expected number of node grip", () => { + expect(grips).toHaveLength(2); + }); + + it("renders as expected", () => { + const defaultOutput = + "Object { foo: button#btn-1.btn.btn-log, bar: button#btn-2.btn.btn-err }"; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("{…}"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Object"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); + + it("calls the expected function on mouseover", () => { + const onDOMNodeMouseOver = jest.fn(); + const wrapper = renderRep({ onDOMNodeMouseOver }); + const node = wrapper.find(".objectBox-node"); + + node.at(0).simulate("mouseover"); + node.at(1).simulate("mouseover"); + + expect(onDOMNodeMouseOver.mock.calls).toHaveLength(2); + expect(onDOMNodeMouseOver.mock.calls[0][0]).toBe(grips[0]); + expect(onDOMNodeMouseOver.mock.calls[1][0]).toBe(grips[1]); + }); + + it("calls the expected function on mouseout", () => { + const onDOMNodeMouseOut = jest.fn(); + const wrapper = renderRep({ onDOMNodeMouseOut }); + const node = wrapper.find(".objectBox-node"); + + node.at(0).simulate("mouseout"); + node.at(1).simulate("mouseout"); + + expect(onDOMNodeMouseOut.mock.calls).toHaveLength(2); + expect(onDOMNodeMouseOut.mock.calls[0][0]).toBe(grips[0]); + expect(onDOMNodeMouseOut.mock.calls[1][0]).toBe(grips[1]); + }); + + it("calls the expected function on click", () => { + const onInspectIconClick = jest.fn(); + const wrapper = renderRep({ onInspectIconClick }); + const node = wrapper.find(".open-inspector"); + + node.at(0).simulate("click"); + node.at(1).simulate("click"); + + expect(onInspectIconClick.mock.calls).toHaveLength(2); + expect(onInspectIconClick.mock.calls[0][0]).toBe(grips[0]); + expect(onInspectIconClick.mock.calls[1][0]).toBe(grips[1]); + }); +}); + +describe("Grip - Object with disconnected nodes", () => { + const object = stubs.get("testObjectWithDisconnectedNodes"); + const renderRep = props => shallowRenderRep(object, props); + const grips = getSelectableInInspectorGrips(object); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("has the expected number of grips", () => { + expect(grips).toHaveLength(2); + }); + + it("no inspect icon when nodes are not connected to the DOM tree", () => { + const onInspectIconClick = jest.fn(); + const wrapper = renderRep({ onInspectIconClick }); + + const node = wrapper.find(".open-inspector"); + expect(node.exists()).toBe(false); + }); +}); + +describe("Grip - Object with getter", () => { + const object = stubs.get("TestObjectWithGetter"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = "Object { x: Getter }"; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("{…}"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Object"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +describe("Grip - Object with setter", () => { + const object = stubs.get("TestObjectWithSetter"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = "Object { x: Setter }"; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("{…}"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Object"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +describe("Grip - Object with getter and setter", () => { + const object = stubs.get("TestObjectWithGetterAndSetter"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = "Object { x: Getter & Setter }"; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("{…}"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Object"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +describe("Grip - Object with symbol properties", () => { + const object = stubs.get("TestObjectWithSymbolProperties"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = + 'Object { x: 10, Symbol(): "first unnamed symbol", ' + + 'Symbol(): "second unnamed symbol", … }'; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("{…}"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Object"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe( + 'Object { x: 10, Symbol(): "first unnamed symbol", ' + + 'Symbol(): "second unnamed symbol", Symbol("named"): "named symbol", ' + + "Symbol(Symbol.iterator): () }" + ); + }); +}); + +describe("Grip - Object with more than max symbol properties", () => { + const object = stubs.get("TestObjectWithMoreThanMaxSymbolProperties"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = + 'Object { Symbol("i-0"): "value-0", Symbol("i-1"): "value-1", ' + + 'Symbol("i-2"): "value-2", … }'; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("{…}"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Object"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe( + 'Object { Symbol("i-0"): "value-0", Symbol("i-1"): "value-1", ' + + 'Symbol("i-2"): "value-2", Symbol("i-3"): "value-3", ' + + 'Symbol("i-4"): "value-4", Symbol("i-5"): "value-5", ' + + 'Symbol("i-6"): "value-6", Symbol("i-7"): "value-7", ' + + 'Symbol("i-8"): "value-8", Symbol("i-9"): "value-9", … }' + ); + }); +}); + +describe("Grip - Without preview", () => { + // Test object: `[1, "foo", {}]` + const object = gripArrayStubs.get("testMaxProps").preview.items[2]; + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = "Object { }"; + + let component = renderRep({ mode: undefined }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.TINY }); + expect(component.text()).toBe("{}"); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.HEADER }); + expect(component.text()).toBe("Object"); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.SHORT }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ mode: MODE.LONG }); + expect(component.text()).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + }); +}); + +describe("Grip - Generator object", () => { + // Test object: + // function* genFunc() { + // var a = 5; while (a < 10) { yield a++; } + // }; + // genFunc(); + const object = stubs.get("Generator"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = "Generator { }"; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("Generator"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Generator"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +describe("Grip - DeadObject object", () => { + // Test object (executed in a privileged content, like about:preferences): + // `var s = Cu.Sandbox(null);Cu.nukeSandbox(s);s;` + + const object = stubs.get("DeadObject"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallowRenderRep(object, props); + const defaultOutput = "DeadObject { }"; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("DeadObject"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("DeadObject"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +// TODO: Re-enable and fix this test. +describe.skip("Grip - Object with __proto__ property", () => { + const object = stubs.get("ObjectWith__proto__Property"); + + it("correctly selects Grip Rep", () => { + expect(getRep(object)).toBe(Grip.rep); + }); + + it("renders as expected", () => { + const renderRep = props => shallow(Rep({ object, ...props })); + const defaultOutput = "Object { __proto__: [] }"; + + expect(renderRep({ mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.TINY }).text()).toBe("{…}"); + expect(renderRep({ mode: MODE.HEADER }).text()).toBe("Object"); + expect(renderRep({ mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep({ mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +// Test that object that might look like raw objects or arrays are rendered +// as grips when the `noGrip` parameter is not passed. +describe("Object - noGrip prop", () => { + it("empty object", () => { + expect(getRep({})).toBe(Grip.rep); + }); + + it("object with custom property", () => { + expect(getRep({ foo: 123 })).toBe(Grip.rep); + }); + + it("empty array", () => { + expect(getRep([])).toBe(Grip.rep); + }); + + it("array with some item", () => { + expect(getRep([123])).toBe(Grip.rep); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/helper-tests.test.js b/devtools/client/shared/components/test/node/components/reps/helper-tests.test.js new file mode 100644 index 0000000000..6fc1be64a3 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/helper-tests.test.js @@ -0,0 +1,122 @@ +/* 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 { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); +const { + getGripLengthBubbleText, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +describe("getGripLengthBubbleText - Zero length", () => { + const object = stubs.get("testBasic"); + + it("length bubble is invisible", () => { + const output = ""; + let text = getGripLengthBubbleText(object, { mode: undefined }); + expect(text).toBe(output); + + text = getGripLengthBubbleText(object, { mode: MODE.TINY }); + expect(text).toBe(output); + + text = getGripLengthBubbleText(object, { mode: MODE.SHORT }); + expect(text).toBe(output); + + text = getGripLengthBubbleText(object, { mode: MODE.LONG }); + expect(text).toBe(output); + }); + + it("length bubble is visible", () => { + const output = "(0)"; + let text = getGripLengthBubbleText(object, { + mode: undefined, + showZeroLength: true, + }); + expect(text).toBe(output); + + text = getGripLengthBubbleText(object, { + mode: MODE.TINY, + showZeroLength: true, + }); + expect(text).toBe(output); + + text = getGripLengthBubbleText(object, { + mode: MODE.SHORT, + showZeroLength: true, + }); + expect(text).toBe(output); + + text = getGripLengthBubbleText(object, { + mode: MODE.LONG, + showZeroLength: true, + }); + expect(text).toBe(output); + }); +}); + +describe("getGripLengthBubbleText - Obvious length for some modes", () => { + const object = stubs.get("testMoreThanShortMaxProps"); + const visibleOutput = `(${object.preview.length})`; + + it("text renders as expected", () => { + let text = getGripLengthBubbleText(object, { mode: undefined }); + expect(text).toBe(visibleOutput); + + text = getGripLengthBubbleText(object, { mode: MODE.TINY }); + expect(text).toBe(visibleOutput); + + text = getGripLengthBubbleText(object, { mode: MODE.SHORT }); + expect(text).toBe(visibleOutput); + + text = getGripLengthBubbleText(object, { mode: MODE.LONG }); + expect(text).toBe(visibleOutput); + + const visibilityThreshold = 5; + text = getGripLengthBubbleText(object, { + mode: undefined, + visibilityThreshold, + }); + expect(text).toBe(visibleOutput); + + text = getGripLengthBubbleText(object, { + mode: MODE.TINY, + visibilityThreshold, + }); + expect(text).toBe(visibleOutput); + + text = getGripLengthBubbleText(object, { + mode: MODE.SHORT, + visibilityThreshold, + }); + expect(text).toBe(visibleOutput); + + text = getGripLengthBubbleText(object, { + mode: MODE.LONG, + visibilityThreshold, + }); + expect(text).toBe(""); + }); +}); + +describe("getGripLengthBubbleText - Visible length", () => { + const object = stubs.get("testMoreThanLongMaxProps"); + const output = `(${object.preview.length})`; + + it("length bubble is always visible", () => { + let text = getGripLengthBubbleText(object, { mode: undefined }); + expect(text).toBe(output); + + text = getGripLengthBubbleText(object, { mode: MODE.TINY }); + expect(text).toBe(output); + + text = getGripLengthBubbleText(object, { mode: MODE.SHORT }); + expect(text).toBe(output); + + text = getGripLengthBubbleText(object, { mode: MODE.LONG }); + expect(text).toBe(output); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/infinity.test.js b/devtools/client/shared/components/test/node/components/reps/infinity.test.js new file mode 100644 index 0000000000..93fd310917 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/infinity.test.js @@ -0,0 +1,70 @@ +/* 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 { shallow } = require("enzyme"); + +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { InfinityRep, Rep } = REPS; + +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/infinity.js"); + +describe("testInfinity", () => { + const stub = stubs.get("Infinity"); + + it("Rep correctly selects Infinity Rep", () => { + expect(getRep(stub)).toBe(InfinityRep.rep); + }); + + it("Infinity rep has expected text content for Infinity", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + }) + ); + expect(renderedComponent.text()).toEqual("Infinity"); + }); + + it("Infinity rep has expected title content for Infinity", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + expect(renderedComponent.prop("title")).toEqual("Infinity"); + }); +}); + +describe("testNegativeInfinity", () => { + const stub = stubs.get("NegativeInfinity"); + + it("Rep correctly selects Infinity Rep", () => { + expect(getRep(stub)).toBe(InfinityRep.rep); + }); + + it("Infinity rep has expected text content for negative Infinity", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + }) + ); + expect(renderedComponent.text()).toEqual("-Infinity"); + }); + + it("Infinity rep has expected title content for negative Infinity", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + expect(renderedComponent.prop("title")).toEqual("-Infinity"); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/long-string.test.js b/devtools/client/shared/components/test/node/components/reps/long-string.test.js new file mode 100644 index 0000000000..ea6bb43e97 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/long-string.test.js @@ -0,0 +1,135 @@ +/* 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 { shallow } = require("enzyme"); +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { + ELLIPSIS, +} = require("resource://devtools/client/shared/components/reps/reps/rep-utils.js"); + +const { + expectActorAttribute, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +const { StringRep } = REPS; +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/long-string.js"); + +function quoteNewlines(text) { + return text.split("\n").join("\\n"); +} + +describe("long StringRep", () => { + it("selects String Rep", () => { + const stub = stubs.get("testMultiline"); + + expect(getRep(stub)).toEqual(StringRep.rep); + }); + + it("renders with expected text content for multiline string", () => { + const stub = stubs.get("testMultiline"); + const renderedComponent = shallow( + StringRep.rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual( + quoteNewlines(`"${stub.initial}${ELLIPSIS}"`) + ); + expectActorAttribute(renderedComponent, stub.actor); + }); + + it( + "renders with expected text content for multiline string with " + + "specified number of characters", + () => { + const stub = stubs.get("testMultiline"); + const renderedComponent = shallow( + StringRep.rep({ + object: stub, + cropLimit: 20, + }) + ); + + expect(renderedComponent.text()).toEqual( + `"a\\naaaaaaaaaaaaaaaaaa${ELLIPSIS}"` + ); + } + ); + + it("renders with expected text for multiline string when open", () => { + const stub = stubs.get("testMultiline"); + const renderedComponent = shallow( + StringRep.rep({ + object: stub, + member: { open: true }, + cropLimit: 20, + }) + ); + + expect(renderedComponent.text()).toEqual( + quoteNewlines(`"${stub.initial}${ELLIPSIS}"`) + ); + }); + + it( + "renders with expected text content when grip has a fullText" + + "property and is open", + () => { + const stub = stubs.get("testLoadedFullText"); + const renderedComponent = shallow( + StringRep.rep({ + object: stub, + member: { open: true }, + cropLimit: 20, + }) + ); + + expect(renderedComponent.text()).toEqual( + quoteNewlines(`"${stub.fullText}"`) + ); + } + ); + + it( + "renders with expected text content when grip has a fullText " + + "property and is not open", + () => { + const stub = stubs.get("testLoadedFullText"); + const renderedComponent = shallow( + StringRep.rep({ + object: stub, + cropLimit: 20, + }) + ); + + expect(renderedComponent.text()).toEqual( + `"a\\naaaaaaaaaaaaaaaaaa${ELLIPSIS}"` + ); + } + ); + + it("expected to omit quotes", () => { + const stub = stubs.get("testMultiline"); + const renderedComponent = shallow( + StringRep.rep({ + object: stub, + cropLimit: 20, + useQuotes: false, + }) + ); + + expect(renderedComponent.html()).toEqual( + '<span data-link-actor-id="server1.conn1.child1/longString58" ' + + `class="objectBox objectBox-string">a\naaaaaaaaaaaaaaaaaa${ELLIPSIS}` + + "</span>" + ); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/nan.test.js b/devtools/client/shared/components/test/node/components/reps/nan.test.js new file mode 100644 index 0000000000..429e70acab --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/nan.test.js @@ -0,0 +1,43 @@ +/* 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 { shallow } = require("enzyme"); + +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { NaNRep, Rep } = REPS; + +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/nan.js"); + +describe("NaN", () => { + const stub = stubs.get("NaN"); + + it("selects NaN Rep as expected", () => { + expect(getRep(stub)).toBe(NaNRep.rep); + }); + + it("renders NaN Rep as expected", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + }) + ); + expect(renderedComponent).toMatchSnapshot(); + }); + + it("NaN rep renders with the correct title element", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + expect(renderedComponent.prop("title")).toBe("NaN"); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/null.test.js b/devtools/client/shared/components/test/node/components/reps/null.test.js new file mode 100644 index 0000000000..af5f615cad --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/null.test.js @@ -0,0 +1,47 @@ +/* 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 { shallow } = require("enzyme"); + +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { Null, Rep } = REPS; + +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/null.js"); + +describe("testNull", () => { + const stub = stubs.get("Null"); + + it("Rep correctly selects Null Rep", () => { + expect(getRep(stub)).toBe(Null.rep); + }); + + it("Rep correctly selects Null Rep for plain JS null object", () => { + expect(getRep(null, undefined, true)).toBe(Null.rep); + }); + + it("Null rep has expected text content", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + }) + ); + expect(renderedComponent.text()).toEqual("null"); + }); + + it("Null rep displays null for title", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + expect(renderedComponent.prop("title")).toEqual("null"); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/number.test.js b/devtools/client/shared/components/test/node/components/reps/number.test.js new file mode 100644 index 0000000000..c0bbab3b0a --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/number.test.js @@ -0,0 +1,136 @@ +/* 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 { shallow } = require("enzyme"); +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const { Number, Rep } = REPS; +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/number.js"); + +describe("Int", () => { + const stub = stubs.get("Int"); + + it("correctly selects Number Rep for Integer value", () => { + expect(getRep(stub)).toBe(Number.rep); + }); + + it("renders with expected text content for integer", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("5"); + expect(renderedComponent.prop("title")).toBe("5"); + }); +}); + +describe("Boolean", () => { + const stubTrue = stubs.get("True"); + const stubFalse = stubs.get("False"); + + it("correctly selects Number Rep for boolean value", () => { + expect(getRep(stubTrue)).toBe(Number.rep); + }); + + it("renders with expected text content for boolean true", () => { + const renderedComponent = shallow( + Rep({ + object: stubTrue, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("true"); + expect(renderedComponent.prop("title")).toBe("true"); + }); + + it("renders with expected text content for boolean false", () => { + const renderedComponent = shallow( + Rep({ + object: stubFalse, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("false"); + expect(renderedComponent.prop("title")).toBe("false"); + }); +}); + +describe("Negative Zero", () => { + const stubNegativeZeroGrip = stubs.get("NegZeroGrip"); + const stubNegativeZeroValue = -0; + + it("correctly selects Number Rep for negative zero grip", () => { + expect(getRep(stubNegativeZeroGrip)).toBe(Number.rep); + }); + + it("correctly selects Number Rep for negative zero value", () => { + expect(getRep(stubNegativeZeroValue)).toBe(Number.rep); + }); + + it("renders with expected text content for negative zero grip", () => { + const renderedComponent = shallow( + Rep({ + object: stubNegativeZeroGrip, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("-0"); + expect(renderedComponent.prop("title")).toBe("-0"); + }); + + it("renders with expected text content for negative zero value", () => { + const renderedComponent = shallow( + Rep({ + object: stubNegativeZeroValue, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("-0"); + expect(renderedComponent.prop("title")).toBe("-0"); + }); +}); + +describe("Zero", () => { + it("correctly selects Number Rep for zero value", () => { + expect(getRep(0)).toBe(Number.rep); + }); + + it("renders with expected text content for zero value", () => { + const renderedComponent = shallow( + Rep({ + object: 0, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("0"); + expect(renderedComponent.prop("title")).toBe("0"); + }); +}); + +describe("Unsafe Int", () => { + it("renders with expected test content for a long number", () => { + const renderedComponent = shallow( + Rep({ + // eslint-disable-next-line no-loss-of-precision + object: 900719925474099122, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("900719925474099100"); + expect(renderedComponent.prop("title")).toBe("900719925474099100"); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/object-with-text.test.js b/devtools/client/shared/components/test/node/components/reps/object-with-text.test.js new file mode 100644 index 0000000000..164024f7e6 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/object-with-text.test.js @@ -0,0 +1,66 @@ +/* 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 { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { shallow } = require("enzyme"); +const { + expectActorAttribute, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/object-with-text.js"); +const { ObjectWithText, Rep } = REPS; + +describe("Object with text - CSSStyleRule", () => { + const gripStub = stubs.get("ShadowRule"); + + // Test that correct rep is chosen + it("selects ObjectsWithText Rep", () => { + expect(getRep(gripStub)).toEqual(ObjectWithText.rep); + }); + + // Test rendering + it("renders with the correct text content", () => { + const renderedComponent = shallow( + Rep({ + object: gripStub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual('CSSStyleRule ".Shadow"'); + expect(renderedComponent.prop("title")).toEqual('CSSStyleRule ".Shadow"'); + expectActorAttribute(renderedComponent, gripStub.actor); + }); +}); + +describe("Object with text - CSSMediaRule", () => { + const gripStub = stubs.get("CSSMediaRule"); + + // Test that correct rep is chosen + it("selects ObjectsWithText Rep", () => { + expect(getRep(gripStub)).toEqual(ObjectWithText.rep); + }); + + // Test rendering + it("renders with the correct text content", () => { + const renderedComponent = shallow( + Rep({ + object: gripStub, + shouldRenderTooltip: true, + }) + ); + + const text = + 'CSSMediaRule "(min-height: 680px), screen and (orientation: portrait)"'; + expect(renderedComponent.text()).toEqual(text); + expect(renderedComponent.prop("title")).toEqual(text); + expectActorAttribute(renderedComponent, gripStub.actor); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/object-with-url.test.js b/devtools/client/shared/components/test/node/components/reps/object-with-url.test.js new file mode 100644 index 0000000000..055afbfde4 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/object-with-url.test.js @@ -0,0 +1,45 @@ +/* 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 { shallow } = require("enzyme"); +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const { ObjectWithURL } = REPS; +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/object-with-url.js"); +const { + expectActorAttribute, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +describe("ObjectWithURL", () => { + const stub = stubs.get("ObjectWithUrl"); + + it("selects the correct rep", () => { + expect(getRep(stub)).toEqual(ObjectWithURL.rep); + }); + + it("renders with correct class name and content", () => { + const renderedComponent = shallow( + ObjectWithURL.rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + expect(renderedComponent.text()).toBe( + "Location https://www.mozilla.org/en-US/" + ); + expect(renderedComponent.prop("title")).toBe( + "Location https://www.mozilla.org/en-US/" + ); + expect(renderedComponent.hasClass("objectBox-Location")).toBe(true); + + const innerNode = renderedComponent.find(".objectPropValue"); + expect(innerNode.text()).toBe("https://www.mozilla.org/en-US/"); + + expectActorAttribute(renderedComponent, stub.actor); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/object.test.js b/devtools/client/shared/components/test/node/components/reps/object.test.js new file mode 100644 index 0000000000..9b32e51d20 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/object.test.js @@ -0,0 +1,356 @@ +/* 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 { shallow } = require("enzyme"); +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const { Obj } = REPS; +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); + +const renderComponent = (object, props) => { + return shallow(Obj.rep({ object, ...props })); +}; + +describe("Object - Basic", () => { + const object = {}; + const defaultOutput = "Object { }"; + + it("selects the correct rep", () => { + expect(getRep(object, undefined, true)).toBe(Obj.rep); + }); + + it("renders basic object as expected", () => { + expect(renderComponent(object, { mode: undefined }).text()).toEqual( + defaultOutput + ); + expect(renderComponent(object, { mode: undefined }).prop("title")).toEqual( + "Object" + ); + + expect(renderComponent(object, { mode: MODE.TINY }).text()).toEqual("{}"); + expect(renderComponent(object, { mode: MODE.TINY }).prop("title")).toEqual( + "Object" + ); + + expect(renderComponent(object, { mode: MODE.SHORT }).text()).toEqual( + defaultOutput + ); + expect(renderComponent(object, { mode: MODE.LONG }).text()).toEqual( + defaultOutput + ); + }); +}); + +describe("Object - Max props", () => { + const object = { a: "a", b: "b", c: "c" }; + const defaultOutput = 'Object { a: "a", b: "b", c: "c" }'; + + it("renders object with max props as expected", () => { + expect(renderComponent(object, { mode: undefined }).text()).toEqual( + defaultOutput + ); + expect(renderComponent(object, { mode: MODE.TINY }).text()).toEqual("{…}"); + expect(renderComponent(object, { mode: MODE.SHORT }).text()).toEqual( + defaultOutput + ); + expect(renderComponent(object, { mode: MODE.LONG }).text()).toEqual( + defaultOutput + ); + }); +}); + +describe("Object - Many props", () => { + const object = {}; + for (let i = 0; i < 100; i++) { + object[`p${i}`] = i; + } + const defaultOutput = "Object { p0: 0, p1: 1, p2: 2, … }"; + + it("renders object with many props as expected", () => { + expect(renderComponent(object, { mode: undefined }).text()).toEqual( + defaultOutput + ); + expect(renderComponent(object, { mode: MODE.TINY }).text()).toEqual("{…}"); + expect(renderComponent(object, { mode: MODE.SHORT }).text()).toEqual( + defaultOutput + ); + expect(renderComponent(object, { mode: MODE.LONG }).text()).toEqual( + defaultOutput + ); + }); +}); + +describe("Object - Uninteresting props", () => { + const object = { a: undefined, b: undefined, c: "c", d: 0 }; + const defaultOutput = 'Object { c: "c", d: 0, a: undefined, … }'; + + it("renders object with uninteresting props as expected", () => { + expect(renderComponent(object, { mode: undefined }).text()).toEqual( + defaultOutput + ); + expect(renderComponent(object, { mode: MODE.TINY }).text()).toEqual("{…}"); + expect(renderComponent(object, { mode: MODE.SHORT }).text()).toEqual( + defaultOutput + ); + expect(renderComponent(object, { mode: MODE.LONG }).text()).toEqual( + defaultOutput + ); + }); +}); + +describe("Object - Escaped property names", () => { + const object = { "": 1, "quote-this": 2, noquotes: 3 }; + const defaultOutput = 'Object { "": 1, "quote-this": 2, noquotes: 3 }'; + + it("renders object with escaped property names as expected", () => { + expect(renderComponent(object, { mode: undefined }).text()).toEqual( + defaultOutput + ); + expect(renderComponent(object, { mode: MODE.TINY }).text()).toEqual("{…}"); + expect(renderComponent(object, { mode: MODE.SHORT }).text()).toEqual( + defaultOutput + ); + expect(renderComponent(object, { mode: MODE.LONG }).text()).toEqual( + defaultOutput + ); + }); +}); + +describe("Object - Nested", () => { + const object = { + objProp: { + id: 1, + arr: [2], + }, + strProp: "test string", + arrProp: [1], + }; + const defaultOutput = + 'Object { strProp: "test string", objProp: {…},' + " arrProp: […] }"; + + it("renders nested object as expected", () => { + expect( + renderComponent(object, { mode: undefined, noGrip: true }).text() + ).toEqual(defaultOutput); + expect( + renderComponent(object, { mode: MODE.TINY, noGrip: true }).text() + ).toEqual("{…}"); + expect( + renderComponent(object, { mode: MODE.SHORT, noGrip: true }).text() + ).toEqual(defaultOutput); + expect( + renderComponent(object, { mode: MODE.LONG, noGrip: true }).text() + ).toEqual(defaultOutput); + }); +}); + +describe("Object - More prop", () => { + const object = { + a: undefined, + b: 1, + more: 2, + d: 3, + }; + const defaultOutput = "Object { b: 1, more: 2, d: 3, … }"; + + it("renders object with more properties as expected", () => { + expect(renderComponent(object, { mode: undefined }).text()).toEqual( + defaultOutput + ); + expect(renderComponent(object, { mode: MODE.TINY }).text()).toEqual("{…}"); + expect(renderComponent(object, { mode: MODE.SHORT }).text()).toEqual( + defaultOutput + ); + expect(renderComponent(object, { mode: MODE.LONG }).text()).toEqual( + defaultOutput + ); + }); +}); + +describe("Object - Custom Title", () => { + const customTitle = "MyCustomObject"; + const object = { a: "a", b: "b", c: "c" }; + const defaultOutput = `${customTitle} { a: "a", b: "b", c: "c" }`; + + it("renders object with more properties as expected", () => { + expect( + renderComponent(object, { mode: undefined, title: customTitle }).text() + ).toEqual(defaultOutput); + expect( + renderComponent(object, { mode: undefined, title: customTitle }).prop( + "title" + ) + ).toEqual(customTitle); + expect( + renderComponent(object, { mode: MODE.TINY, title: customTitle }).text() + ).toEqual(customTitle); + expect( + renderComponent(object, { mode: MODE.TINY, title: customTitle }).prop( + "title" + ) + ).toEqual(customTitle); + expect( + renderComponent(object, { mode: MODE.SHORT, title: customTitle }).text() + ).toEqual(defaultOutput); + expect( + renderComponent(object, { mode: MODE.LONG, title: customTitle }).text() + ).toEqual(defaultOutput); + }); +}); + +// Test that object that might look like Grips are rendered as Object when +// passed the `noGrip` property. +describe("Object - noGrip prop", () => { + it("object with type property", () => { + expect(getRep({ type: "string" }, undefined, true)).toBe(Obj.rep); + }); + + it("object with actor property", () => { + expect(getRep({ actor: "fake/actorId" }, undefined, true)).toBe(Obj.rep); + }); + + it("Attribute grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/attribute.js"); + expect(getRep(stubs.get("Attribute"), undefined, true)).toBe(Obj.rep); + }); + + it("CommentNode grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/comment-node.js"); + expect(getRep(stubs.get("Comment"), undefined, true)).toBe(Obj.rep); + }); + + it("DateTime grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/date-time.js"); + expect(getRep(stubs.get("DateTime"), undefined, true)).toBe(Obj.rep); + }); + + it("Document grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/document.js"); + expect(getRep(stubs.get("Document"), undefined, true)).toBe(Obj.rep); + }); + + it("ElementNode grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/element-node.js"); + expect(getRep(stubs.get("BodyNode"), undefined, true)).toBe(Obj.rep); + }); + + it("Error grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/error.js"); + expect(getRep(stubs.get("SimpleError"), undefined, true)).toBe(Obj.rep); + }); + + it("Event grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/event.js"); + expect(getRep(stubs.get("testEvent"), undefined, true)).toBe(Obj.rep); + }); + + it("Function grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/function.js"); + expect(getRep(stubs.get("Named"), undefined, true)).toBe(Obj.rep); + }); + + it("Array grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js"); + expect(getRep(stubs.get("testMaxProps"), undefined, true)).toBe(Obj.rep); + }); + + it("Map grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-map.js"); + expect(getRep(stubs.get("testSymbolKeyedMap"), undefined, true)).toBe( + Obj.rep + ); + }); + + it("Object grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/grip.js"); + expect(getRep(stubs.get("testMaxProps"), undefined, true)).toBe(Obj.rep); + }); + + it("Infinity grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/infinity.js"); + expect(getRep(stubs.get("Infinity"), undefined, true)).toBe(Obj.rep); + }); + + it("LongString grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/long-string.js"); + expect(getRep(stubs.get("testMultiline"), undefined, true)).toBe(Obj.rep); + }); + + it("NaN grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/nan.js"); + expect(getRep(stubs.get("NaN"), undefined, true)).toBe(Obj.rep); + }); + + it("Null grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/null.js"); + expect(getRep(stubs.get("Null"), undefined, true)).toBe(Obj.rep); + }); + + it("Number grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/number.js"); + expect(getRep(stubs.get("NegZeroGrip"), undefined, true)).toBe(Obj.rep); + }); + + it("ObjectWithText grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/object-with-text.js"); + expect(getRep(stubs.get("ShadowRule"), undefined, true)).toBe(Obj.rep); + }); + + it("ObjectWithURL grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/object-with-url.js"); + expect(getRep(stubs.get("ObjectWithUrl"), undefined, true)).toBe(Obj.rep); + }); + + it("Promise grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/promise.js"); + expect(getRep(stubs.get("Pending"), undefined, true)).toBe(Obj.rep); + }); + + it("RegExp grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/regexp.js"); + expect(getRep(stubs.get("RegExp"), undefined, true)).toBe(Obj.rep); + }); + + it("Stylesheet grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/stylesheet.js"); + expect(getRep(stubs.get("StyleSheet"), undefined, true)).toBe(Obj.rep); + }); + + it("Symbol grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/symbol.js"); + expect(getRep(stubs.get("Symbol"), undefined, true)).toBe(Obj.rep); + }); + + it("TextNode grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/text-node.js"); + expect(getRep(stubs.get("testRendering"), undefined, true)).toBe(Obj.rep); + }); + + it("Undefined grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/undefined.js"); + expect(getRep(stubs.get("Undefined"), undefined, true)).toBe(Obj.rep); + }); + + it("Window grip", () => { + const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/window.js"); + expect(getRep(stubs.get("Window"), undefined, true)).toBe(Obj.rep); + }); + + it("Object with class property", () => { + const object = { + class: "Array", + }; + expect(getRep(object, undefined, true)).toBe(Obj.rep); + + expect( + renderComponent(object, { mode: MODE.SHORT, noGrip: true }).text() + ).toEqual('Object { class: "Array" }'); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/promise.test.js b/devtools/client/shared/components/test/node/components/reps/promise.test.js new file mode 100644 index 0000000000..5c340dccfd --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/promise.test.js @@ -0,0 +1,229 @@ +/* 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"; + +/* global jest */ +const { shallow } = require("enzyme"); +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const { PromiseRep } = REPS; +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/promise.js"); +const { + expectActorAttribute, + getSelectableInInspectorGrips, + getGripLengthBubbleText, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +const renderRep = (object, props) => { + return shallow(PromiseRep.rep({ object, ...props })); +}; + +describe("Promise - Pending", () => { + const object = stubs.get("Pending"); + const defaultOutput = 'Promise { <state>: "pending" }'; + + it("correctly selects PromiseRep Rep for pending Promise", () => { + expect(getRep(object)).toBe(PromiseRep.rep); + }); + + it("renders as expected", () => { + let component = renderRep(object, { + mode: undefined, + shouldRenderTooltip: true, + }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe("Promise"); + expectActorAttribute(component, object.actor); + + component = renderRep(object, { + mode: MODE.TINY, + shouldRenderTooltip: true, + }); + expect(component.text()).toBe('Promise { "pending" }'); + expect(component.prop("title")).toBe("Promise"); + expectActorAttribute(component, object.actor); + + component = renderRep(object, { + mode: MODE.HEADER, + shouldRenderTooltip: true, + }); + expect(component.text()).toBe("Promise"); + expect(component.prop("title")).toBe("Promise"); + expectActorAttribute(component, object.actor); + + component = renderRep(object, { + mode: MODE.SHORT, + shouldRenderTooltip: true, + }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe("Promise"); + expectActorAttribute(component, object.actor); + + component = renderRep(object, { + mode: MODE.LONG, + shouldRenderTooltip: true, + }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe("Promise"); + expectActorAttribute(component, object.actor); + }); +}); + +describe("Promise - fulfilled with string", () => { + const object = stubs.get("FulfilledWithString"); + const defaultOutput = 'Promise { <state>: "fulfilled", <value>: "foo" }'; + + it("selects PromiseRep Rep for Promise fulfilled with a string", () => { + expect(getRep(object)).toBe(PromiseRep.rep); + }); + + it("should render as expected", () => { + expect(renderRep(object, { mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep(object, { mode: MODE.TINY }).text()).toBe( + 'Promise { "fulfilled" }' + ); + expect(renderRep(object, { mode: MODE.HEADER }).text()).toBe("Promise"); + expect(renderRep(object, { mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep(object, { mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +describe("Promise - fulfilled with object", () => { + const object = stubs.get("FulfilledWithObject"); + const defaultOutput = 'Promise { <state>: "fulfilled", <value>: {…} }'; + + it("selects PromiseRep Rep for Promise fulfilled with an object", () => { + expect(getRep(object)).toBe(PromiseRep.rep); + }); + + it("should render as expected", () => { + expect(renderRep(object, { mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep(object, { mode: MODE.TINY }).text()).toBe( + 'Promise { "fulfilled" }' + ); + expect(renderRep(object, { mode: MODE.HEADER }).text()).toBe("Promise"); + expect(renderRep(object, { mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep(object, { mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +describe("Promise - fulfilled with array", () => { + const object = stubs.get("FulfilledWithArray"); + const length = getGripLengthBubbleText( + object.preview.ownProperties["<value>"].value, + { + mode: MODE.TINY, + } + ); + const out = `Promise { <state>: "fulfilled", <value>: ${length} […] }`; + + it("selects PromiseRep Rep for Promise fulfilled with an array", () => { + expect(getRep(object)).toBe(PromiseRep.rep); + }); + + it("should render as expected", () => { + expect(renderRep(object, { mode: undefined }).text()).toBe(out); + expect(renderRep(object, { mode: MODE.TINY }).text()).toBe( + 'Promise { "fulfilled" }' + ); + expect(renderRep(object, { mode: MODE.HEADER }).text()).toBe("Promise"); + expect(renderRep(object, { mode: MODE.SHORT }).text()).toBe(out); + expect(renderRep(object, { mode: MODE.LONG }).text()).toBe(out); + }); +}); + +describe("Promise - fulfilled with node", () => { + const stub = stubs.get("FulfilledWithNode"); + const grips = getSelectableInInspectorGrips(stub); + + it("has one node grip", () => { + expect(grips).toHaveLength(1); + }); + + it("calls the expected function on mouseover", () => { + const onDOMNodeMouseOver = jest.fn(); + const wrapper = renderRep(stub, { onDOMNodeMouseOver }); + const node = wrapper.find(".objectBox-node"); + + node.simulate("mouseover"); + + expect(onDOMNodeMouseOver.mock.calls).toHaveLength(1); + expect(onDOMNodeMouseOver).toHaveBeenCalledWith(grips[0]); + }); + + it("calls the expected function on mouseout", () => { + const onDOMNodeMouseOut = jest.fn(); + const wrapper = renderRep(stub, { onDOMNodeMouseOut }); + const node = wrapper.find(".objectBox-node"); + + node.simulate("mouseout"); + + expect(onDOMNodeMouseOut.mock.calls).toHaveLength(1); + expect(onDOMNodeMouseOut).toHaveBeenCalledWith(grips[0]); + }); + + it("no inspect icon when the node is not connected to the DOM tree", () => { + const renderedComponentWithoutInspectIcon = renderRep( + stubs.get("FulfilledWithDisconnectedNode") + ); + const node = renderedComponentWithoutInspectIcon.find(".open-inspector"); + + expect(node.exists()).toBe(false); + }); + + it("renders an inspect icon", () => { + const onInspectIconClick = jest.fn(); + const renderedComponent = renderRep(stub, { onInspectIconClick }); + const icon = renderedComponent.find(".open-inspector"); + + icon.simulate("click"); + + expect(icon.exists()).toBe(true); + expect(onInspectIconClick.mock.calls).toHaveLength(1); + }); +}); + +describe("Promise - rejected with number", () => { + const object = stubs.get("RejectedWithNumber"); + const defaultOutput = 'Promise { <state>: "rejected", <reason>: 123 }'; + + it("selects PromiseRep Rep for Promise rejected with an object", () => { + expect(getRep(object)).toBe(PromiseRep.rep); + }); + + it("should render as expected", () => { + expect(renderRep(object, { mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep(object, { mode: MODE.TINY }).text()).toBe( + 'Promise { "rejected" }' + ); + expect(renderRep(object, { mode: MODE.HEADER }).text()).toBe("Promise"); + expect(renderRep(object, { mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep(object, { mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); + +describe("Promise - rejected with object", () => { + const object = stubs.get("RejectedWithObject"); + const defaultOutput = 'Promise { <state>: "rejected", <reason>: {…} }'; + + it("selects PromiseRep Rep for Promise rejected with an object", () => { + expect(getRep(object)).toBe(PromiseRep.rep); + }); + + it("should render as expected", () => { + expect(renderRep(object, { mode: undefined }).text()).toBe(defaultOutput); + expect(renderRep(object, { mode: MODE.TINY }).text()).toBe( + 'Promise { "rejected" }' + ); + expect(renderRep(object, { mode: MODE.HEADER }).text()).toBe("Promise"); + expect(renderRep(object, { mode: MODE.SHORT }).text()).toBe(defaultOutput); + expect(renderRep(object, { mode: MODE.LONG }).text()).toBe(defaultOutput); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/regexp.test.js b/devtools/client/shared/components/test/node/components/reps/regexp.test.js new file mode 100644 index 0000000000..7e1ea841b8 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/regexp.test.js @@ -0,0 +1,59 @@ +/* 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 { shallow } = require("enzyme"); +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const { Rep, RegExp } = REPS; +const { + ELLIPSIS, +} = require("resource://devtools/client/shared/components/reps/reps/rep-utils.js"); +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/regexp.js"); +const { + expectActorAttribute, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +describe("test RegExp", () => { + const stub = stubs.get("RegExp"); + + it("selects RegExp Rep", () => { + expect(getRep(stub)).toEqual(RegExp.rep); + }); + + it("renders with expected text content", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("/ab+c/i"); + expect(renderedComponent.prop("title")).toEqual("/ab+c/i"); + expectActorAttribute(renderedComponent, stub.actor); + }); + + it("renders regexp with longString displayString with expected text content", () => { + const longStringDisplayStringRegexpStub = stubs.get( + "longString displayString RegExp" + ); + const renderedComponent = shallow( + Rep({ + object: longStringDisplayStringRegexpStub, + }) + ); + + expect(renderedComponent.text()).toEqual( + `/${"ab ".repeat(333)}${ELLIPSIS}` + ); + expectActorAttribute( + renderedComponent, + longStringDisplayStringRegexpStub.actor + ); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/string-with-url.test.js b/devtools/client/shared/components/test/node/components/reps/string-with-url.test.js new file mode 100644 index 0000000000..a7f287a302 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/string-with-url.test.js @@ -0,0 +1,630 @@ +/* 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"; + +/* global jest */ +const { mount } = require("enzyme"); +const { + REPS, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const { Rep } = REPS; +const { + getGripLengthBubbleText, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +const renderRep = (string, props) => + mount( + Rep({ + object: string, + ...props, + }) + ); + +const testLinkClick = (link, openLink, url) => { + let syntheticEvent; + const preventDefault = jest.fn().mockImplementation(function () { + // This refers to the event object for which preventDefault is called (in + // this case it is the syntheticEvent that is passed to onClick and + // consequently to openLink). + syntheticEvent = this; + }); + + link.simulate("click", { preventDefault }); + // Prevent defaults behavior on click + expect(preventDefault).toHaveBeenCalled(); + expect(openLink).toHaveBeenCalledWith(url, syntheticEvent); +}; + +describe("test String with URL", () => { + it("renders a URL", () => { + const url = "http://example.com"; + const openLink = jest.fn(); + const element = renderRep(url, { openLink, useQuotes: false }); + expect(element.text()).toEqual(url); + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("renders a href when openLink isn't defined", () => { + const url = "http://example.com"; + const element = renderRep(url, { useQuotes: false }); + expect(element.text()).toEqual(url); + const link = element.find("a"); + expect(link.prop("href")).toBe(null); + expect(link.prop("title")).toBe(url); + expect(link.prop("rel")).toBe("noopener noreferrer"); + }); + + it("renders a href when no openLink but isInContentPage is true", () => { + const url = "http://example.com"; + const element = renderRep(url, { useQuotes: false, isInContentPage: true }); + expect(element.text()).toEqual(url); + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + expect(link.prop("rel")).toBe("noopener noreferrer"); + }); + + it("renders a simple quoted URL", () => { + const url = "http://example.com"; + const string = `'${url}'`; + const openLink = jest.fn(); + const element = renderRep(string, { openLink, useQuotes: false }); + expect(element.text()).toEqual(string); + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("renders a double quoted URL", () => { + const url = "http://example.com"; + const string = `"${url}"`; + const openLink = jest.fn(); + const element = renderRep(string, { openLink, useQuotes: false }); + expect(element.text()).toEqual(string); + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("renders a quoted URL when useQuotes is true", () => { + const url = "http://example.com"; + const string = `"${url}"`; + const openLink = jest.fn(); + const element = renderRep(string, { openLink, useQuotes: true }); + expect(element.text()).toEqual(`'"${url}"'`); + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("renders a simple https URL", () => { + const url = "https://example.com"; + const openLink = jest.fn(); + const element = renderRep(url, { openLink, useQuotes: false }); + expect(element.text()).toEqual(url); + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("renders a simple http URL with one slash", () => { + const url = "https:/example.com"; + const openLink = jest.fn(); + const element = renderRep(url, { openLink, useQuotes: false }); + expect(element.text()).toEqual(url); + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("renders a URL with port", () => { + const url = "https://example.com:443"; + const openLink = jest.fn(); + const element = renderRep(url, { openLink, useQuotes: false }); + expect(element.text()).toEqual(url); + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("renders a URL with non-empty path", () => { + const url = "http://example.com/foo"; + const openLink = jest.fn(); + const element = renderRep(url, { openLink, useQuotes: false }); + expect(element.text()).toEqual(url); + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("renders a URL when surrounded by non-URL tokens", () => { + const url = "http://example.com"; + const string = `foo ${url} bar`; + const openLink = jest.fn(); + const element = renderRep(string, { openLink, useQuotes: false }); + expect(element.text()).toEqual(string); + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("renders a URL and whitespace is be preserved", () => { + const url = "http://example.com"; + const string = `foo\n${url}\nbar\n`; + const openLink = jest.fn(); + const element = renderRep(string, { openLink, useQuotes: false }); + expect(element.text()).toEqual(string); + + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("renders multiple URLs", () => { + const url1 = "http://example.com"; + const url2 = "https://example.com/foo"; + const string = `${url1} ${url2}`; + const openLink = jest.fn(); + const element = renderRep(string, { openLink, useQuotes: false }); + expect(element.text()).toEqual(string); + const links = element.find("a"); + expect(links).toHaveLength(2); + + const firstLink = links.at(0); + expect(firstLink.prop("href")).toBe(url1); + expect(firstLink.prop("title")).toBe(url1); + testLinkClick(firstLink, openLink, url1); + + const secondLink = links.at(1); + expect(secondLink.prop("href")).toBe(url2); + expect(secondLink.prop("title")).toBe(url2); + testLinkClick(secondLink, openLink, url2); + }); + + it("renders multiple URLs with various spacing", () => { + const url1 = "http://example.com"; + const url2 = "https://example.com/foo"; + const string = ` ${url1} ${url2} ${url2} ${url1} `; + const element = renderRep(string, { useQuotes: false }); + expect(element.text()).toEqual(string); + const links = element.find("a"); + expect(links).toHaveLength(4); + }); + + it("renders a cropped URL", () => { + const url = "http://example.com"; + const openLink = jest.fn(); + const element = renderRep(url, { + openLink, + useQuotes: false, + cropLimit: 15, + }); + + expect(element.text()).toEqual("http://…ple.com"); + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("renders a non-cropped URL", () => { + const url = "http://example.com/foobarbaz"; + const openLink = jest.fn(); + const element = renderRep(url, { + openLink, + useQuotes: false, + cropLimit: 50, + }); + + expect(element.text()).toEqual(url); + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("renders URL on an open string", () => { + const url = "http://example.com"; + const openLink = jest.fn(); + const element = renderRep(url, { + openLink, + useQuotes: false, + member: { + open: true, + }, + }); + + expect(element.text()).toEqual(url); + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("renders URLs with a stripped string between", () => { + const text = "- http://example.fr --- http://example.us -"; + const openLink = jest.fn(); + const element = renderRep(text, { + openLink, + useQuotes: false, + cropLimit: 41, + }); + + expect(element.text()).toEqual("- http://example.fr … http://example.us -"); + const linkFr = element.find("a").at(0); + expect(linkFr.prop("href")).toBe("http://example.fr"); + expect(linkFr.prop("title")).toBe("http://example.fr"); + + const linkUs = element.find("a").at(1); + expect(linkUs.prop("href")).toBe("http://example.us"); + expect(linkUs.prop("title")).toBe("http://example.us"); + }); + + it("renders URLs with a cropped string between", () => { + const text = "- http://example.fr ---- http://example.us -"; + const openLink = jest.fn(); + const element = renderRep(text, { + openLink, + useQuotes: false, + cropLimit: 42, + }); + + expect(element.text()).toEqual( + "- http://example.fr -…- http://example.us -" + ); + const linkFr = element.find("a").at(0); + expect(linkFr.prop("href")).toBe("http://example.fr"); + expect(linkFr.prop("title")).toBe("http://example.fr"); + + const linkUs = element.find("a").at(1); + expect(linkUs.prop("href")).toBe("http://example.us"); + expect(linkUs.prop("title")).toBe("http://example.us"); + }); + + it("renders successive cropped URLs, 1 at the start, 1 at the end", () => { + const text = "- http://example-long.fr http://example.us -"; + const openLink = jest.fn(); + const element = renderRep(text, { + openLink, + useQuotes: false, + cropLimit: 20, + }); + + expect(element.text()).toEqual("- http://e…ample.us -"); + const linkFr = element.find("a").at(0); + expect(linkFr.prop("href")).toBe("http://example-long.fr"); + expect(linkFr.prop("title")).toBe("http://example-long.fr"); + + const linkUs = element.find("a").at(1); + expect(linkUs.prop("href")).toBe("http://example.us"); + expect(linkUs.prop("title")).toBe("http://example.us"); + }); + + it("renders successive URLs, one cropped in the middle", () => { + const text = + "- http://example-long.fr http://example.com http://example.us -"; + const openLink = jest.fn(); + const element = renderRep(text, { + openLink, + useQuotes: false, + cropLimit: 60, + }); + + expect(element.text()).toEqual( + "- http://example-long.fr http:…xample.com http://example.us -" + ); + const linkFr = element.find("a").at(0); + expect(linkFr.prop("href")).toBe("http://example-long.fr"); + expect(linkFr.prop("title")).toBe("http://example-long.fr"); + + const linkCom = element.find("a").at(1); + expect(linkCom.prop("href")).toBe("http://example.com"); + expect(linkCom.prop("title")).toBe("http://example.com"); + + const linkUs = element.find("a").at(2); + expect(linkUs.prop("href")).toBe("http://example.us"); + expect(linkUs.prop("title")).toBe("http://example.us"); + }); + + it("renders successive cropped URLs with cropped elements between", () => { + const text = + "- http://example.fr test http://example.es test http://example.us -"; + const openLink = jest.fn(); + const element = renderRep(text, { + openLink, + useQuotes: false, + cropLimit: 20, + }); + + expect(element.text()).toEqual("- http://e…ample.us -"); + const linkFr = element.find("a").at(0); + expect(linkFr.prop("href")).toBe("http://example.fr"); + expect(linkFr.prop("title")).toBe("http://example.fr"); + + const linkUs = element.find("a").at(1); + expect(linkUs.prop("href")).toBe("http://example.us"); + expect(linkUs.prop("title")).toBe("http://example.us"); + }); + + it("renders a cropped URL followed by a cropped string", () => { + const text = "http://example.fr abcdefghijkl"; + const openLink = jest.fn(); + const element = renderRep(text, { + openLink, + useQuotes: false, + cropLimit: 20, + }); + + expect(element.text()).toEqual("http://exa…cdefghijkl"); + const linkFr = element.find("a").at(0); + expect(linkFr.prop("href")).toBe("http://example.fr"); + expect(linkFr.prop("title")).toBe("http://example.fr"); + }); + + it("renders a cropped string followed by a cropped URL", () => { + const text = "abcdefghijkl stripped http://example.fr "; + const openLink = jest.fn(); + const element = renderRep(text, { + openLink, + useQuotes: false, + cropLimit: 20, + }); + + expect(element.text()).toEqual("abcdefghij…xample.fr "); + const linkFr = element.find("a").at(0); + expect(linkFr.prop("href")).toBe("http://example.fr"); + expect(linkFr.prop("title")).toBe("http://example.fr"); + }); + + it("renders URLs without unrelated characters", () => { + const text = + "global(http://example.com) and local(http://example.us)" + + " and maybe https://example.fr, “https://example.cz“, https://example.es?"; + const openLink = jest.fn(); + const element = renderRep(text, { + openLink, + useQuotes: false, + }); + + expect(element.text()).toEqual(text); + const linkCom = element.find("a").at(0); + expect(linkCom.prop("href")).toBe("http://example.com"); + + const linkUs = element.find("a").at(1); + expect(linkUs.prop("href")).toBe("http://example.us"); + + const linkFr = element.find("a").at(2); + expect(linkFr.prop("href")).toBe("https://example.fr"); + + const linkCz = element.find("a").at(3); + expect(linkCz.prop("href")).toBe("https://example.cz"); + + const linkEs = element.find("a").at(4); + expect(linkEs.prop("href")).toBe("https://example.es"); + }); + + it("renders a cropped URL with urlCropLimit", () => { + const xyzUrl = "http://xyz.com/abcdefghijklmnopqrst"; + const text = `${xyzUrl} is the best`; + const openLink = jest.fn(); + const element = renderRep(text, { + openLink, + useQuotes: false, + urlCropLimit: 20, + }); + + expect(element.text()).toEqual(text); + const link = element.find("a.cropped-url").at(0); + expect(link.prop("href")).toBe(xyzUrl); + expect(link.prop("title")).toBe(xyzUrl); + const linkParts = link.find("span"); + expect(linkParts.at(0).hasClass("cropped-url-start")).toBe(true); + expect(linkParts.at(0).text()).toEqual("http://xyz"); + expect(linkParts.at(1).hasClass("cropped-url-middle")).toBe(true); + expect(linkParts.at(2).hasClass("cropped-url-end")).toBe(true); + expect(linkParts.at(2).text()).toEqual("klmnopqrst"); + }); + + it("renders multiple cropped URL", () => { + const xyzUrl = "http://xyz.com/abcdefghijklmnopqrst"; + const abcUrl = "http://abc.com/abcdefghijklmnopqrst"; + const text = `${xyzUrl} is lit, not ${abcUrl}`; + const openLink = jest.fn(); + const element = renderRep(text, { + openLink, + useQuotes: false, + urlCropLimit: 20, + }); + + expect(element.text()).toEqual(`${xyzUrl} is lit, not ${abcUrl}`); + + const links = element.find("a.cropped-url"); + const xyzLink = links.at(0); + expect(xyzLink.prop("href")).toBe(xyzUrl); + expect(xyzLink.prop("title")).toBe(xyzUrl); + const xyzLinkParts = xyzLink.find("span"); + expect(xyzLinkParts.at(0).hasClass("cropped-url-start")).toBe(true); + expect(xyzLinkParts.at(0).text()).toEqual("http://xyz"); + expect(xyzLinkParts.at(1).hasClass("cropped-url-middle")).toBe(true); + expect(xyzLinkParts.at(2).hasClass("cropped-url-end")).toBe(true); + expect(xyzLinkParts.at(2).text()).toEqual("klmnopqrst"); + + const abc = links.at(1); + expect(abc.prop("href")).toBe(abcUrl); + expect(abc.prop("title")).toBe(abcUrl); + const abcLinkParts = abc.find("span"); + expect(abcLinkParts.at(0).hasClass("cropped-url-start")).toBe(true); + expect(abcLinkParts.at(0).text()).toEqual("http://abc"); + expect(abcLinkParts.at(1).hasClass("cropped-url-middle")).toBe(true); + expect(abcLinkParts.at(2).hasClass("cropped-url-end")).toBe(true); + expect(abcLinkParts.at(2).text()).toEqual("klmnopqrst"); + }); + + it("renders full URL if smaller than cropLimit", () => { + const xyzUrl = "http://example.com/"; + + const openLink = jest.fn(); + const element = renderRep(xyzUrl, { + openLink, + useQuotes: false, + urlCropLimit: 20, + }); + + expect(element.text()).toEqual(xyzUrl); + const link = element.find("a").at(0); + expect(link.prop("href")).toBe(xyzUrl); + expect(link.prop("title")).toBe(xyzUrl); + expect(link.find(".cropped-url-start").length).toBe(0); + }); + + it("renders cropped URL followed by cropped string with urlCropLimit", () => { + const text = "http://example.fr abcdefghijkl"; + const openLink = jest.fn(); + const element = renderRep(text, { + openLink, + useQuotes: false, + cropLimit: 20, + }); + + expect(element.text()).toEqual("http://exa…cdefghijkl"); + const linkFr = element.find("a").at(0); + expect(linkFr.prop("href")).toBe("http://example.fr"); + expect(linkFr.prop("title")).toBe("http://example.fr"); + }); + + it("does not render a link if the URL has no scheme", () => { + const url = "example.com"; + const element = renderRep(url, { useQuotes: false }); + expect(element.text()).toEqual(url); + expect(element.find("a").exists()).toBeFalsy(); + }); + + it("does not render a link if the URL has an invalid scheme", () => { + const url = "foo://example.com"; + const element = renderRep(url, { useQuotes: false }); + expect(element.text()).toEqual(url); + expect(element.find("a").exists()).toBeFalsy(); + }); + + it("does not render an invalid URL that requires cropping", () => { + const text = + "//www.youtubeinmp3.com/download/?video=https://www.youtube.com/watch?v=8vkfsCIfDFc"; + const openLink = jest.fn(); + const element = renderRep(text, { + openLink, + useQuotes: false, + cropLimit: 60, + }); + expect(element.text()).toEqual( + "//www.youtubeinmp3.com/downloa…outube.com/watch?v=8vkfsCIfDFc" + ); + expect(element.find("a").exists()).toBeFalsy(); + }); + + it("does render a link in a plain array", () => { + const url = "http://example.com/abcdefghijabcdefghij"; + const string = `${url} some other text`; + const object = [string]; + const openLink = jest.fn(); + const element = renderRep(object, { openLink, noGrip: true }); + expect(element.text()).toEqual(`[ "${string}" ]`); + + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("does render a link in a grip array", () => { + const object = + require("resource://devtools/client/shared/components/test/node/stubs/reps/grip-array.js").get( + '["http://example.com/abcdefghijabcdefghij some other text"]' + ); + const length = getGripLengthBubbleText(object); + const openLink = jest.fn(); + const element = renderRep(object, { openLink }); + + const url = "http://example.com/abcdefghijabcdefghij"; + const string = `${url} some other text`; + expect(element.text()).toEqual(`Array${length} [ "${string}" ]`); + + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("does render a link in a plain object", () => { + const url = "http://example.com/abcdefghijabcdefghij"; + const string = `${url} some other text`; + const object = { test: string }; + const openLink = jest.fn(); + const element = renderRep(object, { openLink, noGrip: true }); + expect(element.text()).toEqual(`Object { test: "${string}" }`); + + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("does render a link in a grip object", () => { + const object = + require("resource://devtools/client/shared/components/test/node/stubs/reps/grip.js").get( + '{test: "http://example.com/ some other text"}' + ); + const openLink = jest.fn(); + const element = renderRep(object, { openLink }); + + const url = "http://example.com/"; + const string = `${url} some other text`; + expect(element.text()).toEqual(`Object { test: "${string}" }`); + + const link = element.find("a"); + expect(link.prop("href")).toBe(url); + expect(link.prop("title")).toBe(url); + + testLinkClick(link, openLink, url); + }); + + it("does not render links for js URL", () => { + const url = "javascript:x=42"; + const string = `${url} some other text`; + + const openLink = jest.fn(); + const element = renderRep(string, { openLink, useQuotes: false }); + expect(element.text()).toEqual(string); + const link = element.find("a"); + expect(link.exists()).toBe(false); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/string.test.js b/devtools/client/shared/components/test/node/components/reps/string.test.js new file mode 100644 index 0000000000..4e179ecab0 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/string.test.js @@ -0,0 +1,257 @@ +/* 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 { shallow, mount } = require("enzyme"); +const { + ELLIPSIS, +} = require("resource://devtools/client/shared/components/reps/reps/rep-utils.js"); +const { + REPS, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const { Rep } = REPS; + +const renderRep = (string, props) => + mount( + Rep({ + object: string, + ...props, + }) + ); + +const testCases = [ + { + name: "testMultiline", + props: { + object: "aaaaaaaaaaaaaaaaaaaaa\nbbbbbbbbbbbbbbbbbbb\ncccccccccccccccc\n", + }, + result: + '"aaaaaaaaaaaaaaaaaaaaa\\nbbbbbbbbbbbbbbbbbbb\\ncccccccccccccccc\\n"', + }, + { + name: "testMultilineLimit", + props: { + object: "aaaaaaaaaaaaaaaaaaaaa\nbbbbbbbbbbbbbbbbbbb\ncccccccccccccccc\n", + cropLimit: 20, + }, + result: `\"aaaaaaaaa${ELLIPSIS}cccccc\\n\"`, + }, + { + name: "testMultilineOpen", + props: { + object: "aaaaaaaaaaaaaaaaaaaaa\nbbbbbbbbbbbbbbbbbbb\ncccccccccccccccc\n", + member: { open: true }, + }, + result: + '"aaaaaaaaaaaaaaaaaaaaa\\nbbbbbbbbbbbbbbbbbbb\\ncccccccccccccccc\\n"', + }, + { + name: "testUseQuotes", + props: { + object: "abc", + useQuotes: false, + }, + result: "abc", + }, + { + name: "testNonPrintableCharacters", + props: { + object: "a\x01b", + useQuotes: false, + }, + result: "a\ufffdb", + }, + { + name: "testQuoting", + props: { + object: + "\t\n\r\"'\\\x1f\x9f\ufeff\ufffe\ud8000\u2063\ufffc\u2028\ueeee\ufffd", + useQuotes: true, + }, + result: + "`\\t\\n\\r\"'\\\\\\u001f\\u009f\\ufeff\\ufffe\\ud8000\\u2063" + + "\\ufffc\\u2028\\ueeee\ufffd`", + }, + { + name: "testUnpairedSurrogate", + props: { + object: "\uDC23", + useQuotes: true, + }, + result: '"\\udc23"', + }, + { + name: "testValidSurrogate", + props: { + object: "\ud83d\udeec", + useQuotes: true, + }, + result: '"\ud83d\udeec"', + }, + { + name: "testNoEscapeWhitespace", + props: { + object: "line 1\r\nline 2\n\tline 3", + useQuotes: true, + escapeWhitespace: false, + }, + result: '"line 1\r\nline 2\n\tline 3"', + }, + { + name: "testIgnoreFullTextWhenOpen", + props: { + object: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + fullText: + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaa", + member: { open: true }, + }, + result: '"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"', + }, + { + name: "testIgnoreFullTextWithLimit", + props: { + object: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + fullText: + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaa", + cropLimit: 20, + }, + result: `\"aaaaaaaaa${ELLIPSIS}aaaaaaaa\"`, + }, + { + name: "testIgnoreFullTextWhenOpenWithLimit", + props: { + object: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + fullText: + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaa", + member: { open: true }, + cropLimit: 20, + }, + result: '"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"', + }, + { + name: "testEmptyStringWithoutQuotes", + props: { + object: "", + transformEmptyString: true, + useQuotes: false, + }, + result: "<empty string>", + }, + { + name: "testEmptyStringWithoutQuotesAndNoTransform", + props: { + object: "", + useQuotes: false, + transformEmptyString: false, + }, + result: "", + }, + { + name: "testEmptyStringWithQuotes", + props: { + object: "", + useQuotes: true, + transformEmptyString: true, + }, + result: `""`, + }, + { + name: "testEmptyStringWithQuotesAndNoTransforms", + props: { + object: "", + useQuotes: true, + transformEmptyString: false, + }, + result: `""`, + }, + { + name: "testQuotingSingleQuote", + props: { + object: "'", + useQuotes: true, + }, + result: `"'"`, + }, + { + name: "testQuotingDoubleQuote", + props: { + object: '"', + useQuotes: true, + }, + result: `'"'`, + }, + { + name: "testQuotingBacktick", + props: { + object: "`", + useQuotes: true, + }, + result: '"`"', + }, + { + name: "testQuotingSingleAndDoubleQuotes", + props: { + object: "'\"", + useQuotes: true, + }, + result: "`'\"`", + }, + { + name: "testQuotingSingleAndDoubleQuotesAnd${", + props: { + object: "'\"${", + useQuotes: true, + }, + result: '"\'\\"${"', + }, + { + name: "testQuotingSingleQuoteAndBacktick", + props: { + object: "'`", + useQuotes: true, + }, + result: '"\'`"', + }, + { + name: "testQuotingDoubleQuoteAndBacktick", + props: { + object: '"`', + useQuotes: true, + }, + result: "'\"`'", + }, + { + name: "testQuotingSingleAndDoubleQuotesAndBacktick", + props: { + object: "'\"`", + useQuotes: true, + }, + result: '"\'\\"`"', + }, +]; + +describe("test String", () => { + for (const testCase of testCases) { + it(`String rep ${testCase.name}`, () => { + const renderedComponent = shallow(Rep(testCase.props)); + expect(renderedComponent.text()).toEqual(testCase.result); + }); + } + + it("If shouldRenderTooltip, StringRep displays a tooltip title on the span element.", () => { + const tooltipText = "This is a tooltip"; + const element = renderRep(tooltipText, { shouldRenderTooltip: true }); + expect(element.prop("title")).toBe('"This is a tooltip"'); + }); + + it("If !shouldRenderTooltip, StringRep doesn't display a tooltip title.", () => { + const noTooltip = "There is no tooltip"; + const element = renderRep(noTooltip, { shouldRenderTooltip: false }); + expect(element.prop("title")).toBe(undefined); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/stylesheet.test.js b/devtools/client/shared/components/test/node/components/reps/stylesheet.test.js new file mode 100644 index 0000000000..31c40facfe --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/stylesheet.test.js @@ -0,0 +1,41 @@ +/* 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 { shallow } = require("enzyme"); +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const { StyleSheet, Rep } = REPS; +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/stylesheet.js"); +const { + expectActorAttribute, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +describe("Test StyleSheet", () => { + const stub = stubs.get("StyleSheet")._grip; + + it("selects the StyleSheet Rep", () => { + expect(getRep(stub)).toEqual(StyleSheet.rep); + }); + + it("renders with the expected text content", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual( + "CSSStyleSheet https://example.com/styles.css" + ); + expect(renderedComponent.prop("title")).toEqual( + "CSSStyleSheet https://example.com/styles.css" + ); + expectActorAttribute(renderedComponent, stub.actor); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/symbol.test.js b/devtools/client/shared/components/test/node/components/reps/symbol.test.js new file mode 100644 index 0000000000..4959e9a813 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/symbol.test.js @@ -0,0 +1,64 @@ +/* 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 { shallow } = require("enzyme"); +const { + REPS, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const { Rep } = REPS; +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/symbol.js"); +const { + expectActorAttribute, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +describe("test Symbol", () => { + const stub = stubs.get("Symbol"); + + it("renders with the expected content", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual('Symbol("foo")'); + expect(renderedComponent.prop("title")).toBe("Symbol(foo)"); + expectActorAttribute(renderedComponent, stub.actor); + }); +}); + +describe("test Symbol without identifier", () => { + const stub = stubs.get("SymbolWithoutIdentifier"); + + it("renders the expected content", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual("Symbol()"); + expectActorAttribute(renderedComponent, stub.actor); + }); +}); + +describe("test Symbol with long string", () => { + const stub = stubs.get("SymbolWithLongString"); + + it("renders the expected content", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + }) + ); + + expect(renderedComponent.text()).toEqual( + 'Symbol("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…")' + ); + expectActorAttribute(renderedComponent, stub.actor); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/test-helpers.js b/devtools/client/shared/components/test/node/components/reps/test-helpers.js new file mode 100644 index 0000000000..d601f71e88 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/test-helpers.js @@ -0,0 +1,116 @@ +/* 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 { shallow } = require("enzyme"); + +const { + lengthBubble, +} = require("resource://devtools/client/shared/components/reps/shared/grip-length-bubble.js"); +const { + maxLengthMap: arrayLikeMaxLengthMap, + getLength: getArrayLikeLength, +} = require("resource://devtools/client/shared/components/reps/reps/grip-array.js"); +const { + maxLengthMap: mapMaxLengths, + getLength: getMapLength, +} = require("resource://devtools/client/shared/components/reps/reps/grip-map.js"); +const { + getGripPreviewItems, +} = require("resource://devtools/client/shared/components/reps/reps/rep-utils.js"); +const nodeConstants = require("resource://devtools/client/shared/components/reps/shared/dom-node-constants.js"); + +/** + * Get an array of all the items from the grip in parameter (including the grip + * itself) which can be selected in the inspector. + * + * @param {Object} Grip + * @return {Array} Flat array of the grips which can be selected in the + * inspector + */ +function getSelectableInInspectorGrips(grip) { + const grips = new Set(getFlattenedGrips([grip])); + return [...grips].filter(isGripSelectableInInspector); +} + +/** + * Indicate if a Grip can be selected in the inspector, + * i.e. if it represents a node element. + * + * @param {Object} Grip + * @return {Boolean} + */ +function isGripSelectableInInspector(grip) { + return ( + grip && + typeof grip === "object" && + grip.preview && + [nodeConstants.TEXT_NODE, nodeConstants.ELEMENT_NODE].includes( + grip.preview.nodeType + ) + ); +} + +/** + * Get a flat array of all the grips and their preview items. + * + * @param {Array} Grips + * @return {Array} Flat array of the grips and their preview items + */ +function getFlattenedGrips(grips) { + return grips.reduce((res, grip) => { + const previewItems = getGripPreviewItems(grip); + const flatPreviewItems = previewItems.length + ? getFlattenedGrips(previewItems) + : []; + + return [...res, grip, ...flatPreviewItems]; + }, []); +} + +function expectActorAttribute(wrapper, expectedValue) { + const actorIdAttribute = "data-link-actor-id"; + const attrElement = wrapper.find(`[${actorIdAttribute}]`); + expect(attrElement.exists()).toBeTruthy(); + expect(attrElement.first().prop("data-link-actor-id")).toBe(expectedValue); +} + +function getGripLengthBubbleText(object, props) { + const component = lengthBubble({ + object, + maxLengthMap: arrayLikeMaxLengthMap, + getLength: getArrayLikeLength, + ...props, + }); + + return component ? shallow(component).text() : ""; +} + +function getMapLengthBubbleText(object, props) { + return getGripLengthBubbleText(object, { + maxLengthMap: mapMaxLengths, + getLength: getMapLength, + showZeroLength: true, + ...props, + }); +} + +function createGripMapEntry(key, value) { + return { + type: "mapEntry", + preview: { + key, + value, + }, + }; +} + +module.exports = { + createGripMapEntry, + expectActorAttribute, + getSelectableInInspectorGrips, + getGripLengthBubbleText, + getMapLengthBubbleText, +}; diff --git a/devtools/client/shared/components/test/node/components/reps/text-node.test.js b/devtools/client/shared/components/test/node/components/reps/text-node.test.js new file mode 100644 index 0000000000..13a8a89154 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/text-node.test.js @@ -0,0 +1,203 @@ +/* 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"; + +/* global jest */ +const { shallow } = require("enzyme"); + +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const { TextNode } = REPS; + +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/text-node.js"); +const { + expectActorAttribute, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); +const { + ELLIPSIS, +} = require("resource://devtools/client/shared/components/reps/reps/rep-utils.js"); + +function quoteNewlines(text) { + return text.split("\n").join("\\n"); +} + +describe("TextNode", () => { + it("selects TextNode Rep as expected", () => { + expect(getRep(stubs.get("testRendering")._grip)).toBe(TextNode.rep); + }); + + it("renders as expected", () => { + const object = stubs.get("testRendering")._grip; + const renderRep = props => shallow(TextNode.rep({ object, ...props })); + + const defaultOutput = '#text "hello world"'; + + let component = renderRep({ shouldRenderTooltip: true, mode: undefined }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.TINY }); + expect(component.text()).toBe("#text"); + expect(component.prop("title")).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.HEADER }); + expect(component.text()).toBe("#text"); + expect(component.prop("title")).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.SHORT }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.LONG }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(defaultOutput); + expectActorAttribute(component, object.actor); + }); + + it("renders as expected with EOL", () => { + const object = stubs.get("testRenderingWithEOL")._grip; + const renderRep = props => shallow(TextNode.rep({ object, ...props })); + + const defaultOutput = quoteNewlines('#text "hello\nworld"'); + const defaultTooltip = '#text "hello\nworld"'; + + let component = renderRep({ shouldRenderTooltip: true, mode: undefined }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(defaultTooltip); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.TINY }); + expect(component.text()).toBe("#text"); + expect(component.prop("title")).toBe(defaultTooltip); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.HEADER }); + expect(component.text()).toBe("#text"); + expect(component.prop("title")).toBe(defaultTooltip); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.SHORT }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(defaultTooltip); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.LONG }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(defaultTooltip); + }); + + it("renders as expected with double quote", () => { + const object = stubs.get("testRenderingWithDoubleQuote")._grip; + const renderRep = props => shallow(TextNode.rep({ object, ...props })); + + const defaultOutput = "#text 'hello\"world'"; + const defaultTooltip = '#text "hello"world"'; + + let component = renderRep({ shouldRenderTooltip: true, mode: undefined }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(defaultTooltip); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.TINY }); + expect(component.text()).toBe("#text"); + expect(component.prop("title")).toBe(defaultTooltip); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.HEADER }); + expect(component.text()).toBe("#text"); + expect(component.prop("title")).toBe(defaultTooltip); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.SHORT }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(defaultTooltip); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.LONG }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(defaultTooltip); + }); + + it("renders as expected with long string", () => { + const object = stubs.get("testRenderingWithLongString")._grip; + const renderRep = props => shallow(TextNode.rep({ object, ...props })); + const initialString = object.preview.textContent.initial; + + const defaultOutput = `#text "${quoteNewlines(initialString)}${ELLIPSIS}"`; + const defaultTooltip = `#text "${initialString}"`; + + let component = renderRep({ shouldRenderTooltip: true, mode: undefined }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(defaultTooltip); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.TINY }); + expect(component.text()).toBe("#text"); + expect(component.prop("title")).toBe(defaultTooltip); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.HEADER }); + expect(component.text()).toBe("#text"); + expect(component.prop("title")).toBe(defaultTooltip); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.SHORT }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(defaultTooltip); + + component = renderRep({ shouldRenderTooltip: true, mode: MODE.LONG }); + expect(component.text()).toBe(defaultOutput); + expect(component.prop("title")).toBe(defaultTooltip); + }); + + it("calls the expected function on mouseover", () => { + const object = stubs.get("testRendering")._grip; + const onDOMNodeMouseOver = jest.fn(); + const wrapper = shallow(TextNode.rep({ object, onDOMNodeMouseOver })); + + wrapper.simulate("mouseover"); + + expect(onDOMNodeMouseOver.mock.calls).toHaveLength(1); + expect(onDOMNodeMouseOver).toHaveBeenCalledWith(object); + }); + + it("calls the expected function on mouseout", () => { + const object = stubs.get("testRendering")._grip; + const onDOMNodeMouseOut = jest.fn(); + const wrapper = shallow(TextNode.rep({ object, onDOMNodeMouseOut })); + + wrapper.simulate("mouseout"); + + expect(onDOMNodeMouseOut.mock.calls).toHaveLength(1); + expect(onDOMNodeMouseOut).toHaveBeenCalledWith(object); + }); + + it("displays a button when the node is connected", () => { + const object = stubs.get("testRendering")._grip; + + const onInspectIconClick = jest.fn(); + const wrapper = shallow(TextNode.rep({ object, onInspectIconClick })); + + const inspectIconNode = wrapper.find(".open-inspector"); + expect(inspectIconNode !== null).toBe(true); + + const event = Symbol("click-event"); + inspectIconNode.simulate("click", event); + + // The function is called once + expect(onInspectIconClick.mock.calls).toHaveLength(1); + const [arg1, arg2] = onInspectIconClick.mock.calls[0]; + // First argument is the grip + expect(arg1).toBe(object); + // Second one is the event + expect(arg2).toBe(event); + }); + + it("does not display a button when the node is connected", () => { + const object = stubs.get("testRenderingDisconnected")._grip; + + const onInspectIconClick = jest.fn(); + const wrapper = shallow(TextNode.rep({ object, onInspectIconClick })); + expect(wrapper.find(".open-inspector")).toHaveLength(0); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/undefined.test.js b/devtools/client/shared/components/test/node/components/reps/undefined.test.js new file mode 100644 index 0000000000..56fa512f55 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/undefined.test.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 { shallow } = require("enzyme"); + +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { Undefined, Rep } = REPS; + +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/undefined.js"); +// Test that correct rep is chosen +describe("Test Undefined", () => { + const stub = stubs.get("Undefined"); + + it("selects Undefined as expected", () => { + expect(getRep(stub)).toBe(Undefined.rep); + }); + + it("Rep correctly selects Undefined Rep for plain JS undefined", () => { + expect(getRep(undefined, undefined, true)).toBe(Undefined.rep); + }); + + it("Undefined rep has expected text content", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + }) + ); + expect(renderedComponent.text()).toEqual("undefined"); + }); + + it("Undefined rep has expected class names", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + }) + ); + expect(renderedComponent.hasClass("objectBox objectBox-undefined")).toEqual( + true + ); + }); + + it("Undefined rep has expected title", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + expect(renderedComponent.prop("title")).toEqual("undefined"); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/reps/window.test.js b/devtools/client/shared/components/test/node/components/reps/window.test.js new file mode 100644 index 0000000000..9f789d3be6 --- /dev/null +++ b/devtools/client/shared/components/test/node/components/reps/window.test.js @@ -0,0 +1,197 @@ +/* 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 { shallow } = require("enzyme"); + +const { + REPS, + getRep, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const { Rep, Window } = REPS; +const stubs = require("resource://devtools/client/shared/components/test/node/stubs/reps/window.js"); +const { + expectActorAttribute, +} = require("resource://devtools/client/shared/components/test/node/components/reps/test-helpers.js"); + +describe("test Window", () => { + const stub = stubs.get("Window")._grip; + + it("selects Window Rep correctly", () => { + expect(getRep(stub)).toBe(Window.rep); + }); + + it("renders with correct class name", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + }) + ); + + expect(renderedComponent.hasClass("objectBox-Window")).toBe(true); + expectActorAttribute(renderedComponent, stub.actor); + }); + + it("renders with correct content", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual( + "Window data:text/html;charset=utf-8,stub generation" + ); + expect(renderedComponent.prop("title")).toEqual( + "Window data:text/html;charset=utf-8,stub generation" + ); + }); + + it("renders with correct inner HTML structure and content", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + }) + ); + + expect(renderedComponent.find(".location").text()).toEqual( + "data:text/html;charset=utf-8,stub generation" + ); + }); + + it("renders with expected text in TINY mode", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + mode: MODE.TINY, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("Window"); + expect(renderedComponent.prop("title")).toEqual( + "Window data:text/html;charset=utf-8,stub generation" + ); + }); + + it("renders with expected text in LONG mode", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + mode: MODE.LONG, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual( + "Window data:text/html;charset=utf-8,stub generation" + ); + expect(renderedComponent.prop("title")).toEqual( + "Window data:text/html;charset=utf-8,stub generation" + ); + }); + + it("renders expected text in TINY mode with Custom display class", () => { + const renderedComponent = shallow( + Rep({ + object: { + ...stub, + displayClass: "Custom", + }, + mode: MODE.TINY, + }) + ); + + expect(renderedComponent.text()).toEqual("Custom"); + }); + + it("renders expected text in LONG mode with Custom display class", () => { + const renderedComponent = shallow( + Rep({ + object: { + ...stub, + displayClass: "Custom", + }, + mode: MODE.LONG, + title: "Custom", + }) + ); + + expect(renderedComponent.text()).toEqual( + "Custom data:text/html;charset=utf-8,stub generation" + ); + }); +}); + +describe("test cross-process iframe contentWindow", () => { + const stub = stubs.get("CrossOriginIframeContentWindow")._grip; + + it("selects Window Rep correctly", () => { + expect(getRep(stub)).toBe(Window.rep); + }); + + it("renders with correct class name", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + }) + ); + + expect(renderedComponent.hasClass("objectBox-Window")).toBe(true); + expectActorAttribute(renderedComponent, stub.actor); + }); + + it("renders with correct content", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual( + "Window http://example.org/document-builder.sjs?html=example.org" + ); + expect(renderedComponent.prop("title")).toEqual( + "Window http://example.org/document-builder.sjs?html=example.org" + ); + }); +}); + +describe("test cross-process iframe top window", () => { + const stub = stubs.get("CrossOriginIframeTopWindow")._grip; + + it("selects Window Rep correctly", () => { + expect(getRep(stub)).toBe(Window.rep); + }); + + it("renders with correct class name", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + }) + ); + + expect(renderedComponent.hasClass("objectBox-Window")).toBe(true); + expectActorAttribute(renderedComponent, stub.actor); + }); + + it("renders with correct content", () => { + const renderedComponent = shallow( + Rep({ + object: stub, + shouldRenderTooltip: true, + }) + ); + + expect(renderedComponent.text()).toEqual("Window Restricted"); + expect(renderedComponent.prop("title")).toEqual("Window Restricted"); + }); +}); diff --git a/devtools/client/shared/components/test/node/components/tree.test.js b/devtools/client/shared/components/test/node/components/tree.test.js new file mode 100644 index 0000000000..c70b66e8ff --- /dev/null +++ b/devtools/client/shared/components/test/node/components/tree.test.js @@ -0,0 +1,911 @@ +/* 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"; + +/* global jest */ + +const React = require("react"); +const { mount } = require("enzyme"); +const dom = require("react-dom-factories"); + +const { Component, createFactory } = React; +const Tree = createFactory( + require("resource://devtools/client/shared/components/Tree.js") +); + +function mountTree(overrides = {}) { + return mount( + createFactory( + class container extends Component { + constructor(props) { + super(props); + const state = { + expanded: overrides.expanded || new Set(), + focused: overrides.focused, + active: overrides.active, + }; + delete overrides.focused; + delete overrides.active; + this.state = state; + } + + render() { + return Tree( + Object.assign( + { + getParent: x => TEST_TREE.parent[x], + getChildren: x => TEST_TREE.children[x], + renderItem: (x, depth, focused, arrow) => { + return dom.div( + {}, + arrow, + focused ? "[" : null, + x, + focused ? "]" : null + ); + }, + getRoots: () => ["A", "M"], + getKey: x => `key-${x}`, + itemHeight: 1, + onFocus: x => { + this.setState(previousState => { + return { focused: x }; + }); + }, + onActivate: x => { + this.setState(previousState => { + return { active: x }; + }); + }, + onExpand: x => { + this.setState(previousState => { + const expanded = new Set(previousState.expanded); + expanded.add(x); + return { expanded }; + }); + }, + onCollapse: x => { + this.setState(previousState => { + const expanded = new Set(previousState.expanded); + expanded.delete(x); + return { expanded }; + }); + }, + isExpanded: x => this.state && this.state.expanded.has(x), + focused: this.state.focused, + active: this.state.active, + }, + overrides + ) + ); + } + } + )() + ); +} + +describe("Tree", () => { + it("does not throw", () => { + expect(mountTree()).toBeTruthy(); + }); + + it("Don't auto expand root with very large number of children", () => { + const children = Array.from( + { length: 51 }, + (_, i) => `should-not-be-visible-${i + 1}` + ); + // N has a lot of children, so it won't be automatically expanded + const wrapper = mountTree({ + autoExpandDepth: 2, + autoExpandNodeChildrenLimit: 50, + getChildren: item => { + if (item === "N") { + return children; + } + + return TEST_TREE.children[item] || []; + }, + }); + const ids = getTreeNodes(wrapper).map(node => node.prop("id")); + expect(ids).toMatchSnapshot(); + }); + + it("is accessible", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJMN".split("")), + }); + expect(wrapper.getDOMNode().getAttribute("role")).toBe("tree"); + expect(wrapper.getDOMNode().getAttribute("tabIndex")).toBe("0"); + + const expected = { + A: { id: "key-A", level: 1, expanded: true }, + B: { id: "key-B", level: 2, expanded: true }, + C: { id: "key-C", level: 2, expanded: true }, + D: { id: "key-D", level: 2, expanded: true }, + E: { id: "key-E", level: 3, expanded: true }, + F: { id: "key-F", level: 3, expanded: true }, + G: { id: "key-G", level: 3, expanded: true }, + H: { id: "key-H", level: 3, expanded: true }, + I: { id: "key-I", level: 3, expanded: true }, + J: { id: "key-J", level: 3, expanded: true }, + K: { id: "key-K", level: 4, expanded: undefined }, + L: { id: "key-L", level: 4, expanded: undefined }, + M: { id: "key-M", level: 1, expanded: true }, + N: { id: "key-N", level: 2, expanded: true }, + O: { id: "key-O", level: 3, expanded: undefined }, + }; + + getTreeNodes(wrapper).forEach(node => { + const key = node.prop("id").replace("key-", ""); + const item = expected[key]; + + expect(node.prop("id")).toBe(item.id); + expect(node.prop("role")).toBe("treeitem"); + expect(node.prop("aria-level")).toBe(item.level); + expect(node.prop("aria-expanded")).toBe(item.expanded); + }); + }); + + it("renders as expected", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + }); + + expect(formatTree(wrapper)).toMatchSnapshot(); + }); + + it("renders as expected when passed a className", () => { + const wrapper = mountTree({ + className: "testClassName", + }); + + expect(wrapper.find(".tree").hasClass("testClassName")).toBe(true); + }); + + it("renders as expected when passed a style", () => { + const wrapper = mountTree({ + style: { + color: "red", + }, + }); + + expect(wrapper.getDOMNode().style.color).toBe("red"); + }); + + it("renders as expected when passed a label", () => { + const wrapper = mountTree({ + label: "testAriaLabel", + }); + expect(wrapper.getDOMNode().getAttribute("aria-label")).toBe( + "testAriaLabel" + ); + }); + + it("renders as expected when passed an aria-labelledby", () => { + const wrapper = mountTree({ + labelledby: "testAriaLabelBy", + }); + expect(wrapper.getDOMNode().getAttribute("aria-labelledby")).toBe( + "testAriaLabelBy" + ); + }); + + it("renders as expected with collapsed nodes", () => { + const wrapper = mountTree({ + expanded: new Set("MNO".split("")), + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + }); + + it("renders as expected when passed autoDepth:1", () => { + const wrapper = mountTree({ + autoExpandDepth: 1, + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + }); + + it("calls shouldItemUpdate when provided", () => { + const shouldItemUpdate = jest.fn((prev, next) => true); + const wrapper = mountTree({ + shouldItemUpdate, + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(shouldItemUpdate.mock.calls).toHaveLength(0); + + wrapper.find("Tree").first().instance().forceUpdate(); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(shouldItemUpdate.mock.calls).toHaveLength(2); + + expect(shouldItemUpdate.mock.calls[0][0]).toBe("A"); + expect(shouldItemUpdate.mock.calls[0][1]).toBe("A"); + expect(shouldItemUpdate.mock.results[0].value).toBe(true); + expect(shouldItemUpdate.mock.calls[1][0]).toBe("M"); + expect(shouldItemUpdate.mock.calls[1][1]).toBe("M"); + expect(shouldItemUpdate.mock.results[1].value).toBe(true); + }); + + it("active item - renders as expected when clicking away", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "G", + active: "G", + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.find(".active").prop("id")).toBe("key-G"); + + getTreeNodes(wrapper).first().simulate("click"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.find(".focused").prop("id")).toBe("key-A"); + expect(wrapper.find(".active").exists()).toBe(false); + }); + + it("active item - renders as expected when tree blurs", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "G", + active: "G", + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.find(".active").prop("id")).toBe("key-G"); + + wrapper.simulate("blur"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.find(".active").exists()).toBe(false); + }); + + it("active item - renders as expected when moving away with keyboard", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "L", + active: "L", + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.find(".active").prop("id")).toBe("key-L"); + + simulateKeyDown(wrapper, "ArrowUp"); + expect(wrapper.find(".active").exists()).toBe(false); + }); + + it("active item - renders as expected when using keyboard and Enter", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "L", + }); + wrapper.getDOMNode().focus(); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.find(".active").exists()).toBe(false); + + simulateKeyDown(wrapper, "Enter"); + expect(wrapper.find(".active").prop("id")).toBe("key-L"); + + expect(wrapper.getDOMNode().ownerDocument.activeElement).toBe( + wrapper.getDOMNode() + ); + + simulateKeyDown(wrapper, "Escape"); + expect(wrapper.find(".active").exists()).toBe(false); + expect(wrapper.getDOMNode().ownerDocument.activeElement).toBe( + wrapper.getDOMNode() + ); + }); + + it("active item - renders as expected when using keyboard and Space", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "L", + }); + wrapper.getDOMNode().focus(); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.find(".active").exists()).toBe(false); + + simulateKeyDown(wrapper, " "); + expect(wrapper.find(".active").prop("id")).toBe("key-L"); + + simulateKeyDown(wrapper, "Escape"); + expect(wrapper.find(".active").exists()).toBe(false); + }); + + it("active item - focus is inside the tree node when possible", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "L", + renderItem: renderItemWithFocusableContent, + }); + wrapper.getDOMNode().focus(); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.find(".active").exists()).toBe(false); + expect(wrapper.getDOMNode().ownerDocument.activeElement).toBe( + wrapper.getDOMNode() + ); + + simulateKeyDown(wrapper, "Enter"); + expect(wrapper.find(".active").prop("id")).toBe("key-L"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().ownerDocument.activeElement).toBe( + wrapper.find("#active-anchor").getDOMNode() + ); + }); + + it("active item - navigate inside the tree node", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "L", + renderItem: renderItemWithFocusableContent, + }); + wrapper.getDOMNode().focus(); + simulateKeyDown(wrapper, "Enter"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.find(".active").prop("id")).toBe("key-L"); + expect(wrapper.getDOMNode().ownerDocument.activeElement).toBe( + wrapper.find("#active-anchor").getDOMNode() + ); + + simulateKeyDown(wrapper, "Tab"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.find(".active").prop("id")).toBe("key-L"); + expect(wrapper.getDOMNode().ownerDocument.activeElement).toBe( + wrapper.find("#active-anchor").getDOMNode() + ); + + simulateKeyDown(wrapper, "Tab", { shiftKey: true }); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.find(".active").prop("id")).toBe("key-L"); + expect(wrapper.getDOMNode().ownerDocument.activeElement).toBe( + wrapper.find("#active-anchor").getDOMNode() + ); + }); + + it("active item - focus is inside the tree node and then blur", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "L", + renderItem: renderItemWithFocusableContent, + }); + wrapper.getDOMNode().focus(); + simulateKeyDown(wrapper, "Enter"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.find(".active").prop("id")).toBe("key-L"); + expect(wrapper.getDOMNode().ownerDocument.activeElement).toBe( + wrapper.find("#active-anchor").getDOMNode() + ); + + wrapper.find("#active-anchor").simulate("blur"); + expect(wrapper.find(".active").exists()).toBe(false); + expect(wrapper.getDOMNode().ownerDocument.activeElement).toBe( + wrapper.getDOMNode().ownerDocument.body + ); + }); + + it("renders as expected when given a focused item", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "G", + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-G" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-G"); + + getTreeNodes(wrapper).first().simulate("click"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-A" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-A"); + + getTreeNodes(wrapper).first().simulate("click"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-A" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-A"); + + wrapper.simulate("blur"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().hasAttribute("aria-activedescendant")).toBe( + false + ); + expect(wrapper.find(".focused").exists()).toBe(false); + }); + + it("renders as expected when navigating up with the keyboard", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "L", + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-L" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-L"); + + simulateKeyDown(wrapper, "ArrowUp"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-K" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-K"); + + simulateKeyDown(wrapper, "ArrowUp"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-E" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-E"); + }); + + it("renders as expected navigating up with the keyboard on a root", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "A", + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-A" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-A"); + + simulateKeyDown(wrapper, "ArrowUp"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-A" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-A"); + }); + + it("renders as expected when navigating down with the keyboard", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "K", + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-K" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-K"); + + simulateKeyDown(wrapper, "ArrowDown"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-L" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-L"); + + simulateKeyDown(wrapper, "ArrowDown"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-F" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-F"); + }); + + it("renders as expected navigating down with keyboard on last node", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "O", + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-O" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-O"); + + simulateKeyDown(wrapper, "ArrowDown"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-O" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-O"); + }); + + it("renders as expected when navigating with right/left arrows", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "L", + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-L" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-L"); + + simulateKeyDown(wrapper, "ArrowLeft"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-E" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-E"); + + simulateKeyDown(wrapper, "ArrowLeft"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-E" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-E"); + + simulateKeyDown(wrapper, "ArrowRight"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-E" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-E"); + + simulateKeyDown(wrapper, "ArrowRight"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-K" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-K"); + }); + + it("renders as expected when navigating with left arrows on roots", () => { + const wrapper = mountTree({ + focused: "M", + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-M" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-M"); + + simulateKeyDown(wrapper, "ArrowLeft"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-A" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-A"); + + simulateKeyDown(wrapper, "ArrowLeft"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-A" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-A"); + }); + + it("renders as expected when navigating with home/end", () => { + const wrapper = mountTree({ + focused: "M", + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-M" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-M"); + + simulateKeyDown(wrapper, "Home"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-A" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-A"); + + simulateKeyDown(wrapper, "Home"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-A" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-A"); + + simulateKeyDown(wrapper, "End"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-M" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-M"); + + simulateKeyDown(wrapper, "End"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-M" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-M"); + + simulateKeyDown(wrapper, "ArrowRight"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-M" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-M"); + + simulateKeyDown(wrapper, "End"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-N" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-N"); + + simulateKeyDown(wrapper, "End"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-N" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-N"); + + simulateKeyDown(wrapper, "Home"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-A" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-A"); + }); + + it("renders as expected navigating with arrows on unexpandable roots", () => { + const wrapper = mountTree({ + focused: "A", + isExpandable: item => false, + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + + simulateKeyDown(wrapper, "ArrowRight"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-M" + ); + + simulateKeyDown(wrapper, "ArrowLeft"); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-A" + ); + }); + + it("calls onFocus when expected", () => { + const onFocus = jest.fn(x => { + wrapper && + wrapper.setState(() => { + return { focused: x }; + }); + }); + + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "I", + onFocus, + }); + + simulateKeyDown(wrapper, "ArrowUp"); + expect(onFocus.mock.calls[0][0]).toBe("H"); + + simulateKeyDown(wrapper, "ArrowUp"); + expect(onFocus.mock.calls[1][0]).toBe("C"); + + simulateKeyDown(wrapper, "ArrowLeft"); + simulateKeyDown(wrapper, "ArrowLeft"); + expect(onFocus.mock.calls[2][0]).toBe("A"); + + simulateKeyDown(wrapper, "ArrowRight"); + expect(onFocus.mock.calls[3][0]).toBe("B"); + + simulateKeyDown(wrapper, "ArrowDown"); + expect(onFocus.mock.calls[4][0]).toBe("E"); + }); + + it("focus treeRef when a node is clicked", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + }); + const treeRef = wrapper.find("Tree").first().instance().treeRef.current; + treeRef.focus = jest.fn(); + + getTreeNodes(wrapper).first().simulate("click"); + expect(treeRef.focus.mock.calls).toHaveLength(1); + }); + + it("doesn't focus treeRef when focused is null", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "A", + }); + const treeRef = wrapper.find("Tree").first().instance().treeRef.current; + treeRef.focus = jest.fn(); + wrapper.simulate("blur"); + expect(treeRef.focus.mock.calls).toHaveLength(0); + }); + + it("ignores key strokes when pressing modifiers", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + focused: "L", + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-L" + ); + expect(wrapper.find(".focused").prop("id")).toBe("key-L"); + + const testKeys = [ + { key: "ArrowDown" }, + { key: "ArrowUp" }, + { key: "ArrowLeft" }, + { key: "ArrowRight" }, + ]; + const modifiers = [ + { altKey: true }, + { ctrlKey: true }, + { metaKey: true }, + { shiftKey: true }, + ]; + + for (const key of testKeys) { + for (const modifier of modifiers) { + wrapper.simulate("keydown", Object.assign({}, key, modifier)); + expect(formatTree(wrapper)).toMatchSnapshot(); + expect(wrapper.getDOMNode().getAttribute("aria-activedescendant")).toBe( + "key-L" + ); + } + } + }); + + it("renders arrows as expected when nodes are expanded", () => { + const wrapper = mountTree({ + expanded: new Set("ABCDEFGHIJKLMNO".split("")), + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + + getTreeNodes(wrapper).forEach(n => { + if ("ABECDMN".split("").includes(getSanitizedNodeText(n))) { + expect(n.find(".arrow.expanded").exists()).toBe(true); + } else { + expect(n.find(".arrow").exists()).toBe(false); + } + }); + }); + + it("renders arrows as expected when nodes are collapsed", () => { + const wrapper = mountTree(); + expect(formatTree(wrapper)).toMatchSnapshot(); + + getTreeNodes(wrapper).forEach(n => { + const arrow = n.find(".arrow"); + expect(arrow.exists()).toBe(true); + expect(arrow.hasClass("expanded")).toBe(false); + }); + }); + + it("uses isExpandable prop if it exists to render tree nodes", () => { + const wrapper = mountTree({ + isExpandable: item => item === "A", + }); + expect(formatTree(wrapper)).toMatchSnapshot(); + }); + + it("adds the expected data-expandable attribute", () => { + const wrapper = mountTree({ + isExpandable: item => item === "A", + }); + const nodes = getTreeNodes(wrapper); + expect(nodes.at(0).prop("data-expandable")).toBe(true); + expect(nodes.at(1).prop("data-expandable")).toBe(false); + }); +}); + +function getTreeNodes(wrapper) { + return wrapper.find(".tree-node"); +} + +function simulateKeyDown(wrapper, key, options) { + wrapper.simulate("keydown", { + key, + preventDefault: () => {}, + stopPropagation: () => {}, + ...options, + }); +} + +function renderItemWithFocusableContent(x, depth, focused, arrow) { + const children = [arrow, focused ? "[" : null, x]; + if (x === "L") { + children.push(dom.a({ id: "active-anchor", href: "#" }, " anchor")); + } + + if (focused) { + children.push("]"); + } + + return dom.div({}, ...children); +} + +/* + * Takes an Enzyme wrapper (obtained with mount/mount/…) and + * returns a stringified version of the Tree, e.g. + * + * ▼ A + * | ▼ B + * | | ▼ E + * | | | K + * | | | L + * | | F + * | | G + * | ▼ C + * | | H + * | | I + * | ▼ D + * | | J + * ▼ M + * | ▼ N + * | | O + * + */ +function formatTree(wrapper) { + const textTree = getTreeNodes(wrapper) + .map(node => { + const level = (node.prop("aria-level") || 1) - 1; + const indentStr = "| ".repeat(level); + const arrow = node.find(".arrow"); + let arrowStr = " "; + if (arrow.exists()) { + arrowStr = arrow.hasClass("expanded") ? "▼ " : "▶︎ "; + } + + return `${indentStr}${arrowStr}${getSanitizedNodeText(node)}`; + }) + .join("\n"); + + // Wrap in new lines so tree nodes are aligned as expected. + return `\n${textTree}\n`; +} + +function getSanitizedNodeText(node) { + // Stripping off the invisible space used in the indent. + return node.text().replace(/^\u200B+/, ""); +} + +// Encoding of the following tree/forest: +// +// A +// |-- B +// | |-- E +// | | |-- K +// | | `-- L +// | |-- F +// | `-- G +// |-- C +// | |-- H +// | `-- I +// `-- D +// `-- J +// M +// `-- N +// `-- O + +var TEST_TREE = { + children: { + A: ["B", "C", "D"], + B: ["E", "F", "G"], + C: ["H", "I"], + D: ["J"], + E: ["K", "L"], + F: [], + G: [], + H: [], + I: [], + J: [], + K: [], + L: [], + M: ["N"], + N: ["O"], + O: [], + }, + parent: { + A: null, + B: "A", + C: "A", + D: "A", + E: "B", + F: "B", + G: "B", + H: "C", + I: "C", + J: "D", + K: "E", + L: "E", + M: null, + N: "M", + O: "N", + }, +}; diff --git a/devtools/client/shared/components/test/node/jest.config.js b/devtools/client/shared/components/test/node/jest.config.js new file mode 100644 index 0000000000..9c5a211c25 --- /dev/null +++ b/devtools/client/shared/components/test/node/jest.config.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* global __dirname */ + +"use strict"; + +const sharedJestConfig = require(`${__dirname}/../../../test-helpers/shared-jest.config`); + +module.exports = { + ...sharedJestConfig, + setupFiles: ["<rootDir>/setup.js"], + snapshotSerializers: ["enzyme-to-json/serializer"], + testURL: "http://localhost/", +}; diff --git a/devtools/client/shared/components/test/node/package.json b/devtools/client/shared/components/test/node/package.json new file mode 100644 index 0000000000..9db588176e --- /dev/null +++ b/devtools/client/shared/components/test/node/package.json @@ -0,0 +1,27 @@ +{ + "name": "devtools-client-shared-components-tests", + "license": "MPL-2.0", + "version": "0.0.1", + "engines": { + "node": ">=8.9.4" + }, + "scripts": { + "test": "jest", + "test-ci": "jest --json" + }, + "dependencies": { + "@babel/plugin-proposal-class-properties": "7.10.4", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-proposal-optional-chaining": "^7.8.3", + "babel-plugin-transform-amd-to-commonjs": "1.4.0", + "enzyme": "^3.9.0", + "enzyme-adapter-react-16": "^1.13.2", + "enzyme-to-json": "^3.3.5", + "jest": "^24.6.0", + "jsdom": "20.0.0", + "react": "16.4.1", + "react-dom": "16.4.1", + "react-dom-factories": "1.0.2", + "react-test-renderer": "16.4.1" + } +} diff --git a/devtools/client/shared/components/test/node/setup.js b/devtools/client/shared/components/test/node/setup.js new file mode 100644 index 0000000000..570e4462ae --- /dev/null +++ b/devtools/client/shared/components/test/node/setup.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"; + +// Configure enzyme with React 16 adapter. +const Enzyme = require("enzyme"); +const Adapter = require("enzyme-adapter-react-16"); +Enzyme.configure({ adapter: new Adapter() }); + +const { + setMocksInGlobal, +} = require("resource://devtools/client/shared/test-helpers/shared-node-helpers.js"); +setMocksInGlobal(); diff --git a/devtools/client/shared/components/test/node/stubs/object-inspector/grip.js b/devtools/client/shared/components/test/node/stubs/object-inspector/grip.js new file mode 100644 index 0000000000..5df79fd62d --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/object-inspector/grip.js @@ -0,0 +1,64 @@ +/* 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/>. */ + +const stubs = new Map(); + +stubs.set("proto-properties-symbols", { + ownProperties: { + a: { + configurable: true, + enumerable: true, + writable: true, + value: 1, + }, + }, + from: "server2.conn13.child19/propertyIterator160", + prototype: { + type: "object", + actor: "server2.conn13.child19/obj162", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 15, + preview: { + kind: "Object", + ownProperties: {}, + ownSymbols: [], + ownPropertiesLength: 15, + ownSymbolsLength: 0, + safeGetterValues: {}, + }, + }, + ownSymbols: [ + { + name: "Symbol()", + descriptor: { + configurable: true, + enumerable: true, + writable: true, + value: "hello", + }, + }, + ], +}); + +stubs.set("longs-string-safe-getter", { + ownProperties: { + baseVal: { + getterValue: { + type: "longString", + initial: "data:image/png;base64,initial", + length: 95080, + actor: "server1.conn1.child1/longString28", + }, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + }, + from: "server1.conn1.child1/propertyIterator30", +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/object-inspector/map.js b/devtools/client/shared/components/test/node/stubs/object-inspector/map.js new file mode 100644 index 0000000000..ed97b51c88 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/object-inspector/map.js @@ -0,0 +1,154 @@ +/* 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/>. */ + +const stubs = new Map(); + +stubs.set("properties", { + from: "server2.conn14.child18/obj30", + prototype: { + type: "object", + actor: "server2.conn14.child18/obj31", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 11, + preview: { + kind: "Object", + ownProperties: {}, + ownSymbols: [], + ownPropertiesLength: 11, + ownSymbolsLength: 2, + safeGetterValues: {}, + }, + }, + ownProperties: {}, + ownSymbols: [], + safeGetterValues: { + size: { + getterValue: 2, + getterPrototypeLevel: 2, + enumerable: false, + writable: true, + }, + }, +}); + +stubs.set("11-entries", { + ownProperties: { + "0": { + enumerable: true, + value: { + type: "mapEntry", + preview: { + key: "key-0", + value: "value-0", + }, + }, + }, + "1": { + enumerable: true, + value: { + type: "mapEntry", + preview: { + key: "key-1", + value: "value-1", + }, + }, + }, + "2": { + enumerable: true, + value: { + type: "mapEntry", + preview: { + key: "key-2", + value: "value-2", + }, + }, + }, + "3": { + enumerable: true, + value: { + type: "mapEntry", + preview: { + key: "key-3", + value: "value-3", + }, + }, + }, + "4": { + enumerable: true, + value: { + type: "mapEntry", + preview: { + key: "key-4", + value: "value-4", + }, + }, + }, + "5": { + enumerable: true, + value: { + type: "mapEntry", + preview: { + key: "key-5", + value: "value-5", + }, + }, + }, + "6": { + enumerable: true, + value: { + type: "mapEntry", + preview: { + key: "key-6", + value: "value-6", + }, + }, + }, + "7": { + enumerable: true, + value: { + type: "mapEntry", + preview: { + key: "key-7", + value: "value-7", + }, + }, + }, + "8": { + enumerable: true, + value: { + type: "mapEntry", + preview: { + key: "key-8", + value: "value-8", + }, + }, + }, + "9": { + enumerable: true, + value: { + type: "mapEntry", + preview: { + key: "key-9", + value: "value-9", + }, + }, + }, + "10": { + enumerable: true, + value: { + type: "mapEntry", + preview: { + key: "key-10", + value: "value-10", + }, + }, + }, + }, + from: "server4.conn4.child19/propertyIterator54", +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/object-inspector/performance.js b/devtools/client/shared/components/test/node/stubs/object-inspector/performance.js new file mode 100644 index 0000000000..5488070f14 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/object-inspector/performance.js @@ -0,0 +1,784 @@ +/* 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/>. */ + +const stubs = new Map(); + +stubs.set("performance", { + from: "server2.conn4.child1/obj30", + prototype: { + type: "object", + actor: "server2.conn4.child1/obj33", + class: "PerformancePrototype", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 16, + preview: { + kind: "Object", + ownProperties: { + now: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server2.conn4.child1/obj34", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "now", + displayName: "now", + }, + }, + getEntries: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server2.conn4.child1/obj35", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "getEntries", + displayName: "getEntries", + }, + }, + getEntriesByType: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server2.conn4.child1/obj36", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "getEntriesByType", + displayName: "getEntriesByType", + }, + }, + getEntriesByName: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server2.conn4.child1/obj37", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "getEntriesByName", + displayName: "getEntriesByName", + }, + }, + clearResourceTimings: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server2.conn4.child1/obj38", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "clearResourceTimings", + displayName: "clearResourceTimings", + }, + }, + setResourceTimingBufferSize: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server2.conn4.child1/obj39", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "setResourceTimingBufferSize", + displayName: "setResourceTimingBufferSize", + }, + }, + mark: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server2.conn4.child1/obj40", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "mark", + displayName: "mark", + }, + }, + clearMarks: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server2.conn4.child1/obj41", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "clearMarks", + displayName: "clearMarks", + }, + }, + measure: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server2.conn4.child1/obj42", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "measure", + displayName: "measure", + }, + }, + clearMeasures: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server2.conn4.child1/obj43", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "clearMeasures", + displayName: "clearMeasures", + }, + }, + }, + ownPropertiesLength: 16, + }, + }, + ownProperties: { + userTimingJsNow: { + configurable: true, + enumerable: true, + writable: true, + value: false, + }, + userTimingJsNowPrefixed: { + configurable: true, + enumerable: true, + writable: true, + value: false, + }, + userTimingJsUserTiming: { + configurable: true, + enumerable: true, + writable: true, + value: false, + }, + userTimingJsUserTimingPrefixed: { + configurable: true, + enumerable: true, + writable: true, + value: false, + }, + userTimingJsPerformanceTimeline: { + configurable: true, + enumerable: true, + writable: true, + value: false, + }, + userTimingJsPerformanceTimelinePrefixed: { + configurable: true, + enumerable: true, + writable: true, + value: false, + }, + timeOrigin: { + enumerable: true, + writable: true, + value: 1500971976372.9033, + }, + timing: { + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server2.conn4.child1/obj44", + class: "PerformanceTiming", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: {}, + ownPropertiesLength: 0, + safeGetterValues: { + navigationStart: { + getterValue: 1500971976373, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + unloadEventStart: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + unloadEventEnd: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + redirectStart: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + redirectEnd: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + fetchStart: { + getterValue: 1500971982226, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + domainLookupStart: { + getterValue: 1500971982251, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + domainLookupEnd: { + getterValue: 1500971982255, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + connectStart: { + getterValue: 1500971982255, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + connectEnd: { + getterValue: 1500971982638, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + }, + }, + }, + }, + navigation: { + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server2.conn4.child1/obj45", + class: "PerformanceNavigation", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: {}, + ownPropertiesLength: 0, + safeGetterValues: { + type: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + redirectCount: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + }, + }, + }, + }, + onresourcetimingbufferfull: { + enumerable: true, + writable: true, + value: { + type: "null", + }, + }, + }, + safeGetterValues: { + timeOrigin: { + getterValue: 1500971976372.9033, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + timing: { + getterValue: { + type: "object", + actor: "server2.conn4.child1/obj44", + class: "PerformanceTiming", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: {}, + ownPropertiesLength: 0, + safeGetterValues: { + navigationStart: { + getterValue: 1500971976373, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + unloadEventStart: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + unloadEventEnd: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + redirectStart: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + redirectEnd: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + fetchStart: { + getterValue: 1500971982226, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + domainLookupStart: { + getterValue: 1500971982251, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + domainLookupEnd: { + getterValue: 1500971982255, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + connectStart: { + getterValue: 1500971982255, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + connectEnd: { + getterValue: 1500971982638, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + }, + }, + }, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + navigation: { + getterValue: { + type: "object", + actor: "server2.conn4.child1/obj45", + class: "PerformanceNavigation", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: {}, + ownPropertiesLength: 0, + safeGetterValues: { + type: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + redirectCount: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + }, + }, + }, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + onresourcetimingbufferfull: { + getterValue: { + type: "null", + }, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + }, +}); + +stubs.set("timing", { + from: "server1.conn1.child1/obj31", + prototype: { + type: "object", + actor: "server1.conn1.child1/obj32", + class: "PerformanceTimingPrototype", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 23, + preview: { + kind: "Object", + ownProperties: { + toJSON: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server1.conn1.child1/obj33", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "toJSON", + displayName: "toJSON", + }, + }, + navigationStart: { + configurable: true, + enumerable: true, + get: { + type: "object", + actor: "server1.conn1.child1/obj34", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "get navigationStart", + displayName: "get navigationStart", + }, + set: { + type: "undefined", + }, + }, + unloadEventStart: { + configurable: true, + enumerable: true, + get: { + type: "object", + actor: "server1.conn1.child1/obj35", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "get unloadEventStart", + displayName: "get unloadEventStart", + }, + set: { + type: "undefined", + }, + }, + unloadEventEnd: { + configurable: true, + enumerable: true, + get: { + type: "object", + actor: "server1.conn1.child1/obj36", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "get unloadEventEnd", + displayName: "get unloadEventEnd", + }, + set: { + type: "undefined", + }, + }, + redirectStart: { + configurable: true, + enumerable: true, + get: { + type: "object", + actor: "server1.conn1.child1/obj37", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "get redirectStart", + displayName: "get redirectStart", + }, + set: { + type: "undefined", + }, + }, + redirectEnd: { + configurable: true, + enumerable: true, + get: { + type: "object", + actor: "server1.conn1.child1/obj38", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "get redirectEnd", + displayName: "get redirectEnd", + }, + set: { + type: "undefined", + }, + }, + fetchStart: { + configurable: true, + enumerable: true, + get: { + type: "object", + actor: "server1.conn1.child1/obj39", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "get fetchStart", + displayName: "get fetchStart", + }, + set: { + type: "undefined", + }, + }, + domainLookupStart: { + configurable: true, + enumerable: true, + get: { + type: "object", + actor: "server1.conn1.child1/obj40", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "get domainLookupStart", + displayName: "get domainLookupStart", + }, + set: { + type: "undefined", + }, + }, + domainLookupEnd: { + configurable: true, + enumerable: true, + get: { + type: "object", + actor: "server1.conn1.child1/obj41", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "get domainLookupEnd", + displayName: "get domainLookupEnd", + }, + set: { + type: "undefined", + }, + }, + connectStart: { + configurable: true, + enumerable: true, + get: { + type: "object", + actor: "server1.conn1.child1/obj42", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "get connectStart", + displayName: "get connectStart", + }, + set: { + type: "undefined", + }, + }, + }, + ownPropertiesLength: 23, + }, + }, + ownProperties: {}, + safeGetterValues: { + navigationStart: { + getterValue: 1500967716401, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + unloadEventStart: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + unloadEventEnd: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + redirectStart: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + redirectEnd: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + fetchStart: { + getterValue: 1500967716401, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + domainLookupStart: { + getterValue: 1500967716401, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + domainLookupEnd: { + getterValue: 1500967716401, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + connectStart: { + getterValue: 1500967716401, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + connectEnd: { + getterValue: 1500967716401, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + secureConnectionStart: { + getterValue: 1500967716401, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + requestStart: { + getterValue: 1500967716401, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + responseStart: { + getterValue: 1500967716401, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + responseEnd: { + getterValue: 1500967716401, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + domLoading: { + getterValue: 1500967716426, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + domInteractive: { + getterValue: 1500967716552, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + domContentLoadedEventStart: { + getterValue: 1500967716696, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + domContentLoadedEventEnd: { + getterValue: 1500967716715, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + domComplete: { + getterValue: 1500967716719, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + loadEventStart: { + getterValue: 1500967716719, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + loadEventEnd: { + getterValue: 1500967716720, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/accessible.js b/devtools/client/shared/components/test/node/stubs/reps/accessible.js new file mode 100644 index 0000000000..3c9834e6d3 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/accessible.js @@ -0,0 +1,74 @@ +/* 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 stubs = new Map(); +stubs.set("Document", { + actor: "server1.conn1.child1/accessible31", + typeName: "accessible", + preview: { + name: "New Tab", + role: "document", + isConnected: true, + }, +}); + +stubs.set("ButtonMenu", { + actor: "server1.conn1.child1/accessible38", + typeName: "accessible", + preview: { + name: "New to Nightly? Let’s get started.", + role: "buttonmenu", + isConnected: true, + }, +}); + +stubs.set("NoName", { + actor: "server1.conn1.child1/accessible93", + typeName: "accessible", + preview: { + name: null, + role: "text container", + isConnected: true, + }, +}); + +stubs.set("NoPreview", { + actor: "server1.conn1.child1/accessible93", + typeName: "accessible", +}); + +stubs.set("DisconnectedAccessible", { + actor: null, + typeName: "accessible", + preview: { + name: null, + role: "section", + isConnected: false, + }, +}); + +const name = "a".repeat(1000); +stubs.set("AccessibleWithLongName", { + actor: "server1.conn1.child1/accessible98", + typeName: "accessible", + preview: { + name, + role: "text leaf", + isConnected: true, + }, +}); + +stubs.set("PushButton", { + actor: "server1.conn1.child1/accessible157", + typeName: "accessible", + preview: { + name: "Search", + role: "pushbutton", + isConnected: true, + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/accessor.js b/devtools/client/shared/components/test/node/stubs/reps/accessor.js new file mode 100644 index 0000000000..cee4a836ce --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/accessor.js @@ -0,0 +1,85 @@ +/* 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 stubs = new Map(); + +stubs.set("getter", { + configurable: true, + enumerable: true, + get: { + type: "object", + actor: "server2.conn1.child1/obj106", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "get x", + displayName: "get x", + location: { + url: "debugger eval code", + line: 1, + }, + }, + set: { + type: "undefined", + }, +}); + +stubs.set("setter", { + configurable: true, + enumerable: true, + get: { + type: "undefined", + }, + set: { + type: "object", + actor: "server2.conn1.child1/obj116", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "set x", + displayName: "set x", + location: { + url: "debugger eval code", + line: 1, + }, + }, +}); + +stubs.set("getter setter", { + configurable: true, + enumerable: true, + get: { + type: "object", + actor: "server2.conn1.child1/obj127", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "get x", + displayName: "get x", + location: { + url: "debugger eval code", + line: 1, + }, + }, + set: { + type: "object", + actor: "server2.conn1.child1/obj128", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "set x", + displayName: "set x", + location: { + url: "debugger eval code", + line: 1, + }, + }, +}); +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/attribute.js b/devtools/client/shared/components/test/node/stubs/reps/attribute.js new file mode 100644 index 0000000000..d820da07f7 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/attribute.js @@ -0,0 +1,36 @@ +/* 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"; +/* + * THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND. RUN browser_reps_stubs.js with STUBS_UPDATE=true env TO UPDATE. + */ + +const stubs = new Map(); +stubs.set(`Attribute`, { + "_grip": { + "type": "object", + "actor": "server0.conn0.windowGlobal12884901889/obj24", + "class": "Attr", + "ownPropertyLength": 0, + "extensible": true, + "frozen": false, + "sealed": false, + "isError": false, + "preview": { + "kind": "DOMNode", + "nodeType": 2, + "nodeName": "class", + "isConnected": false, + "value": "autocomplete-suggestions" + }, + "contentDomReference": { + "browsingContextId": 51, + "id": 0.4985715593006155 + } + }, + "actorID": "server0.conn0.windowGlobal12884901889/obj24" +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/big-int.js b/devtools/client/shared/components/test/node/stubs/reps/big-int.js new file mode 100644 index 0000000000..423145359b --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/big-int.js @@ -0,0 +1,196 @@ +/* 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 stubs = new Map(); +stubs.set("1n", { + type: "BigInt", + text: "1", +}); + +stubs.set("-2n", { + type: "BigInt", + text: "-2", +}); + +stubs.set("0n", { + type: "BigInt", + text: "0", +}); + +stubs.set("[1n,-2n,0n]", { + type: "object", + actor: "server1.conn15.child1/obj27", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "ArrayLike", + length: 3, + items: [ + { + type: "BigInt", + text: "1", + }, + { + type: "BigInt", + text: "-2", + }, + { + type: "BigInt", + text: "0", + }, + ], + }, +}); + +stubs.set("new Set([1n,-2n,0n])", { + type: "object", + actor: "server1.conn15.child1/obj29", + class: "Set", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "ArrayLike", + length: 3, + items: [ + { + type: "BigInt", + text: "1", + }, + { + type: "BigInt", + text: "-2", + }, + { + type: "BigInt", + text: "0", + }, + ], + }, +}); + +stubs.set("new Map([ [1n, -1n], [-2n, 0n], [0n, -2n]])", { + type: "object", + actor: "server1.conn15.child1/obj32", + class: "Map", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "MapLike", + size: 3, + entries: [ + [ + { + type: "BigInt", + text: "1", + }, + { + type: "BigInt", + text: "-1", + }, + ], + [ + { + type: "BigInt", + text: "-2", + }, + { + type: "BigInt", + text: "0", + }, + ], + [ + { + type: "BigInt", + text: "0", + }, + { + type: "BigInt", + text: "-2", + }, + ], + ], + }, +}); + +stubs.set("({simple: 1n, negative: -2n, zero: 0n})", { + type: "object", + actor: "server1.conn15.child1/obj34", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 3, + preview: { + kind: "Object", + ownProperties: { + simple: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "BigInt", + text: "1", + }, + }, + negative: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "BigInt", + text: "-2", + }, + }, + zero: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "BigInt", + text: "0", + }, + }, + }, + ownSymbols: [], + ownPropertiesLength: 3, + ownSymbolsLength: 0, + safeGetterValues: {}, + }, +}); + +stubs.set("Promise.resolve(1n)", { + type: "object", + actor: "server1.conn15.child1/obj36", + class: "Promise", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: { + "<state>": { + value: "fulfilled", + }, + "<value>": { + value: { + type: "BigInt", + text: "1", + }, + }, + }, + ownPropertiesLength: 2, + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/browser_dummy.js b/devtools/client/shared/components/test/node/stubs/reps/browser_dummy.js new file mode 100644 index 0000000000..8a9353cd7e --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/browser_dummy.js @@ -0,0 +1,11 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This file is a fake test so we can have support files in the stubs.ini, which are then +// referenced as support files in the webconsole mochitest ini file. + +"use strict"; + +add_task(function() { + ok(true, "this is not a test"); +}); diff --git a/devtools/client/shared/components/test/node/stubs/reps/comment-node.js b/devtools/client/shared/components/test/node/stubs/reps/comment-node.js new file mode 100644 index 0000000000..5ee0970283 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/comment-node.js @@ -0,0 +1,36 @@ +/* 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"; +/* + * THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND. RUN browser_reps_stubs.js with STUBS_UPDATE=true env TO UPDATE. + */ + +const stubs = new Map(); +stubs.set(`Comment`, { + "_grip": { + "type": "object", + "actor": "server0.conn0.windowGlobal4294967299/obj26", + "class": "Comment", + "ownPropertyLength": 0, + "extensible": true, + "frozen": false, + "sealed": false, + "isError": false, + "preview": { + "kind": "DOMNode", + "nodeType": 8, + "nodeName": "#comment", + "isConnected": false, + "textContent": "test\nand test\nand test\nand test\nand test\nand test\nand test" + }, + "contentDomReference": { + "browsingContextId": 51, + "id": 0.7876406289746626 + } + }, + "actorID": "server0.conn0.windowGlobal4294967299/obj26" +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/date-time.js b/devtools/client/shared/components/test/node/stubs/reps/date-time.js new file mode 100644 index 0000000000..9027397104 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/date-time.js @@ -0,0 +1,47 @@ +/* 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"; +/* + * THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND. RUN browser_reps_stubs.js with STUBS_UPDATE=true env TO UPDATE. + */ + +const stubs = new Map(); +stubs.set(`DateTime`, { + "_grip": { + "type": "object", + "actor": "server0.conn0.windowGlobal4294967299/obj28", + "class": "Date", + "ownPropertyLength": 0, + "extensible": true, + "frozen": false, + "sealed": false, + "isError": false, + "preview": { + "timestamp": 1459372644859 + } + }, + "actorID": "server0.conn0.windowGlobal4294967299/obj28" +}); + +stubs.set(`InvalidDateTime`, { + "_grip": { + "type": "object", + "actor": "server0.conn0.windowGlobal4294967299/obj30", + "class": "Date", + "ownPropertyLength": 0, + "extensible": true, + "frozen": false, + "sealed": false, + "isError": false, + "preview": { + "timestamp": { + "type": "NaN" + } + } + }, + "actorID": "server0.conn0.windowGlobal4294967299/obj30" +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/document-type.js b/devtools/client/shared/components/test/node/stubs/reps/document-type.js new file mode 100644 index 0000000000..a499670b58 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/document-type.js @@ -0,0 +1,40 @@ +/* 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 stubs = new Map(); +stubs.set("html", { + type: "object", + actor: "server1.conn7.child1/obj195", + class: "DocumentType", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 10, + nodeName: "html", + isConnected: true, + }, +}); + +stubs.set("unnamed", { + type: "object", + actor: "server1.conn7.child1/obj195", + class: "DocumentType", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 10, + nodeName: "", + isConnected: true, + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/document.js b/devtools/client/shared/components/test/node/stubs/reps/document.js new file mode 100644 index 0000000000..555bbff2d1 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/document.js @@ -0,0 +1,39 @@ +/* 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 stubs = new Map(); +stubs.set("Document", { + type: "object", + class: "HTMLDocument", + actor: "server1.conn17.obj115", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "DOMNode", + nodeType: 9, + nodeName: "#document", + location: "https://www.mozilla.org/en-US/firefox/new/", + }, +}); + +stubs.set("Location-less Document", { + type: "object", + actor: "server1.conn6.child1/obj31", + class: "HTMLDocument", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "DOMNode", + nodeType: 9, + nodeName: "#document", + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/element-node.js b/devtools/client/shared/components/test/node/stubs/reps/element-node.js new file mode 100644 index 0000000000..b0b4369b3c --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/element-node.js @@ -0,0 +1,292 @@ +/* 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 stubs = new Map(); +stubs.set("BodyNode", { + type: "object", + actor: "server1.conn1.child1/obj30", + class: "HTMLBodyElement", + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "body", + attributes: { + class: "body-class", + id: "body-id", + }, + attributesLength: 2, + }, +}); + +stubs.set("DocumentElement", { + type: "object", + actor: "server1.conn1.child1/obj40", + class: "HTMLHtmlElement", + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "html", + attributes: { + dir: "ltr", + lang: "en-US", + }, + attributesLength: 2, + }, +}); + +stubs.set("Node", { + type: "object", + actor: "server1.conn2.child1/obj116", + class: "HTMLInputElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "input", + isConnected: true, + attributes: { + id: "newtab-customize-button", + dir: "ltr", + title: "Customize your New Tab page", + class: "bar baz", + value: "foo", + type: "button", + }, + attributesLength: 6, + }, +}); + +stubs.set("DisconnectedNode", { + type: "object", + actor: "server1.conn2.child1/obj116", + class: "HTMLInputElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "input", + isConnected: false, + attributes: { + id: "newtab-customize-button", + dir: "ltr", + title: "Customize your New Tab page", + class: "bar baz", + value: "foo", + type: "button", + }, + attributesLength: 6, + }, +}); + +stubs.set("NodeWithLeadingAndTrailingSpacesClassName", { + type: "object", + actor: "server1.conn3.child1/obj59", + class: "HTMLBodyElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "body", + attributes: { + id: "nightly-whatsnew", + class: " html-ltr ", + }, + attributesLength: 2, + }, +}); + +stubs.set("NodeWithSpacesInClassName", { + type: "object", + actor: "server1.conn3.child1/obj59", + class: "HTMLBodyElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "body", + attributes: { + class: "a b c", + }, + attributesLength: 1, + }, +}); + +stubs.set("NodeWithoutAttributes", { + type: "object", + actor: "server1.conn1.child1/obj32", + class: "HTMLParagraphElement", + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "p", + attributes: {}, + attributesLength: 1, + }, +}); + +stubs.set("LotsOfAttributes", { + type: "object", + actor: "server1.conn2.child1/obj30", + class: "HTMLParagraphElement", + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "p", + attributes: { + id: "lots-of-attributes", + a: "", + b: "", + c: "", + d: "", + e: "", + f: "", + g: "", + h: "", + i: "", + j: "", + k: "", + l: "", + m: "", + n: "", + }, + attributesLength: 15, + }, +}); + +stubs.set("SvgNode", { + type: "object", + actor: "server1.conn1.child1/obj42", + class: "SVGClipPathElement", + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "clipPath", + attributes: { + id: "clip", + class: "svg-element", + }, + attributesLength: 0, + }, +}); + +stubs.set("SvgNodeInXHTML", { + type: "object", + actor: "server1.conn3.child1/obj34", + class: "SVGCircleElement", + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "svg:circle", + attributes: { + class: "svg-element", + cx: "0", + cy: "0", + r: "5", + }, + attributesLength: 3, + }, +}); + +stubs.set("NodeWithLongAttribute", { + type: "object", + actor: "server1.conn1.child1/obj32", + class: "HTMLParagraphElement", + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "p", + attributes: { + "data-test": "a".repeat(100), + }, + attributesLength: 1, + }, +}); + +const initialText = "a".repeat(1000); +stubs.set("NodeWithLongStringAttribute", { + type: "object", + actor: "server1.conn1.child1/obj28", + class: "HTMLDivElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "div", + isConnected: false, + attributes: { + "data-test": { + type: "longString", + initial: initialText, + length: 50000, + actor: "server1.conn1.child1/longString29", + }, + }, + attributesLength: 1, + }, +}); + +stubs.set("MarkerPseudoElement", { + type: "object", + actor: "server1.conn1.child1/obj26", + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "_moz_generated_content_marker", + attributes: {}, + attributesLength: 0, + isMarkerPseudoElement: true, + }, +}); + +stubs.set("BeforePseudoElement", { + type: "object", + actor: "server1.conn1.child1/obj27", + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "_moz_generated_content_before", + attributes: {}, + attributesLength: 0, + isBeforePseudoElement: true, + }, +}); + +stubs.set("AfterPseudoElement", { + type: "object", + actor: "server1.conn1.child1/obj28", + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "_moz_generated_content_after", + attributes: {}, + attributesLength: 0, + isAfterPseudoElement: true, + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/error.js b/devtools/client/shared/components/test/node/stubs/reps/error.js new file mode 100644 index 0000000000..ea4b7ac4ee --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/error.js @@ -0,0 +1,396 @@ +/* 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 stubs = new Map(); +stubs.set("SimpleError", { + type: "object", + actor: "server1.conn1.child1/obj1020", + class: "Error", + isError: true, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "Error", + message: "Error message", + stack: "@debugger eval code:1:13\n", + fileName: "debugger eval code", + lineNumber: 1, + columnNumber: 13, + }, +}); + +stubs.set("MultilineStackError", { + type: "object", + actor: "server1.conn1.child1/obj1021", + class: "Error", + isError: true, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "Error", + message: "bar", + stack: + "errorBar@debugger eval code:6:15\n" + + "errorFoo@debugger eval code:3:3\n" + + "@debugger eval code:8:1\n", + fileName: "debugger eval code", + lineNumber: 6, + columnNumber: 15, + }, +}); + +stubs.set("ErrorWithoutStacktrace", { + type: "object", + actor: "server1.conn1.child1/obj1020", + class: "Error", + isError: true, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "Error", + message: "Error message", + }, +}); + +stubs.set("EvalError", { + type: "object", + actor: "server1.conn1.child1/obj1022", + class: "Error", + isError: true, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "EvalError", + message: "EvalError message", + stack: "@debugger eval code:10:13\n", + fileName: "debugger eval code", + lineNumber: 10, + columnNumber: 13, + }, +}); + +stubs.set("InternalError", { + type: "object", + actor: "server1.conn1.child1/obj1023", + class: "Error", + isError: true, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "InternalError", + message: "InternalError message", + stack: "@debugger eval code:11:13\n", + fileName: "debugger eval code", + lineNumber: 11, + columnNumber: 13, + }, +}); + +stubs.set("RangeError", { + type: "object", + actor: "server1.conn1.child1/obj1024", + class: "Error", + isError: true, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "RangeError", + message: "RangeError message", + stack: "@debugger eval code:12:13\n", + fileName: "debugger eval code", + lineNumber: 12, + columnNumber: 13, + }, +}); + +stubs.set("ReferenceError", { + type: "object", + actor: "server1.conn1.child1/obj1025", + class: "Error", + isError: true, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "ReferenceError", + message: "ReferenceError message", + stack: "@debugger eval code:13:13\n", + fileName: "debugger eval code", + lineNumber: 13, + columnNumber: 13, + }, +}); + +stubs.set("SyntaxError", { + type: "object", + actor: "server1.conn1.child1/obj1026", + class: "Error", + isError: true, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "SyntaxError", + message: "SyntaxError message", + stack: "@debugger eval code:14:13\n", + fileName: "debugger eval code", + lineNumber: 14, + columnNumber: 13, + }, +}); + +stubs.set("TypeError", { + type: "object", + actor: "server1.conn1.child1/obj1027", + class: "Error", + isError: true, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "TypeError", + message: "TypeError message", + stack: "@debugger eval code:15:13\n", + fileName: "debugger eval code", + lineNumber: 15, + columnNumber: 13, + }, +}); + +stubs.set("URIError", { + type: "object", + actor: "server1.conn1.child1/obj1028", + class: "Error", + isError: true, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "URIError", + message: "URIError message", + stack: "@debugger eval code:16:13\n", + fileName: "debugger eval code", + lineNumber: 16, + columnNumber: 13, + }, +}); + +/** + * Example code: + * try { + * var foo = document.querySelector("foo;()bar!"); + * } catch (ex) { + * ex; + * } + */ +stubs.set("DOMException", { + type: "object", + actor: "server2.conn2.child3/obj32", + class: "DOMException", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMException", + name: "SyntaxError", + message: "'foo;()bar!' is not a valid selector", + code: 12, + result: 2152923148, + filename: "debugger eval code", + lineNumber: 1, + columnNumber: 0, + }, +}); + +stubs.set("base-loader Error", { + type: "object", + actor: "server1.conn1.child1/obj1020", + class: "Error", + isError: true, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "Error", + message: "Error message", + stack: + "onPacket@resource://devtools/shared/base-loader.sys.mjs -> resource://devtools/client/debugger-client.js:856:9\n" + + "send/<@resource://devtools/shared/base-loader.sys.mjs -> resource://devtools/shared/transport/transport.js:569:13\n" + + "exports.makeInfallible/<@resource://devtools/shared/base-loader.sys.mjs -> resource://devtools/shared/ThreadSafeDevToolsUtils.js:109:14\n" + + "exports.makeInfallible/<@resource://devtools/shared/base-loader.sys.mjs -> resource://devtools/shared/ThreadSafeDevToolsUtils.js:109:14\n", + fileName: "debugger-client.js", + lineNumber: 859, + columnNumber: 9, + }, +}); + +stubs.set("longString stack Error", { + type: "object", + actor: "server1.conn2.child1/obj33", + class: "Error", + isError: true, + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "Error", + message: "", + stack: { + type: "longString", + initial: + "NgForOf.prototype.ngOnChanges@webpack-internal:///./node_modules/@angular/common/esm5/common.js:2656:27\n checkAndUpdateDirectiveInline@webpack-internal:///./node_modules/@angular/core/esm5/core.js:12581:9\n checkAndUpdateNodeInline@webpack-internal:///./node_modules/@angular/core/esm5/core.js:14109:20\n checkAndUpdateNode@webpack-internal:///./node_modules/@angular/core/esm5/core.js:14052:16\n debugCheckAndUpdateNode@webpack-internal:///./node_modules/@angular/core/esm5/core.js:14945:55\n debugCheckDirectivesFn@webpack-internal:///./node_modules/@angular/core/esm5/core.js:14886:13\n View_MetaTableComponent_6/<@ng:///AppModule/MetaTableComponent.ngfactory.js:98:5\n debugUpdateDirectives@webpack-internal:///./node_modules/@angular/core/esm5/core.js:14871:12\n checkAndUpdateView@webpack-internal:///./node_modules/@angular/core/esm5/core.js:14018:5\n callViewAction@webpack-internal:///./node_modules/@angular/core/esm5/core.js:14369:21\n execEmbeddedViewsAction@webpack-internal:///./node_modules/@an", + length: 11907, + actor: "server1.conn2.child1/longString31", + }, + fileName: "debugger eval code", + lineNumber: 1, + columnNumber: 5, + }, +}); + +stubs.set("longString stack Error - cut-off location", { + type: "object", + actor: "server1.conn1.child1/obj33", + class: "Error", + isError: true, + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 6, + preview: { + kind: "Error", + name: "InternalError", + message: "too much recursion", + stack: { + type: "longString", + initial: + "execute/AppComponent</AppComponent.prototype.doStuff@https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:32:1\nexecute/AppComponent</AppComponent.prototype.doStuff@https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21\nexecute/AppComponent</AppComponent.prototype.doStuff@https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21\nexecute/AppComponent</AppComponent.prototype.doStuff@https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21\nexecute/AppComponent</AppComponent.prototype.doStuff@https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21\nexecute/AppComponent</AppComponent.prototype.doStuff@https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21\nexecute/AppComponent</AppComponent.prototype.doStuff@https://angular-3eqab4.stackblitz.io/tmp/appfiles/src/app/app.component.ts:33:21\nexecute/AppComponent</AppComponent.prototype.doStuff@https://an", + length: 17151, + actor: "server1.conn1.child1/longString27", + }, + fileName: + "https://c.staticblitz.com/assets/engineblock-bc7b07e99ec5c6739c766b4898e4cff5acfddc137ccb7218377069c32731f1d0.js line 1 > eval", + lineNumber: 32, + columnNumber: 1, + }, +}); + +stubs.set("Error with V8-like stack", { + type: "object", + actor: "server1.conn1.child1/obj1020", + class: "Error", + isError: true, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "Error", + message: "BOOM", + stack: "Error: BOOM\ngetAccount@http://moz.com/script.js:1:2", + fileName: "http://moz.com/script.js:1:2", + lineNumber: 1, + columnNumber: 2, + }, +}); + +stubs.set("Error with invalid stack", { + type: "object", + actor: "server1.conn1.child1/obj1020", + class: "Error", + isError: true, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "Error", + message: "bad stack", + stack: "bar\nbaz\nfoo\n\n\n\n\n\n\n", + fileName: "http://moz.com/script.js:1:2", + lineNumber: 1, + columnNumber: 2, + }, +}); + +stubs.set("Error with undefined-grip stack", { + type: "object", + actor: "server0.conn0.child1/obj88", + class: "Error", + isError: true, + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "InternalError", + message: "too much recursion", + stack: { + type: "undefined", + }, + fileName: "debugger eval code", + lineNumber: 13, + columnNumber: 13, + }, +}); + +stubs.set("Error with undefined-grip name", { + type: "object", + actor: "server0.conn0.child1/obj88", + class: "Error", + isError: true, + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: { + type: "undefined", + }, + message: "too much recursion", + stack: "@debugger eval code:16:13\n", + fileName: "debugger eval code", + lineNumber: 13, + columnNumber: 13, + }, +}); + +stubs.set("Error with undefined-grip message", { + type: "object", + actor: "server0.conn0.child1/obj88", + class: "Error", + isError: true, + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "Error", + message: { type: "undefined" }, + stack: "@debugger eval code:16:13\n", + fileName: "debugger eval code", + lineNumber: 13, + columnNumber: 13, + }, +}); + +stubs.set("Error with stack having frames with multiple @", { + type: "object", + actor: "server1.conn1.child1/obj1021", + class: "Error", + isError: true, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "Error", + message: "bar", + stack: + "errorBar@https://example.com/turbo/from-npm.js@0.8.26/dist/from-npm.js:814:31\n" + + "errorFoo@https://example.com/turbo/from-npm.js@0.8.26/dist/from-npm.js:815:31\n" + + "@https://example.com/turbo/from-npm.js@0.8.26/dist/from-npm.js:816:31\n", + fileName: "from-npm.js", + lineNumber: 6, + columnNumber: 15, + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/event.js b/devtools/client/shared/components/test/node/stubs/reps/event.js new file mode 100644 index 0000000000..c0c49c5e42 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/event.js @@ -0,0 +1,269 @@ +/* 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 stubs = new Map(); + +stubs.set("testEvent", { + type: "object", + class: "Event", + actor: "server1.conn23.obj35", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "DOMEvent", + type: "beforeprint", + properties: { + isTrusted: true, + currentTarget: { + type: "object", + class: "Window", + actor: "server1.conn23.obj37", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 760, + preview: { + kind: "ObjectWithURL", + url: "http://example.com", + }, + }, + eventPhase: 2, + bubbles: false, + cancelable: false, + defaultPrevented: false, + timeStamp: 1466780008434005, + originalTarget: { + type: "object", + class: "Window", + actor: "server1.conn23.obj38", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 760, + preview: { + kind: "ObjectWithURL", + url: "http://example.com", + }, + }, + explicitOriginalTarget: { + type: "object", + class: "Window", + actor: "server1.conn23.obj39", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 760, + preview: { + kind: "ObjectWithURL", + url: "http://example.com", + }, + }, + NONE: 0, + }, + target: { + type: "object", + class: "Window", + actor: "server1.conn23.obj36", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 760, + preview: { + kind: "ObjectWithURL", + url: "http://example.com", + }, + }, + }, +}); + +stubs.set("testMouseEvent", { + type: "object", + class: "MouseEvent", + actor: "server1.conn20.obj39", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "DOMEvent", + type: "click", + properties: { + buttons: 0, + clientX: 62, + clientY: 18, + layerX: 0, + layerY: 0, + }, + target: { + type: "object", + class: "HTMLDivElement", + actor: "server1.conn20.obj40", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "div", + isConnected: true, + attributes: { + id: "test", + }, + attributesLength: 1, + }, + }, + }, +}); + +stubs.set("testKeyboardEvent", { + type: "object", + class: "KeyboardEvent", + actor: "server1.conn21.obj49", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "DOMEvent", + type: "keyup", + properties: { + key: "Control", + charCode: 0, + keyCode: 17, + }, + target: { + type: "object", + class: "HTMLBodyElement", + actor: "server1.conn21.obj50", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "body", + attributes: {}, + attributesLength: 0, + }, + }, + eventKind: "key", + modifiers: [], + }, +}); + +stubs.set("testKeyboardEventWithModifiers", { + type: "object", + class: "KeyboardEvent", + actor: "server1.conn21.obj49", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "DOMEvent", + type: "keyup", + properties: { + key: "M", + charCode: 0, + keyCode: 77, + }, + target: { + type: "object", + class: "HTMLBodyElement", + actor: "server1.conn21.obj50", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "body", + attributes: {}, + attributesLength: 0, + }, + }, + eventKind: "key", + modifiers: ["Meta", "Shift"], + }, +}); + +stubs.set("testMessageEvent", { + type: "object", + class: "MessageEvent", + actor: "server1.conn3.obj34", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "DOMEvent", + type: "message", + properties: { + isTrusted: false, + data: "test data", + origin: "null", + lastEventId: "", + source: { + type: "object", + class: "Window", + actor: "server1.conn3.obj36", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 760, + preview: { + kind: "ObjectWithURL", + url: "", + }, + }, + ports: { + type: "object", + class: "Array", + actor: "server1.conn3.obj37", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + }, + currentTarget: { + type: "object", + class: "Window", + actor: "server1.conn3.obj38", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 760, + preview: { + kind: "ObjectWithURL", + url: "", + }, + }, + eventPhase: 2, + bubbles: false, + cancelable: false, + }, + target: { + type: "object", + class: "Window", + actor: "server1.conn3.obj35", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 760, + preview: { + kind: "ObjectWithURL", + url: "http://example.com", + }, + }, + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/failure.js b/devtools/client/shared/components/test/node/stubs/reps/failure.js new file mode 100644 index 0000000000..f839d5eab5 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/failure.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/>. */ + +"use strict"; + +const stubs = new Map(); +stubs.set("Failure", { + type: "object", + class: "RegExp", + actor: "server1.conn22.obj39", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + get displayString() { + throw new Error("failure"); + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/function.js b/devtools/client/shared/components/test/node/stubs/reps/function.js new file mode 100644 index 0000000000..a0ba6ddf8d --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/function.js @@ -0,0 +1,227 @@ +/* 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 stubs = new Map(); +stubs.set("Named", { + type: "object", + class: "Function", + actor: "server1.conn6.obj35", + extensible: true, + frozen: false, + sealed: false, + isAsync: false, + isGenerator: false, + name: "testName", + displayName: "testName", + location: { + url: "debugger eval code", + line: 1, + }, +}); + +stubs.set("UserNamed", { + type: "object", + class: "Function", + actor: "server1.conn6.obj35", + extensible: true, + frozen: false, + sealed: false, + isAsync: false, + isGenerator: false, + name: "testName", + userDisplayName: "testUserName", + displayName: "testName", + location: { + url: "debugger eval code", + line: 1, + }, +}); + +stubs.set("VarNamed", { + type: "object", + class: "Function", + actor: "server1.conn7.obj41", + extensible: true, + frozen: false, + sealed: false, + isAsync: false, + isGenerator: false, + displayName: "testVarName", + location: { + url: "debugger eval code", + line: 1, + }, +}); + +stubs.set("Anon", { + type: "object", + class: "Function", + actor: "server1.conn7.obj45", + extensible: true, + frozen: false, + sealed: false, + isAsync: false, + isGenerator: false, + location: { + url: "debugger eval code", + line: 1, + }, +}); + +stubs.set("LongName", { + type: "object", + class: "Function", + actor: "server1.conn7.obj67", + extensible: true, + frozen: false, + sealed: false, + isAsync: false, + isGenerator: false, + name: + "looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo" + + "ooooooooooooooooooooooooooooooooooong", + displayName: + "loooooooooooooooooooooooooooooooooooooooooooooooooooooooooo" + + "oooooooooooooooooooooooooooooooooooooooooong", + location: { + url: "debugger eval code", + line: 1, + }, +}); + +stubs.set("AsyncFunction", { + type: "object", + class: "Function", + actor: "server1.conn7.obj45", + extensible: true, + frozen: false, + sealed: false, + isAsync: true, + isGenerator: false, + name: "waitUntil2017", + displayName: "waitUntil2017", + location: { + url: "debugger eval code", + line: 1, + }, +}); + +stubs.set("AnonAsyncFunction", { + type: "object", + class: "Function", + actor: "server1.conn7.obj45", + extensible: true, + frozen: false, + sealed: false, + isAsync: true, + isGenerator: false, + location: { + url: "debugger eval code", + line: 1, + }, +}); + +stubs.set("GeneratorFunction", { + type: "object", + class: "Function", + actor: "server1.conn7.obj45", + extensible: true, + frozen: false, + sealed: false, + isAsync: false, + isGenerator: true, + name: "fib", + displayName: "fib", + location: { + url: "debugger eval code", + line: 1, + }, +}); + +stubs.set("AnonGeneratorFunction", { + type: "object", + class: "Function", + actor: "server1.conn7.obj45", + extensible: true, + frozen: false, + sealed: false, + isAsync: false, + isGenerator: true, + location: { + url: "debugger eval code", + line: 1, + }, +}); + +stubs.set("getRandom", { + type: "object", + actor: "server1.conn7.child1/obj984", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 3, + name: "getRandom", + displayName: "getRandom", + location: { + url: "https://nchevobbe.github.io/demo/console-test-app.html", + line: 314, + }, +}); + +stubs.set("EvaledInDebuggerFunction", { + type: "object", + actor: "server1.conn2.child1/obj29", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 3, + name: "evaledInDebugger", + displayName: "evaledInDebugger", + location: { + url: "debugger eval code", + line: 1, + }, +}); + +stubs.set("ObjectProperty", { + type: "object", + class: "Function", + actor: "server1.conn7.obj45", + extensible: true, + frozen: false, + sealed: false, + isAync: false, + isGenerator: false, + name: "$", + displayName: "jQuery", + location: { + url: "debugger eval code", + line: 1, + }, +}); + +stubs.set("EmptyClass", { + actor: "server0.conn0.child1/obj27", + class: "Function", + displayName: "EmptyClass", + extensible: true, + frozen: false, + isAsync: false, + isClassConstructor: true, + isGenerator: false, + location: { + url: "debugger eval code", + line: 1, + }, + name: "EmptyClass", + parameterNames: [], + sealed: false, + type: "object", +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/grip-array.js b/devtools/client/shared/components/test/node/stubs/reps/grip-array.js new file mode 100644 index 0000000000..93b07fa136 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/grip-array.js @@ -0,0 +1,1087 @@ +/* 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 { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const { + maxLengthMap, +} = require("resource://devtools/client/shared/components/reps/reps/grip-array.js"); +const stubs = new Map(); + +stubs.set("testBasic", { + type: "object", + class: "Array", + actor: "server1.conn0.obj35", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "ArrayLike", + length: 0, + items: [], + }, +}); + +stubs.set("DOMTokenList", { + type: "object", + actor: "server2.conn4.child12/obj39", + class: "DOMTokenList", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "ArrayLike", + length: 0, + items: [], + }, +}); + +stubs.set("testMaxProps", { + type: "object", + class: "Array", + actor: "server1.conn1.obj35", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "ArrayLike", + length: 3, + items: [ + 1, + "foo", + { + type: "object", + class: "Object", + actor: "server1.conn1.obj36", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + }, + ], + }, +}); + +stubs.set("testMoreThanShortMaxProps", { + type: "object", + class: "Array", + actor: "server1.conn1.obj35", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: maxLengthMap.get(MODE.SHORT) + 1, + preview: { + kind: "ArrayLike", + length: maxLengthMap.get(MODE.SHORT) + 1, + items: new Array(maxLengthMap.get(MODE.SHORT) + 1).fill("test string"), + }, +}); + +stubs.set("testMoreThanLongMaxProps", { + type: "object", + class: "Array", + actor: "server1.conn1.obj35", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "ArrayLike", + length: maxLengthMap.get(MODE.LONG) + 1, + items: new Array(maxLengthMap.get(MODE.LONG) + 1).fill("test string"), + }, +}); + +stubs.set("testPreviewLimit", { + type: "object", + class: "Array", + actor: "server1.conn1.obj31", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 12, + preview: { + kind: "ArrayLike", + length: 11, + items: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, +}); + +stubs.set("testRecursiveArray", { + type: "object", + class: "Array", + actor: "server1.conn3.obj42", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + preview: { + kind: "ArrayLike", + length: 1, + items: [ + { + type: "object", + class: "Array", + actor: "server1.conn3.obj43", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + preview: { + kind: "ArrayLike", + length: 1, + }, + }, + ], + }, +}); + +stubs.set("testNamedNodeMap", { + type: "object", + class: "NamedNodeMap", + actor: "server1.conn3.obj42", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 6, + preview: { + kind: "ArrayLike", + length: 3, + items: [ + { + type: "object", + class: "Attr", + actor: "server1.conn3.obj43", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 2, + nodeName: "class", + value: "myclass", + }, + }, + { + type: "object", + class: "Attr", + actor: "server1.conn3.obj44", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 2, + nodeName: "cellpadding", + value: "7", + }, + }, + { + type: "object", + class: "Attr", + actor: "server1.conn3.obj44", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 2, + nodeName: "border", + value: "3", + }, + }, + ], + }, +}); + +stubs.set("testNodeList", { + type: "object", + actor: "server1.conn1.child1/obj51", + class: "NodeList", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 3, + preview: { + kind: "ArrayLike", + length: 3, + items: [ + { + type: "object", + actor: "server1.conn1.child1/obj52", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + id: "btn-1", + class: "btn btn-log", + type: "button", + }, + attributesLength: 3, + }, + }, + { + type: "object", + actor: "server1.conn1.child1/obj53", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + id: "btn-2", + class: "btn btn-err", + type: "button", + }, + attributesLength: 3, + }, + }, + { + type: "object", + actor: "server1.conn1.child1/obj54", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + id: "btn-3", + class: "btn btn-count", + type: "button", + }, + attributesLength: 3, + }, + }, + ], + }, +}); + +stubs.set("testDisconnectedNodeList", { + type: "object", + actor: "server1.conn1.child1/obj51", + class: "NodeList", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 3, + preview: { + kind: "ArrayLike", + length: 3, + items: [ + { + type: "object", + actor: "server1.conn1.child1/obj52", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: false, + attributes: { + id: "btn-1", + class: "btn btn-log", + type: "button", + }, + attributesLength: 3, + }, + }, + { + type: "object", + actor: "server1.conn1.child1/obj53", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: false, + attributes: { + id: "btn-2", + class: "btn btn-err", + type: "button", + }, + attributesLength: 3, + }, + }, + { + type: "object", + actor: "server1.conn1.child1/obj54", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: false, + attributes: { + id: "btn-3", + class: "btn btn-count", + type: "button", + }, + attributesLength: 3, + }, + }, + ], + }, +}); + +stubs.set("testDocumentFragment", { + type: "object", + actor: "server1.conn1.child1/obj45", + class: "DocumentFragment", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 11, + nodeName: "#document-fragment", + childNodesLength: 5, + childNodes: [ + { + type: "object", + actor: "server1.conn1.child1/obj46", + class: "HTMLLIElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "li", + attributes: { + id: "li-0", + class: "list-element", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server1.conn1.child1/obj47", + class: "HTMLLIElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "li", + attributes: { + id: "li-1", + class: "list-element", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server1.conn1.child1/obj48", + class: "HTMLLIElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "li", + attributes: { + id: "li-2", + class: "list-element", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server1.conn1.child1/obj49", + class: "HTMLLIElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "li", + attributes: { + id: "li-3", + class: "list-element", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server1.conn1.child1/obj50", + class: "HTMLLIElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "li", + attributes: { + id: "li-4", + class: "list-element", + }, + attributesLength: 2, + }, + }, + ], + }, +}); + +stubs.set("Array(5)", { + type: "object", + actor: "server1.conn4.child1/obj33", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "ArrayLike", + length: 5, + items: [null, null, null, null, null], + }, +}); + +stubs.set("[,1,2,3]", { + type: "object", + actor: "server1.conn4.child1/obj35", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "ArrayLike", + length: 4, + items: [null, 1, 2, 3], + }, +}); + +stubs.set("[,,,3,4,5]", { + type: "object", + actor: "server1.conn4.child1/obj37", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "ArrayLike", + length: 6, + items: [null, null, null, 3, 4, 5], + }, +}); + +stubs.set("[0,1,,3,4,5]", { + type: "object", + actor: "server1.conn4.child1/obj65", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 6, + preview: { + kind: "ArrayLike", + length: 6, + items: [0, 1, null, 3, 4, 5], + }, +}); + +stubs.set("[0,1,,,,5]", { + type: "object", + actor: "server1.conn4.child1/obj83", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "ArrayLike", + length: 6, + items: [0, 1, null, null, null, 5], + }, +}); + +stubs.set("[0,,2,,4,5]", { + type: "object", + actor: "server1.conn4.child1/obj85", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 5, + preview: { + kind: "ArrayLike", + length: 6, + items: [0, null, 2, null, 4, 5], + }, +}); + +stubs.set("[0,,,3,,,,7,8]", { + type: "object", + actor: "server1.conn4.child1/obj87", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 5, + preview: { + kind: "ArrayLike", + length: 9, + items: [0, null, null, 3, null, null, null, 7, 8], + }, +}); + +stubs.set("[0,1,2,3,4,,]", { + type: "object", + actor: "server1.conn4.child1/obj89", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 6, + preview: { + kind: "ArrayLike", + length: 6, + items: [0, 1, 2, 3, 4, null], + }, +}); + +stubs.set("[0,1,2,,,,]", { + type: "object", + actor: "server1.conn13.child1/obj88", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "ArrayLike", + length: 6, + items: [0, 1, 2, null, null, null], + }, +}); + +// We can have cases where we don't have the array items in the preview, +// (e.g. in the packet for `Promise.resolve([1, 2, 3])`), but we have the +// length of the array. +stubs.set("testItemsNotInPreview", { + type: "object", + actor: "server2.conn0.child1/obj135", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "ArrayLike", + length: 3, + }, +}); + +stubs.set("new Set([1,2,3,4])", { + type: "object", + actor: "server2.conn8.child18/obj30", + class: "Set", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "ArrayLike", + length: 4, + items: [1, 2, 3, 4], + }, +}); + +stubs.set("new Set([0,1,2,…,19])", { + type: "object", + actor: "server2.conn8.child18/obj42", + class: "Set", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "ArrayLike", + length: 20, + items: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, +}); + +stubs.set("new WeakSet(document.querySelectorAll('button:nth-child(3n)'))", { + type: "object", + actor: "server2.conn11.child18/obj107", + class: "WeakSet", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "ArrayLike", + length: 4, + items: [ + { + type: "object", + actor: "server2.conn11.child18/obj108", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + type: "button", + "data-key": "g", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server2.conn11.child18/obj109", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + type: "button", + "data-key": "E", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server2.conn11.child18/obj110", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + type: "button", + "data-key": "l", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server2.conn11.child18/obj111", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + type: "button", + "data-key": "r", + }, + attributesLength: 2, + }, + }, + ], + }, +}); + +stubs.set("new WeakSet(document.querySelectorAll('div, button'))", { + type: "object", + actor: "server2.conn11.child18/obj172", + class: "WeakSet", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "ArrayLike", + length: 12, + items: [ + { + type: "object", + actor: "server2.conn11.child18/obj173", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + type: "button", + "data-key": "L", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server2.conn11.child18/obj174", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + type: "button", + "data-key": "E", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server2.conn11.child18/obj175", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + type: "button", + "data-key": "t", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server2.conn11.child18/obj176", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + type: "button", + "data-key": "G", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server2.conn11.child18/obj177", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + type: "button", + "data-key": "g", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server2.conn11.child18/obj178", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + type: "button", + "data-key": "e", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server2.conn11.child18/obj179", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + type: "button", + "data-key": "T", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server2.conn11.child18/obj180", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + type: "button", + "data-key": "l", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server2.conn11.child18/obj181", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + type: "button", + "data-key": "C", + }, + attributesLength: 2, + }, + }, + { + type: "object", + actor: "server2.conn11.child18/obj182", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + type: "button", + "data-key": "c", + }, + attributesLength: 2, + }, + }, + ], + }, +}); + +stubs.set('["http://example.com/abcdefghijabcdefghij some other text"]', { + type: "object", + actor: "server2.conn3.child17/obj37", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + preview: { + kind: "ArrayLike", + length: 1, + items: ["http://example.com/abcdefghijabcdefghij some other text"], + }, +}); + +stubs.set("Array(234)", { + type: "object", + actor: "server4.conn2.child19/obj668", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 235, + preview: { + kind: "ArrayLike", + length: 234, + items: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, +}); + +stubs.set("Array(23456)", { + type: "object", + actor: "server4.conn2.child19/obj668", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 23457, + preview: { + kind: "ArrayLike", + length: 23456, + items: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, +}); + +stubs.set("TestArrayWithGetter", { + type: "object", + actor: "server0.conn0.windowGlobal13/obj21", + class: "Array", + ownPropertyLength: 2, + extensible: true, + frozen: false, + sealed: false, + isError: false, + preview: { + kind: "ArrayLike", + length: 1, + items: [{ + type: "accessor", + get: { + type: "object", + actor: "server0.conn0.windowGlobal13/obj22", + } + }] + } +}); + +stubs.set("TestArrayWithSetter", { + type: "object", + actor: "server0.conn0.windowGlobal13/obj24", + class: "Array", + ownPropertyLength: 2, + extensible: true, + frozen: false, + sealed: false, + isError: false, + preview: { + kind: "ArrayLike", + length: 1, + items: [{ + type: "accessor", + set: { + type: "object", + actor: "server0.conn0.windowGlobal13/obj25", + } + }] + } +}); + +stubs.set("TestArrayWithGetterAndSetter", { + type: "object", + actor: "server0.conn0.windowGlobal13/obj28", + class: "Array", + ownPropertyLength: 2, + extensible: true, + frozen: false, + sealed: false, + isError: false, + preview: { + kind: "ArrayLike", + length: 1, + items: [{ + type: "accessor", + get: { + type: "object", + actor: "server0.conn0.windowGlobal13/obj29", + }, + set: { + type: "object", + actor: "server0.conn0.windowGlobal13/obj30", + } + }] + } +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/grip-entry.js b/devtools/client/shared/components/test/node/stubs/reps/grip-entry.js new file mode 100644 index 0000000000..6c21389845 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/grip-entry.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +const stubs = new Map(); +stubs.set("A → 0", { + type: "mapEntry", + preview: { + key: "A", + value: 0, + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/grip-map.js b/devtools/client/shared/components/test/node/stubs/reps/grip-map.js new file mode 100644 index 0000000000..8c2af0956f --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/grip-map.js @@ -0,0 +1,908 @@ +/* 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 { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const { + maxLengthMap, +} = require("resource://devtools/client/shared/components/reps/reps/grip-map.js"); + +const stubs = new Map(); + +stubs.set("testEmptyMap", { + type: "object", + actor: "server1.conn1.child1/obj97", + class: "Map", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "MapLike", + size: 0, + entries: [], + }, +}); + +stubs.set("testSymbolKeyedMap", { + type: "object", + actor: "server1.conn1.child1/obj118", + class: "Map", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "MapLike", + size: 2, + entries: [ + [ + { + type: "symbol", + name: "a", + }, + "value-a", + ], + [ + { + type: "symbol", + name: "b", + }, + "value-b", + ], + ], + }, +}); + +stubs.set("testWeakMap", { + type: "object", + actor: "server1.conn1.child1/obj115", + class: "WeakMap", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "MapLike", + size: 1, + entries: [ + [ + { + type: "object", + actor: "server1.conn1.child1/obj116", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + }, + "value-a", + ], + ], + }, +}); + +stubs.set("testMaxEntries", { + type: "object", + actor: "server1.conn1.child1/obj109", + class: "Map", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "MapLike", + size: 3, + entries: [ + ["key-a", "value-a"], + ["key-b", "value-b"], + ["key-c", "value-c"], + ], + }, +}); + +stubs.set("testMoreThanMaxEntries", { + type: "object", + class: "Map", + actor: "server1.conn0.obj332", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "MapLike", + size: maxLengthMap.get(MODE.LONG) + 1, + entries: Array.from({ length: 10 }).map((_, i) => { + return [`key-${i}`, `value-${i}`]; + }), + }, +}); + +stubs.set("testUninterestingEntries", { + type: "object", + actor: "server1.conn1.child1/obj111", + class: "Map", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "MapLike", + size: 4, + entries: [ + [ + "key-a", + { + type: "null", + }, + ], + [ + "key-b", + { + type: "undefined", + }, + ], + ["key-c", "value-c"], + ["key-d", 4], + ], + }, +}); + +stubs.set("testDisconnectedNodeValuedMap", { + type: "object", + actor: "server1.conn1.child1/obj213", + class: "Map", + ownPropertyLength: 0, + preview: { + kind: "MapLike", + size: 3, + entries: [ + [ + "item-0", + { + type: "object", + actor: "server1.conn1.child1/obj214", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: false, + attributes: { + id: "btn-1", + class: "btn btn-log", + type: "button", + }, + attributesLength: 3, + }, + }, + ], + [ + "item-1", + { + type: "object", + actor: "server1.conn1.child1/obj215", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: false, + attributes: { + id: "btn-2", + class: "btn btn-err", + type: "button", + }, + attributesLength: 3, + }, + }, + ], + [ + "item-2", + { + type: "object", + actor: "server1.conn1.child1/obj216", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: false, + attributes: { + id: "btn-3", + class: "btn btn-count", + type: "button", + }, + attributesLength: 3, + }, + }, + ], + ], + }, +}); + +stubs.set("testNodeValuedMap", { + type: "object", + actor: "server1.conn1.child1/obj213", + class: "Map", + ownPropertyLength: 0, + preview: { + kind: "MapLike", + size: 3, + entries: [ + [ + "item-0", + { + type: "object", + actor: "server1.conn1.child1/obj214", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + id: "btn-1", + class: "btn btn-log", + type: "button", + }, + attributesLength: 3, + }, + }, + ], + [ + "item-1", + { + type: "object", + actor: "server1.conn1.child1/obj215", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + id: "btn-2", + class: "btn btn-err", + type: "button", + }, + attributesLength: 3, + }, + }, + ], + [ + "item-2", + { + type: "object", + actor: "server1.conn1.child1/obj216", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + id: "btn-3", + class: "btn btn-count", + type: "button", + }, + attributesLength: 3, + }, + }, + ], + ], + }, +}); + +stubs.set("testNodeKeyedMap", { + type: "object", + actor: "server1.conn1.child1/obj223", + class: "WeakMap", + ownPropertyLength: 0, + preview: { + kind: "MapLike", + size: 3, + entries: [ + [ + { + type: "object", + actor: "server1.conn1.child1/obj224", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + id: "btn-1", + class: "btn btn-log", + type: "button", + }, + attributesLength: 3, + }, + }, + "item-0", + ], + [ + { + type: "object", + actor: "server1.conn1.child1/obj225", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + id: "btn-3", + class: "btn btn-count", + type: "button", + }, + attributesLength: 3, + }, + }, + "item-2", + ], + [ + { + type: "object", + actor: "server1.conn1.child1/obj226", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + id: "btn-2", + class: "btn btn-err", + type: "button", + }, + attributesLength: 3, + }, + }, + "item-1", + ], + ], + }, +}); + +stubs.set("20-entries Map", { + type: "object", + actor: "server4.conn2.child19/obj777", + class: "Map", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "MapLike", + size: 20, + entries: [ + [ + { + type: "object", + actor: "server4.conn2.child19/obj778", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "1", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj779", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "2", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj780", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "3", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj781", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "4", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj782", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "5", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj783", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "6", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj784", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "7", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj785", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "8", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj786", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "9", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj787", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "10", + }, + ], + ], + }, +}); + +stubs.set("234-entries Map", { + type: "object", + actor: "server4.conn2.child19/obj789", + class: "Map", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "MapLike", + size: 234, + entries: [ + [ + { + type: "object", + actor: "server4.conn2.child19/obj790", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "1", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj791", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "2", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj792", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "3", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj793", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "4", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj794", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "5", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj795", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "6", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj796", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "7", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj797", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "8", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj798", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "9", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj799", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "10", + }, + ], + ], + }, +}); + +stubs.set("23456-entries Map", { + type: "object", + actor: "server4.conn2.child19/obj803", + class: "Map", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "MapLike", + size: 23456, + entries: [ + [ + { + type: "object", + actor: "server4.conn2.child19/obj804", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "1", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj805", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "2", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj806", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "3", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj807", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "4", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj808", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "5", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj809", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "6", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj810", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "7", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj811", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "8", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj812", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "9", + }, + ], + [ + { + type: "object", + actor: "server4.conn2.child19/obj813", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + { + type: "symbol", + name: "10", + }, + ], + ], + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/grip.js b/devtools/client/shared/components/test/node/stubs/reps/grip.js new file mode 100644 index 0000000000..69a24013ef --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/grip.js @@ -0,0 +1,1057 @@ +/* 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 { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const { + maxLengthMap, +} = require("resource://devtools/client/shared/components/reps/reps/grip.js"); + +const stubs = new Map(); + +stubs.set("testBasic", { + type: "object", + class: "Object", + actor: "server1.conn0.obj304", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: {}, + ownPropertiesLength: 0, + safeGetterValues: {}, + }, +}); + +stubs.set("testMaxProps", { + type: "object", + class: "Object", + actor: "server1.conn0.obj337", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 3, + preview: { + kind: "Object", + ownProperties: { + a: { + configurable: true, + enumerable: true, + writable: true, + value: "a", + }, + b: { + configurable: true, + enumerable: true, + writable: true, + value: "b", + }, + c: { + configurable: true, + enumerable: true, + writable: true, + value: "c", + }, + }, + ownPropertiesLength: 3, + safeGetterValues: {}, + }, +}); + +const longModeMaxLength = maxLengthMap.get(MODE.LONG); + +stubs.set("testMoreThanMaxProps", { + type: "object", + class: "Object", + actor: "server1.conn0.obj332", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: longModeMaxLength + 1, + preview: { + kind: "Object", + ownProperties: Array.from({ length: longModeMaxLength }).reduce( + (res, item, index) => ({ + ...res, + [`p${index}`]: { + configurable: true, + enumerable: true, + writable: true, + value: index.toString(), + }, + }), + {} + ), + ownPropertiesLength: longModeMaxLength + 1, + safeGetterValues: {}, + }, +}); + +stubs.set("testUninterestingProps", { + type: "object", + class: "Object", + actor: "server1.conn0.obj342", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "Object", + ownProperties: { + a: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "undefined", + }, + }, + b: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "undefined", + }, + }, + c: { + configurable: true, + enumerable: true, + writable: true, + value: "c", + }, + d: { + configurable: true, + enumerable: true, + writable: true, + value: 1, + }, + }, + ownPropertiesLength: 4, + safeGetterValues: {}, + }, +}); +stubs.set("testNonEnumerableProps", { + type: "object", + actor: "server1.conn1.child1/obj30", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "Object", + ownProperties: {}, + ownPropertiesLength: 1, + safeGetterValues: {}, + }, +}); +stubs.set("testNestedObject", { + type: "object", + class: "Object", + actor: "server1.conn0.obj145", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + preview: { + kind: "Object", + ownProperties: { + objProp: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + class: "Object", + actor: "server1.conn0.obj146", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + }, + }, + strProp: { + configurable: true, + enumerable: true, + writable: true, + value: "test string", + }, + }, + ownPropertiesLength: 2, + safeGetterValues: {}, + }, +}); + +stubs.set("testNestedArray", { + type: "object", + class: "Object", + actor: "server1.conn0.obj326", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "Object", + ownProperties: { + arrProp: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + class: "Array", + actor: "server1.conn0.obj327", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "ArrayLike", + length: 3, + }, + }, + }, + }, + ownPropertiesLength: 1, + safeGetterValues: {}, + }, +}); + +stubs.set("testMoreProp", { + type: "object", + class: "Object", + actor: "server1.conn0.obj342", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "Object", + ownProperties: { + a: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "undefined", + }, + }, + b: { + configurable: true, + enumerable: true, + writable: true, + value: 1, + }, + more: { + configurable: true, + enumerable: true, + writable: true, + value: 2, + }, + d: { + configurable: true, + enumerable: true, + writable: true, + value: 3, + }, + }, + ownPropertiesLength: 4, + safeGetterValues: {}, + }, +}); +stubs.set("testBooleanObject", { + type: "object", + actor: "server1.conn1.child1/obj57", + class: "Boolean", + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: {}, + ownPropertiesLength: 0, + safeGetterValues: {}, + wrappedValue: true, + }, +}); +stubs.set("testNumberObject", { + type: "object", + actor: "server1.conn1.child1/obj59", + class: "Number", + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: {}, + ownPropertiesLength: 0, + safeGetterValues: {}, + wrappedValue: 42, + }, +}); +stubs.set("testStringObject", { + type: "object", + actor: "server1.conn1.child1/obj61", + class: "String", + ownPropertyLength: 4, + preview: { + kind: "Object", + ownProperties: {}, + ownPropertiesLength: 4, + safeGetterValues: {}, + wrappedValue: "foo", + }, +}); +stubs.set("testProxy", { + type: "object", + actor: "server1.conn1.child1/obj47", + class: "Proxy", + preview: { + kind: "Object", + ownProperties: { + "<target>": { + value: { + type: "object", + actor: "server1.conn1.child1/obj48", + class: "Object", + ownPropertyLength: 1, + }, + }, + "<handler>": { + value: { + type: "object", + actor: "server1.conn1.child1/obj49", + class: "Array", + ownPropertyLength: 4, + preview: { + kind: "ArrayLike", + length: 3, + }, + }, + }, + }, + ownPropertiesLength: 2, + }, +}); +stubs.set("testProxySlots", { + proxyTarget: { + type: "object", + actor: "server1.conn1.child1/obj48", + class: "Object", + ownPropertyLength: 1, + }, + proxyHandler: { + type: "object", + actor: "server1.conn1.child1/obj49", + class: "Array", + ownPropertyLength: 4, + preview: { + kind: "ArrayLike", + length: 3, + }, + }, +}); +stubs.set("testArrayBuffer", { + type: "object", + actor: "server1.conn1.child1/obj170", + class: "ArrayBuffer", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: {}, + ownPropertiesLength: 0, + safeGetterValues: { + byteLength: { + getterValue: 10, + getterPrototypeLevel: 1, + enumerable: false, + writable: true, + }, + }, + }, +}); +stubs.set("testSharedArrayBuffer", { + type: "object", + actor: "server1.conn1.child1/obj171", + class: "SharedArrayBuffer", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: {}, + ownPropertiesLength: 0, + safeGetterValues: { + byteLength: { + getterValue: 5, + getterPrototypeLevel: 1, + enumerable: false, + writable: true, + }, + }, + }, +}); +stubs.set("testApplicationCache", { + type: "object", + actor: "server2.conn1.child2/obj45", + class: "OfflineResourceList", + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: {}, + ownPropertiesLength: 0, + safeGetterValues: { + status: { + getterValue: 0, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + onchecking: { + getterValue: { + type: "null", + }, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + onerror: { + getterValue: { + type: "null", + }, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + onnoupdate: { + getterValue: { + type: "null", + }, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + ondownloading: { + getterValue: { + type: "null", + }, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + onprogress: { + getterValue: { + type: "null", + }, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + onupdateready: { + getterValue: { + type: "null", + }, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + oncached: { + getterValue: { + type: "null", + }, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + onobsolete: { + getterValue: { + type: "null", + }, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + mozItems: { + getterValue: { + type: "object", + actor: "server2.conn1.child2/obj46", + class: "DOMStringList", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "ArrayLike", + length: 0, + }, + }, + getterPrototypeLevel: 1, + enumerable: true, + writable: true, + }, + }, + }, +}); +stubs.set("testObjectWithNodes", { + type: "object", + actor: "server1.conn1.child1/obj214", + class: "Object", + ownPropertyLength: 2, + preview: { + kind: "Object", + ownProperties: { + foo: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server1.conn1.child1/obj215", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + id: "btn-1", + class: "btn btn-log", + type: "button", + }, + attributesLength: 3, + }, + }, + }, + bar: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server1.conn1.child1/obj216", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + id: "btn-2", + class: "btn btn-err", + type: "button", + }, + attributesLength: 3, + }, + }, + }, + }, + ownPropertiesLength: 2, + safeGetterValues: {}, + }, +}); +stubs.set("testObjectWithDisconnectedNodes", { + type: "object", + actor: "server1.conn1.child1/obj214", + class: "Object", + ownPropertyLength: 2, + preview: { + kind: "Object", + ownProperties: { + foo: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server1.conn1.child1/obj215", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + attributes: { + id: "btn-1", + class: "btn btn-log", + type: "button", + }, + attributesLength: 3, + }, + }, + }, + bar: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server1.conn1.child1/obj216", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + attributes: { + id: "btn-2", + class: "btn btn-err", + type: "button", + }, + attributesLength: 3, + }, + }, + }, + }, + ownPropertiesLength: 2, + safeGetterValues: {}, + }, +}); + +// Packet for `({get x(){}})` +stubs.set("TestObjectWithGetter", { + type: "object", + actor: "server2.conn1.child1/obj105", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "Object", + ownProperties: { + x: { + configurable: true, + enumerable: true, + get: { + type: "object", + actor: "server2.conn1.child1/obj106", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "get x", + displayName: "get x", + location: { + url: "debugger eval code", + line: 1, + }, + }, + set: { + type: "undefined", + }, + }, + }, + ownPropertiesLength: 1, + safeGetterValues: {}, + }, +}); + +// Packet for `({set x(s){}})` +stubs.set("TestObjectWithSetter", { + type: "object", + actor: "server2.conn1.child1/obj115", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "Object", + ownProperties: { + x: { + configurable: true, + enumerable: true, + get: { + type: "undefined", + }, + set: { + type: "object", + actor: "server2.conn1.child1/obj116", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "set x", + displayName: "set x", + location: { + url: "debugger eval code", + line: 1, + }, + }, + }, + }, + ownPropertiesLength: 1, + safeGetterValues: {}, + }, +}); + +// Packet for `({get x(){}, set x(s){}})` +stubs.set("TestObjectWithGetterAndSetter", { + type: "object", + actor: "server2.conn1.child1/obj126", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "Object", + ownProperties: { + x: { + configurable: true, + enumerable: true, + get: { + type: "object", + actor: "server2.conn1.child1/obj127", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "get x", + displayName: "get x", + location: { + url: "debugger eval code", + line: 1, + }, + }, + set: { + type: "object", + actor: "server2.conn1.child1/obj128", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + name: "set x", + displayName: "set x", + location: { + url: "debugger eval code", + line: 1, + }, + }, + }, + }, + ownPropertiesLength: 1, + safeGetterValues: {}, + }, +}); + +// Packet for : +// ({ +// [Symbol()]: "first unnamed symbol", +// [Symbol()]: "second unnamed symbol", +// [Symbol("named")] : "named symbol", +// [Symbol.iterator] : function* () {yield 1;yield 2;}, +// x: 10, +// }) +stubs.set("TestObjectWithSymbolProperties", { + type: "object", + actor: "server2.conn1.child1/obj30", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "Object", + ownProperties: { + x: { + configurable: true, + enumerable: true, + writable: true, + value: 10, + }, + }, + ownSymbols: [ + { + descriptor: { + configurable: true, + enumerable: true, + writable: true, + value: "first unnamed symbol", + }, + type: "symbol", + }, + { + descriptor: { + configurable: true, + enumerable: true, + writable: true, + value: "second unnamed symbol", + }, + type: "symbol", + }, + { + descriptor: { + configurable: true, + enumerable: true, + writable: true, + value: "named symbol", + }, + type: "symbol", + name: "named", + }, + { + descriptor: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server2.conn1.child1/obj31", + class: "Function", + extensible: true, + frozen: false, + sealed: false, + location: { + url: "debugger eval code", + line: 1, + }, + }, + }, + type: "symbol", + name: "Symbol.iterator", + }, + ], + ownPropertiesLength: 1, + ownSymbolsLength: 4, + safeGetterValues: {}, + }, +}); + +// Packet for : +// x = {}; +// for(let i = 0; i < 11; i++) { +// x[Symbol(`i-${i}`)] = `value-${i}` +// } +// x; +stubs.set("TestObjectWithMoreThanMaxSymbolProperties", { + type: "object", + actor: "server2.conn1.child1/obj39", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: {}, + ownSymbols: [ + { + descriptor: { + configurable: true, + enumerable: true, + writable: true, + value: "value-0", + }, + type: "symbol", + name: "i-0", + }, + { + descriptor: { + configurable: true, + enumerable: true, + writable: true, + value: "value-1", + }, + type: "symbol", + name: "i-1", + }, + { + descriptor: { + configurable: true, + enumerable: true, + writable: true, + value: "value-2", + }, + type: "symbol", + name: "i-2", + }, + { + descriptor: { + configurable: true, + enumerable: true, + writable: true, + value: "value-3", + }, + type: "symbol", + name: "i-3", + }, + { + descriptor: { + configurable: true, + enumerable: true, + writable: true, + value: "value-4", + }, + type: "symbol", + name: "i-4", + }, + { + descriptor: { + configurable: true, + enumerable: true, + writable: true, + value: "value-5", + }, + type: "symbol", + name: "i-5", + }, + { + descriptor: { + configurable: true, + enumerable: true, + writable: true, + value: "value-6", + }, + type: "symbol", + name: "i-6", + }, + { + descriptor: { + configurable: true, + enumerable: true, + writable: true, + value: "value-7", + }, + type: "symbol", + name: "i-7", + }, + { + descriptor: { + configurable: true, + enumerable: true, + writable: true, + value: "value-8", + }, + type: "symbol", + name: "i-8", + }, + { + descriptor: { + configurable: true, + enumerable: true, + writable: true, + value: "value-9", + }, + type: "symbol", + name: "i-9", + }, + ], + ownPropertiesLength: 0, + ownSymbolsLength: 11, + }, +}); + +stubs.set('{test: "http://example.com/ some other text"}', { + type: "object", + actor: "server2.conn4.child17/obj30", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "Object", + ownProperties: { + test: { + configurable: true, + enumerable: true, + writable: true, + value: "http://example.com/ some other text", + }, + }, + ownSymbols: [], + ownPropertiesLength: 1, + ownSymbolsLength: 0, + safeGetterValues: {}, + }, +}); + +stubs.set("Generator", { + type: "object", + actor: "server1.conn2.child1/obj33", + class: "Generator", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: {}, + ownSymbols: [], + ownPropertiesLength: 0, + ownSymbolsLength: 0, + safeGetterValues: {}, + }, +}); + +stubs.set("DeadObject", { + type: "object", + actor: "server1.conn7.child2/obj41", + class: "DeadObject", + extensible: true, + frozen: false, + sealed: false, +}); + +// Packet for : +// var obj = Object.create(null); obj.__proto__ = []; obj; +stubs.set("ObjectWith__proto__Property", { + type: "object", + actor: "server1.conn1.child1/obj31", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "Object", + ownProperties: { + ["__proto__"]: { + configurable: true, + enumerable: true, + writable: true, + value: { + type: "object", + actor: "server1.conn1.child1/obj32", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + preview: { + kind: "ArrayLike", + length: 0, + }, + }, + }, + }, + ownSymbols: [], + ownPropertiesLength: 1, + ownSymbolsLength: 0, + safeGetterValues: {}, + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/infinity.js b/devtools/client/shared/components/test/node/stubs/reps/infinity.js new file mode 100644 index 0000000000..9948f218ef --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/infinity.js @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; +/* + * THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND. RUN browser_reps_stubs.js with STUBS_UPDATE=true env TO UPDATE. + */ + +const stubs = new Map(); +stubs.set(`Infinity`, { + "type": "Infinity" +}); + +stubs.set(`NegativeInfinity`, { + "type": "-Infinity" +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/long-string.js b/devtools/client/shared/components/test/node/stubs/reps/long-string.js new file mode 100644 index 0000000000..26715c4017 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/long-string.js @@ -0,0 +1,39 @@ +/* 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 multilineFullText = `a\n${Array(20000) + .fill("a") + .join("")}`; +const fullTextLength = multilineFullText.length; +const initialText = multilineFullText.substring(0, 10000); + +const stubs = new Map(); + +stubs.set("testMultiline", { + type: "longString", + initial: initialText, + length: fullTextLength, + actor: "server1.conn1.child1/longString58", +}); + +stubs.set("testUnloadedFullText", { + type: "longString", + initial: Array(10000) + .fill("a") + .join(""), + length: 20000, + actor: "server1.conn1.child1/longString58", +}); + +stubs.set("testLoadedFullText", { + type: "longString", + fullText: multilineFullText, + initial: initialText, + length: fullTextLength, + actor: "server1.conn1.child1/longString58", +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/nan.js b/devtools/client/shared/components/test/node/stubs/reps/nan.js new file mode 100644 index 0000000000..51b67dd128 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/nan.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"; +/* + * THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND. RUN browser_reps_stubs.js with STUBS_UPDATE=true env TO UPDATE. + */ + +const stubs = new Map(); +stubs.set(`NaN`, { + "type": "NaN" +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/null.js b/devtools/client/shared/components/test/node/stubs/reps/null.js new file mode 100644 index 0000000000..5f7ccf5f0c --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/null.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"; +/* + * THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND. RUN browser_reps_stubs.js with STUBS_UPDATE=true env TO UPDATE. + */ + +const stubs = new Map(); +stubs.set(`Null`, { + "type": "null" +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/number.js b/devtools/client/shared/components/test/node/stubs/reps/number.js new file mode 100644 index 0000000000..217a3fa0da --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/number.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/>. */ + +"use strict"; +/* + * THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND. RUN browser_reps_stubs.js with STUBS_UPDATE=true env TO UPDATE. + */ + +const stubs = new Map(); +stubs.set(`Int`, 5); + +stubs.set(`True`, true); + +stubs.set(`False`, false); + +stubs.set(`NegZeroGrip`, { + "type": "-0" +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/object-with-text.js b/devtools/client/shared/components/test/node/stubs/reps/object-with-text.js new file mode 100644 index 0000000000..ea17d01d7e --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/object-with-text.js @@ -0,0 +1,36 @@ +/* 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 stubs = new Map(); +stubs.set("ShadowRule", { + type: "object", + class: "CSSStyleRule", + actor: "server1.conn3.obj273", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "ObjectWithText", + text: ".Shadow", + }, +}); + +stubs.set("CSSMediaRule", { + type: "object", + actor: "server2.conn8.child17/obj30", + class: "CSSMediaRule", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "ObjectWithText", + text: "(min-height: 680px), screen and (orientation: portrait)", + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/object-with-url.js b/devtools/client/shared/components/test/node/stubs/reps/object-with-url.js new file mode 100644 index 0000000000..179faf0874 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/object-with-url.js @@ -0,0 +1,22 @@ +/* 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 stubs = new Map(); +stubs.set("ObjectWithUrl", { + type: "object", + class: "Location", + actor: "server1.conn2.obj272", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 15, + preview: { + kind: "ObjectWithURL", + url: "https://www.mozilla.org/en-US/", + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/promise.js b/devtools/client/shared/components/test/node/stubs/reps/promise.js new file mode 100644 index 0000000000..6dc71cd230 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/promise.js @@ -0,0 +1,244 @@ +/* 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 stubs = new Map(); +stubs.set("Pending", { + type: "object", + actor: "server1.conn1.child1/obj54", + class: "Promise", + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: { + "<state>": { + value: "pending", + }, + }, + ownPropertiesLength: 1, + }, +}); + +stubs.set("FulfilledWithNumber", { + type: "object", + actor: "server1.conn1.child1/obj55", + class: "Promise", + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: { + "<state>": { + value: "fulfilled", + }, + "<value>": { + value: 42, + }, + }, + ownPropertiesLength: 2, + }, +}); + +stubs.set("FulfilledWithString", { + type: "object", + actor: "server1.conn1.child1/obj56", + class: "Promise", + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: { + "<state>": { + value: "fulfilled", + }, + "<value>": { + value: "foo", + }, + }, + ownPropertiesLength: 2, + }, +}); + +stubs.set("FulfilledWithObject", { + type: "object", + actor: "server1.conn1.child1/obj59", + class: "Promise", + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: { + "<state>": { + value: "fulfilled", + }, + "<value>": { + value: { + type: "object", + actor: "server1.conn1.child1/obj60", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 2, + }, + }, + }, + ownPropertiesLength: 2, + }, +}); + +stubs.set("FulfilledWithArray", { + type: "object", + actor: "server1.conn1.child1/obj57", + class: "Promise", + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: { + "<state>": { + value: "fulfilled", + }, + "<value>": { + value: { + type: "object", + actor: "server1.conn1.child1/obj58", + class: "Array", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "ArrayLike", + length: 3, + }, + }, + }, + }, + ownPropertiesLength: 2, + }, +}); + +stubs.set("FulfilledWithNode", { + type: "object", + actor: "server1.conn1.child1/obj217", + class: "Promise", + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: { + "<state>": { + value: "fulfilled", + }, + "<value>": { + value: { + type: "object", + actor: "server1.conn1.child1/obj218", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: true, + attributes: { + id: "btn-1", + class: "btn btn-log", + type: "button", + }, + attributesLength: 3, + }, + }, + }, + }, + ownPropertiesLength: 2, + }, +}); + +stubs.set("FulfilledWithDisconnectedNode", { + type: "object", + actor: "server1.conn1.child1/obj217", + class: "Promise", + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: { + "<state>": { + value: "fulfilled", + }, + "<value>": { + value: { + type: "object", + actor: "server1.conn1.child1/obj218", + class: "HTMLButtonElement", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 0, + preview: { + kind: "DOMNode", + nodeType: 1, + nodeName: "button", + isConnected: false, + attributes: { + id: "btn-1", + class: "btn btn-log", + type: "button", + }, + attributesLength: 3, + }, + }, + }, + }, + ownPropertiesLength: 2, + }, +}); + +stubs.set("RejectedWithNumber", { + type: "object", + actor: "server0.conn0.child3/obj27", + class: "Promise", + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: { + "<state>": { + value: "rejected", + }, + "<reason>": { + value: 123, + }, + }, + ownPropertiesLength: 2, + }, +}); + +stubs.set("RejectedWithObject", { + type: "object", + actor: "server0.conn0.child3/obj67", + class: "Promise", + ownPropertyLength: 0, + preview: { + kind: "Object", + ownProperties: { + "<state>": { + value: "rejected", + }, + "<reason>": { + value: { + type: "object", + actor: "server1.conn1.child1/obj68", + class: "Object", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + }, + }, + }, + ownPropertiesLength: 2, + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/regexp.js b/devtools/client/shared/components/test/node/stubs/reps/regexp.js new file mode 100644 index 0000000000..0dd5b06e97 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/regexp.js @@ -0,0 +1,36 @@ +/* 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 stubs = new Map(); +stubs.set("RegExp", { + type: "object", + class: "RegExp", + actor: "server1.conn22.obj39", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 1, + displayString: "/ab+c/i", +}); + +stubs.set("longString displayString RegExp", { + type: "object", + actor: "server0.conn0.child2/obj79", + class: "RegExp", + ownPropertyLength: 1, + extensible: true, + frozen: false, + sealed: false, + displayString: { + type: "longString", + actor: "server0.conn0.child2/longstractor78", + length: 30002, + initial: + "/ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ", + }, +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/stubs.toml b/devtools/client/shared/components/test/node/stubs/reps/stubs.toml new file mode 100644 index 0000000000..9cf1570cbe --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/stubs.toml @@ -0,0 +1,20 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "attribute.js", + "comment-node.js", + "date-time.js", + "infinity.js", + "nan.js", + "null.js", + "number.js", + "stylesheet.js", + "symbol.js", + "text-node.js", + "undefined.js", + "window.js", +] + +["browser_dummy.js"] +skip-if = ["true"] #This is only here so we can expose the support files in other toml files. diff --git a/devtools/client/shared/components/test/node/stubs/reps/stylesheet.js b/devtools/client/shared/components/test/node/stubs/reps/stylesheet.js new file mode 100644 index 0000000000..eeca646dd2 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/stylesheet.js @@ -0,0 +1,29 @@ +/* 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"; +/* + * THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND. RUN browser_reps_stubs.js with STUBS_UPDATE=true env TO UPDATE. + */ + +const stubs = new Map(); +stubs.set(`StyleSheet`, { + "_grip": { + "type": "object", + "actor": "server0.conn0.windowGlobal4294967299/obj40", + "class": "CSSStyleSheet", + "ownPropertyLength": 0, + "extensible": true, + "frozen": false, + "sealed": false, + "isError": false, + "preview": { + "kind": "ObjectWithURL", + "url": "https://example.com/styles.css" + } + }, + "actorID": "server0.conn0.windowGlobal4294967299/obj40" +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/symbol.js b/devtools/client/shared/components/test/node/stubs/reps/symbol.js new file mode 100644 index 0000000000..d7f0ace128 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/symbol.js @@ -0,0 +1,33 @@ +/* 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"; +/* + * THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND. RUN browser_reps_stubs.js with STUBS_UPDATE=true env TO UPDATE. + */ + +const stubs = new Map(); +stubs.set(`Symbol`, { + "type": "symbol", + "actor": "server0.conn0.windowGlobal4294967299/symbol40", + "name": "foo" +}); + +stubs.set(`SymbolWithoutIdentifier`, { + "type": "symbol", + "actor": "server0.conn0.windowGlobal4294967299/symbol42" +}); + +stubs.set(`SymbolWithLongString`, { + "type": "symbol", + "actor": "server0.conn0.windowGlobal4294967299/symbol44", + "name": { + "type": "longString", + "actor": "server0.conn0.windowGlobal4294967299/longstractor45", + "length": 20000, + "initial": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/text-node.js b/devtools/client/shared/components/test/node/stubs/reps/text-node.js new file mode 100644 index 0000000000..eaf893cdbb --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/text-node.js @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; +/* + * THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND. RUN browser_reps_stubs.js with STUBS_UPDATE=true env TO UPDATE. + */ + +const stubs = new Map(); +stubs.set(`testRendering`, { + "_grip": { + "type": "object", + "actor": "server0.conn0.windowGlobal2147483651/obj40", + "class": "Text", + "ownPropertyLength": 0, + "extensible": true, + "frozen": false, + "sealed": false, + "isError": false, + "preview": { + "kind": "DOMNode", + "nodeType": 3, + "nodeName": "#text", + "isConnected": true, + "textContent": "hello world" + }, + "contentDomReference": { + "browsingContextId": 51, + "id": 0.5296372099388534 + } + }, + "actorID": "server0.conn0.windowGlobal2147483651/obj40" +}); + +stubs.set(`testRenderingDisconnected`, { + "_grip": { + "type": "object", + "actor": "server0.conn0.windowGlobal2147483651/obj42", + "class": "Text", + "ownPropertyLength": 0, + "extensible": true, + "frozen": false, + "sealed": false, + "isError": false, + "preview": { + "kind": "DOMNode", + "nodeType": 3, + "nodeName": "#text", + "isConnected": false, + "textContent": "hello world" + }, + "contentDomReference": { + "browsingContextId": 51, + "id": 0.6969799823627325 + } + }, + "actorID": "server0.conn0.windowGlobal2147483651/obj42" +}); + +stubs.set(`testRenderingWithEOL`, { + "_grip": { + "type": "object", + "actor": "server0.conn0.windowGlobal2147483651/obj44", + "class": "Text", + "ownPropertyLength": 0, + "extensible": true, + "frozen": false, + "sealed": false, + "isError": false, + "preview": { + "kind": "DOMNode", + "nodeType": 3, + "nodeName": "#text", + "isConnected": false, + "textContent": "hello\nworld" + }, + "contentDomReference": { + "browsingContextId": 51, + "id": 0.7640922670176581 + } + }, + "actorID": "server0.conn0.windowGlobal2147483651/obj44" +}); + +stubs.set(`testRenderingWithDoubleQuote`, { + "_grip": { + "type": "object", + "actor": "server0.conn0.windowGlobal2147483651/obj46", + "class": "Text", + "ownPropertyLength": 0, + "extensible": true, + "frozen": false, + "sealed": false, + "isError": false, + "preview": { + "kind": "DOMNode", + "nodeType": 3, + "nodeName": "#text", + "isConnected": false, + "textContent": "hello\"world" + }, + "contentDomReference": { + "browsingContextId": 51, + "id": 0.113013948491126 + } + }, + "actorID": "server0.conn0.windowGlobal2147483651/obj46" +}); + +stubs.set(`testRenderingWithLongString`, { + "_grip": { + "type": "object", + "actor": "server0.conn0.windowGlobal2147483651/obj48", + "class": "Text", + "ownPropertyLength": 0, + "extensible": true, + "frozen": false, + "sealed": false, + "isError": false, + "preview": { + "kind": "DOMNode", + "nodeType": 3, + "nodeName": "#text", + "isConnected": false, + "textContent": { + "type": "longString", + "actor": "server0.conn0.windowGlobal2147483651/longstractor49", + "length": 20002, + "initial": "a\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + "contentDomReference": { + "browsingContextId": 51, + "id": 0.792316936882363 + } + }, + "actorID": "server0.conn0.windowGlobal2147483651/obj48" +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/undefined.js b/devtools/client/shared/components/test/node/stubs/reps/undefined.js new file mode 100644 index 0000000000..7acd7be3cf --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/undefined.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"; +/* + * THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND. RUN browser_reps_stubs.js with STUBS_UPDATE=true env TO UPDATE. + */ + +const stubs = new Map(); +stubs.set(`Undefined`, { + "type": "undefined" +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/stubs/reps/window.js b/devtools/client/shared/components/test/node/stubs/reps/window.js new file mode 100644 index 0000000000..6386e91054 --- /dev/null +++ b/devtools/client/shared/components/test/node/stubs/reps/window.js @@ -0,0 +1,65 @@ +/* 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"; +/* + * THIS FILE IS AUTOGENERATED. DO NOT MODIFY BY HAND. RUN browser_reps_stubs.js with STUBS_UPDATE=true env TO UPDATE. + */ + +const stubs = new Map(); +stubs.set(`Window`, { + "_grip": { + "type": "object", + "actor": "server0.conn0.windowGlobal2147483651/obj35", + "class": "Window", + "ownPropertyLength": 806, + "extensible": true, + "frozen": false, + "sealed": false, + "isError": false, + "preview": { + "kind": "ObjectWithURL", + "url": "data:text/html;charset=utf-8,stub generation" + } + }, + "actorID": "server0.conn0.windowGlobal2147483651/obj35" +}); + +stubs.set(`CrossOriginIframeContentWindow`, { + "_grip": { + "type": "object", + "actor": "server0.conn0.windowGlobal10737418241/obj59", + "class": "Window", + "ownPropertyLength": 14, + "extensible": true, + "frozen": false, + "sealed": false, + "isError": false, + "preview": { + "kind": "ObjectWithURL", + "url": "http://example.org/document-builder.sjs?html=example.org" + } + }, + "actorID": "server0.conn0.windowGlobal10737418241/obj59" +}); + +stubs.set(`CrossOriginIframeTopWindow`, { + "_grip": { + "type": "object", + "actor": "server0.conn0.windowGlobal12884901889/obj20", + "class": "Window", + "ownPropertyLength": 16, + "extensible": true, + "frozen": false, + "sealed": false, + "isError": false, + "preview": { + "kind": "ObjectWithURL", + "url": "Restricted" + } + }, + "actorID": "server0.conn0.windowGlobal12884901889/obj20" +}); + +module.exports = stubs; diff --git a/devtools/client/shared/components/test/node/yarn.lock b/devtools/client/shared/components/test/node/yarn.lock new file mode 100644 index 0000000000..de7f467a56 --- /dev/null +++ b/devtools/client/shared/components/test/node/yarn.lock @@ -0,0 +1,4209 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" + integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== + dependencies: + "@babel/highlight" "^7.10.4" + +"@babel/core@^7.1.0": + version "7.11.6" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.11.6.tgz#3a9455dc7387ff1bac45770650bc13ba04a15651" + integrity sha512-Wpcv03AGnmkgm6uS6k8iwhIwTrcP0m17TL1n1sy7qD0qelDu4XNeW0dN0mHfa+Gei211yDaLoEe/VlbXQzM4Bg== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/generator" "^7.11.6" + "@babel/helper-module-transforms" "^7.11.0" + "@babel/helpers" "^7.10.4" + "@babel/parser" "^7.11.5" + "@babel/template" "^7.10.4" + "@babel/traverse" "^7.11.5" + "@babel/types" "^7.11.5" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.1" + json5 "^2.1.2" + lodash "^4.17.19" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.5.0" + +"@babel/generator@^7.11.5", "@babel/generator@^7.11.6", "@babel/generator@^7.4.0": + version "7.11.6" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.11.6.tgz#b868900f81b163b4d464ea24545c61cbac4dc620" + integrity sha512-DWtQ1PV3r+cLbySoHrwn9RWEgKMBLLma4OBQloPRyDYvc5msJM9kvTLo1YnlJd1P/ZuKbdli3ijr5q3FvAF3uA== + dependencies: + "@babel/types" "^7.11.5" + jsesc "^2.5.1" + source-map "^0.5.0" + +"@babel/helper-create-class-features-plugin@^7.10.4": + version "7.10.5" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.5.tgz#9f61446ba80e8240b0a5c85c6fdac8459d6f259d" + integrity sha512-0nkdeijB7VlZoLT3r/mY3bUkw3T8WG/hNw+FATs/6+pG2039IJWjTYL0VTISqsNHMUTEnwbVnc89WIJX9Qed0A== + dependencies: + "@babel/helper-function-name" "^7.10.4" + "@babel/helper-member-expression-to-functions" "^7.10.5" + "@babel/helper-optimise-call-expression" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-replace-supers" "^7.10.4" + "@babel/helper-split-export-declaration" "^7.10.4" + +"@babel/helper-function-name@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz#d2d3b20c59ad8c47112fa7d2a94bc09d5ef82f1a" + integrity sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ== + dependencies: + "@babel/helper-get-function-arity" "^7.10.4" + "@babel/template" "^7.10.4" + "@babel/types" "^7.10.4" + +"@babel/helper-get-function-arity@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz#98c1cbea0e2332f33f9a4661b8ce1505b2c19ba2" + integrity sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A== + dependencies: + "@babel/types" "^7.10.4" + +"@babel/helper-member-expression-to-functions@^7.10.4", "@babel/helper-member-expression-to-functions@^7.10.5": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.11.0.tgz#ae69c83d84ee82f4b42f96e2a09410935a8f26df" + integrity sha512-JbFlKHFntRV5qKw3YC0CvQnDZ4XMwgzzBbld7Ly4Mj4cbFy3KywcR8NtNctRToMWJOVvLINJv525Gd6wwVEx/Q== + dependencies: + "@babel/types" "^7.11.0" + +"@babel/helper-module-imports@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz#4c5c54be04bd31670a7382797d75b9fa2e5b5620" + integrity sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw== + dependencies: + "@babel/types" "^7.10.4" + +"@babel/helper-module-transforms@^7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz#b16f250229e47211abdd84b34b64737c2ab2d359" + integrity sha512-02EVu8COMuTRO1TAzdMtpBPbe6aQ1w/8fePD2YgQmxZU4gpNWaL9gK3Jp7dxlkUlUCJOTaSeA+Hrm1BRQwqIhg== + dependencies: + "@babel/helper-module-imports" "^7.10.4" + "@babel/helper-replace-supers" "^7.10.4" + "@babel/helper-simple-access" "^7.10.4" + "@babel/helper-split-export-declaration" "^7.11.0" + "@babel/template" "^7.10.4" + "@babel/types" "^7.11.0" + lodash "^4.17.19" + +"@babel/helper-optimise-call-expression@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz#50dc96413d594f995a77905905b05893cd779673" + integrity sha512-n3UGKY4VXwXThEiKrgRAoVPBMqeoPgHVqiHZOanAJCG9nQUL2pLRQirUzl0ioKclHGpGqRgIOkgcIJaIWLpygg== + dependencies: + "@babel/types" "^7.10.4" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.8.0": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" + integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== + +"@babel/helper-replace-supers@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.10.4.tgz#d585cd9388ea06e6031e4cd44b6713cbead9e6cf" + integrity sha512-sPxZfFXocEymYTdVK1UNmFPBN+Hv5mJkLPsYWwGBxZAxaWfFu+xqp7b6qWD0yjNuNL2VKc6L5M18tOXUP7NU0A== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.10.4" + "@babel/helper-optimise-call-expression" "^7.10.4" + "@babel/traverse" "^7.10.4" + "@babel/types" "^7.10.4" + +"@babel/helper-simple-access@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz#0f5ccda2945277a2a7a2d3a821e15395edcf3461" + integrity sha512-0fMy72ej/VEvF8ULmX6yb5MtHG4uH4Dbd6I/aHDb/JVg0bbivwt9Wg+h3uMvX+QSFtwr5MeItvazbrc4jtRAXw== + dependencies: + "@babel/template" "^7.10.4" + "@babel/types" "^7.10.4" + +"@babel/helper-skip-transparent-expression-wrappers@^7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.11.0.tgz#eec162f112c2f58d3af0af125e3bb57665146729" + integrity sha512-0XIdiQln4Elglgjbwo9wuJpL/K7AGCY26kmEt0+pRP0TAj4jjyNq1MjoRvikrTVqKcx4Gysxt4cXvVFXP/JO2Q== + dependencies: + "@babel/types" "^7.11.0" + +"@babel/helper-split-export-declaration@^7.10.4", "@babel/helper-split-export-declaration@^7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz#f8a491244acf6a676158ac42072911ba83ad099f" + integrity sha512-74Vejvp6mHkGE+m+k5vHY93FX2cAtrw1zXrZXRlG4l410Nm9PxfEiVTn1PjDPV5SnmieiueY4AFg2xqhNFuuZg== + dependencies: + "@babel/types" "^7.11.0" + +"@babel/helper-validator-identifier@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2" + integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw== + +"@babel/helpers@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.10.4.tgz#2abeb0d721aff7c0a97376b9e1f6f65d7a475044" + integrity sha512-L2gX/XeUONeEbI78dXSrJzGdz4GQ+ZTA/aazfUsFaWjSe95kiCuOZ5HsXvkiw3iwF+mFHSRUfJU8t6YavocdXA== + dependencies: + "@babel/template" "^7.10.4" + "@babel/traverse" "^7.10.4" + "@babel/types" "^7.10.4" + +"@babel/highlight@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.10.4.tgz#7d1bdfd65753538fabe6c38596cdb76d9ac60143" + integrity sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA== + dependencies: + "@babel/helper-validator-identifier" "^7.10.4" + chalk "^2.0.0" + js-tokens "^4.0.0" + +"@babel/parser@^7.1.0", "@babel/parser@^7.10.4", "@babel/parser@^7.11.5", "@babel/parser@^7.4.3": + version "7.11.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037" + integrity sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q== + +"@babel/plugin-proposal-class-properties@7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.10.4.tgz#a33bf632da390a59c7a8c570045d1115cd778807" + integrity sha512-vhwkEROxzcHGNu2mzUC0OFFNXdZ4M23ib8aRRcJSsW8BZK9pQMD7QB7csl97NBbgGZO7ZyHUyKDnxzOaP4IrCg== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-proposal-nullish-coalescing-operator@^7.8.3": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.10.4.tgz#02a7e961fc32e6d5b2db0649e01bf80ddee7e04a" + integrity sha512-wq5n1M3ZUlHl9sqT2ok1T2/MTt6AXE0e1Lz4WzWBr95LsAZ5qDXe4KnFuauYyEyLiohvXFMdbsOTMyLZs91Zlw== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" + +"@babel/plugin-proposal-optional-chaining@^7.8.3": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.11.0.tgz#de5866d0646f6afdaab8a566382fe3a221755076" + integrity sha512-v9fZIu3Y8562RRwhm1BbMRxtqZNFmFA2EG+pT2diuU8PT3H6T/KXoZ54KgYisfOFZHV6PfvAiBIZ9Rcz+/JCxA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-skip-transparent-expression-wrappers" "^7.11.0" + "@babel/plugin-syntax-optional-chaining" "^7.8.0" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-object-rest-spread@^7.0.0": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" + integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.0": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/template@^7.10.4", "@babel/template@^7.4.0": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278" + integrity sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/parser" "^7.10.4" + "@babel/types" "^7.10.4" + +"@babel/traverse@^7.1.0", "@babel/traverse@^7.10.4", "@babel/traverse@^7.11.5", "@babel/traverse@^7.4.3": + version "7.11.5" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.11.5.tgz#be777b93b518eb6d76ee2e1ea1d143daa11e61c3" + integrity sha512-EjiPXt+r7LiCZXEfRpSJd+jUMnBd4/9OUv7Nx3+0u9+eimMwJmG0Q98lw4/289JCoxSE8OolDMNZaaF/JZ69WQ== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/generator" "^7.11.5" + "@babel/helper-function-name" "^7.10.4" + "@babel/helper-split-export-declaration" "^7.11.0" + "@babel/parser" "^7.11.5" + "@babel/types" "^7.11.5" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.19" + +"@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.11.0", "@babel/types@^7.11.5", "@babel/types@^7.3.0", "@babel/types@^7.4.0": + version "7.11.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d" + integrity sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q== + dependencies: + "@babel/helper-validator-identifier" "^7.10.4" + lodash "^4.17.19" + to-fast-properties "^2.0.0" + +"@cnakazawa/watch@^1.0.3": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.4.tgz#f864ae85004d0fcab6f50be9141c4da368d1656a" + integrity sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ== + dependencies: + exec-sh "^0.3.2" + minimist "^1.2.0" + +"@jest/console@^24.7.1", "@jest/console@^24.9.0": + version "24.9.0" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-24.9.0.tgz#79b1bc06fb74a8cfb01cbdedf945584b1b9707f0" + integrity sha512-Zuj6b8TnKXi3q4ymac8EQfc3ea/uhLeCGThFqXeC8H9/raaH8ARPUTdId+XyGd03Z4In0/VjD2OYFcBF09fNLQ== + dependencies: + "@jest/source-map" "^24.9.0" + chalk "^2.0.1" + slash "^2.0.0" + +"@jest/core@^24.9.0": + version "24.9.0" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-24.9.0.tgz#2ceccd0b93181f9c4850e74f2a9ad43d351369c4" + integrity sha512-Fogg3s4wlAr1VX7q+rhV9RVnUv5tD7VuWfYy1+whMiWUrvl7U3QJSJyWcDio9Lq2prqYsZaeTv2Rz24pWGkJ2A== + dependencies: + "@jest/console" "^24.7.1" + "@jest/reporters" "^24.9.0" + "@jest/test-result" "^24.9.0" + "@jest/transform" "^24.9.0" + "@jest/types" "^24.9.0" + ansi-escapes "^3.0.0" + chalk "^2.0.1" + exit "^0.1.2" + graceful-fs "^4.1.15" + jest-changed-files "^24.9.0" + jest-config "^24.9.0" + jest-haste-map "^24.9.0" + jest-message-util "^24.9.0" + jest-regex-util "^24.3.0" + jest-resolve "^24.9.0" + jest-resolve-dependencies "^24.9.0" + jest-runner "^24.9.0" + jest-runtime "^24.9.0" + jest-snapshot "^24.9.0" + jest-util "^24.9.0" + jest-validate "^24.9.0" + jest-watcher "^24.9.0" + micromatch "^3.1.10" + p-each-series "^1.0.0" + realpath-native "^1.1.0" + rimraf "^2.5.4" + slash "^2.0.0" + strip-ansi "^5.0.0" + +"@jest/environment@^24.9.0": + version "24.9.0" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-24.9.0.tgz#21e3afa2d65c0586cbd6cbefe208bafade44ab18" + integrity sha512-5A1QluTPhvdIPFYnO3sZC3smkNeXPVELz7ikPbhUj0bQjB07EoE9qtLrem14ZUYWdVayYbsjVwIiL4WBIMV4aQ== + dependencies: + "@jest/fake-timers" "^24.9.0" + "@jest/transform" "^24.9.0" + "@jest/types" "^24.9.0" + jest-mock "^24.9.0" + +"@jest/fake-timers@^24.9.0": + version "24.9.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-24.9.0.tgz#ba3e6bf0eecd09a636049896434d306636540c93" + integrity sha512-eWQcNa2YSwzXWIMC5KufBh3oWRIijrQFROsIqt6v/NS9Io/gknw1jsAC9c+ih/RQX4A3O7SeWAhQeN0goKhT9A== + dependencies: + "@jest/types" "^24.9.0" + jest-message-util "^24.9.0" + jest-mock "^24.9.0" + +"@jest/reporters@^24.9.0": + version "24.9.0" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-24.9.0.tgz#86660eff8e2b9661d042a8e98a028b8d631a5b43" + integrity sha512-mu4X0yjaHrffOsWmVLzitKmmmWSQ3GGuefgNscUSWNiUNcEOSEQk9k3pERKEQVBb0Cnn88+UESIsZEMH3o88Gw== + dependencies: + "@jest/environment" "^24.9.0" + "@jest/test-result" "^24.9.0" + "@jest/transform" "^24.9.0" + "@jest/types" "^24.9.0" + chalk "^2.0.1" + exit "^0.1.2" + glob "^7.1.2" + istanbul-lib-coverage "^2.0.2" + istanbul-lib-instrument "^3.0.1" + istanbul-lib-report "^2.0.4" + istanbul-lib-source-maps "^3.0.1" + istanbul-reports "^2.2.6" + jest-haste-map "^24.9.0" + jest-resolve "^24.9.0" + jest-runtime "^24.9.0" + jest-util "^24.9.0" + jest-worker "^24.6.0" + node-notifier "^5.4.2" + slash "^2.0.0" + source-map "^0.6.0" + string-length "^2.0.0" + +"@jest/source-map@^24.3.0", "@jest/source-map@^24.9.0": + version "24.9.0" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-24.9.0.tgz#0e263a94430be4b41da683ccc1e6bffe2a191714" + integrity sha512-/Xw7xGlsZb4MJzNDgB7PW5crou5JqWiBQaz6xyPd3ArOg2nfn/PunV8+olXbbEZzNl591o5rWKE9BRDaFAuIBg== + dependencies: + callsites "^3.0.0" + graceful-fs "^4.1.15" + source-map "^0.6.0" + +"@jest/test-result@^24.9.0": + version "24.9.0" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-24.9.0.tgz#11796e8aa9dbf88ea025757b3152595ad06ba0ca" + integrity sha512-XEFrHbBonBJ8dGp2JmF8kP/nQI/ImPpygKHwQ/SY+es59Z3L5PI4Qb9TQQMAEeYsThG1xF0k6tmG0tIKATNiiA== + dependencies: + "@jest/console" "^24.9.0" + "@jest/types" "^24.9.0" + "@types/istanbul-lib-coverage" "^2.0.0" + +"@jest/test-sequencer@^24.9.0": + version "24.9.0" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-24.9.0.tgz#f8f334f35b625a4f2f355f2fe7e6036dad2e6b31" + integrity sha512-6qqsU4o0kW1dvA95qfNog8v8gkRN9ph6Lz7r96IvZpHdNipP2cBcb07J1Z45mz/VIS01OHJ3pY8T5fUY38tg4A== + dependencies: + "@jest/test-result" "^24.9.0" + jest-haste-map "^24.9.0" + jest-runner "^24.9.0" + jest-runtime "^24.9.0" + +"@jest/transform@^24.9.0": + version "24.9.0" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-24.9.0.tgz#4ae2768b296553fadab09e9ec119543c90b16c56" + integrity sha512-TcQUmyNRxV94S0QpMOnZl0++6RMiqpbH/ZMccFB/amku6Uwvyb1cjYX7xkp5nGNkbX4QPH/FcB6q1HBTHynLmQ== + dependencies: + "@babel/core" "^7.1.0" + "@jest/types" "^24.9.0" + babel-plugin-istanbul "^5.1.0" + chalk "^2.0.1" + convert-source-map "^1.4.0" + fast-json-stable-stringify "^2.0.0" + graceful-fs "^4.1.15" + jest-haste-map "^24.9.0" + jest-regex-util "^24.9.0" + jest-util "^24.9.0" + micromatch "^3.1.10" + pirates "^4.0.1" + realpath-native "^1.1.0" + slash "^2.0.0" + source-map "^0.6.1" + write-file-atomic "2.4.1" + +"@jest/types@^24.9.0": + version "24.9.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-24.9.0.tgz#63cb26cb7500d069e5a389441a7c6ab5e909fc59" + integrity sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^1.1.1" + "@types/yargs" "^13.0.0" + +"@tootallnate/once@2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" + integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== + +"@types/babel__core@^7.1.0": + version "7.1.10" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.10.tgz#ca58fc195dd9734e77e57c6f2df565623636ab40" + integrity sha512-x8OM8XzITIMyiwl5Vmo2B1cR1S1Ipkyv4mdlbJjMa1lmuKvKY9FrBbEANIaMlnWn5Rf7uO+rC/VgYabNkE17Hw== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.6.2" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.2.tgz#f3d71178e187858f7c45e30380f8f1b7415a12d8" + integrity sha512-MdSJnBjl+bdwkLskZ3NGFp9YcXGx5ggLpQQPqtgakVhsWK0hTtNYhjpZLlWQTviGTvF8at+Bvli3jV7faPdgeQ== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.0.3.tgz#b8aaeba0a45caca7b56a5de9459872dde3727214" + integrity sha512-uCoznIPDmnickEi6D0v11SBpW0OuVqHJCa7syXqQHy5uktSCreIlt0iglsCnmvz8yCb38hGcWeseA8cWJSwv5Q== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.0.15.tgz#db9e4238931eb69ef8aab0ad6523d4d4caa39d03" + integrity sha512-Pzh9O3sTK8V6I1olsXpCfj2k/ygO2q1X0vhhnDrEQyYLHZesWz+zMZMVcwXLCYf0U36EtmyYaFGPfXlTtDHe3A== + dependencies: + "@babel/types" "^7.3.0" + +"@types/cheerio@^0.22.22": + version "0.22.22" + resolved "https://registry.yarnpkg.com/@types/cheerio/-/cheerio-0.22.22.tgz#ae71cf4ca59b8bbaf34c99af7a5d6c8894988f5f" + integrity sha512-05DYX4zU96IBfZFY+t3Mh88nlwSMtmmzSYaQkKN48T495VV1dkHSah6qYyDTN5ngaS0i0VonH37m+RuzSM0YiA== + dependencies: + "@types/node" "*" + +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" + integrity sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw== + +"@types/istanbul-lib-report@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686" + integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg== + dependencies: + "@types/istanbul-lib-coverage" "*" + +"@types/istanbul-reports@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-1.1.2.tgz#e875cc689e47bce549ec81f3df5e6f6f11cfaeb2" + integrity sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw== + dependencies: + "@types/istanbul-lib-coverage" "*" + "@types/istanbul-lib-report" "*" + +"@types/node@*": + version "14.11.8" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.8.tgz#fe2012f2355e4ce08bca44aeb3abbb21cf88d33f" + integrity sha512-KPcKqKm5UKDkaYPTuXSx8wEP7vE9GnuaXIZKijwRYcePpZFDVuy2a57LarFKiORbHOuTOOwYzxVxcUzsh2P2Pw== + +"@types/stack-utils@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" + integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== + +"@types/yargs-parser@*": + version "15.0.0" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d" + integrity sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw== + +"@types/yargs@^13.0.0": + version "13.0.11" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-13.0.11.tgz#def2f0c93e4bdf2c61d7e34899b17e34be28d3b1" + integrity sha512-NRqD6T4gktUrDi1o1wLH3EKC1o2caCr7/wR87ODcbVITQF106OM3sFN92ysZ++wqelOd1CTzatnOBRDYYG6wGQ== + dependencies: + "@types/yargs-parser" "*" + +abab@^2.0.0: + version "2.0.5" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" + integrity sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q== + +abab@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" + integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== + +acorn-globals@^4.1.0: + version "4.3.4" + resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-4.3.4.tgz#9fa1926addc11c97308c4e66d7add0d40c3272e7" + integrity sha512-clfQEh21R+D0leSbUdWf3OcfqyaCSAQ8Ryq00bofSekfr9W8u1jyYZo6ir0xu9Gtcf7BjcHJpnbZH7JOCpP60A== + dependencies: + acorn "^6.0.1" + acorn-walk "^6.0.1" + +acorn-globals@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-6.0.0.tgz#46cdd39f0f8ff08a876619b55f5ac8a6dc770b45" + integrity sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg== + dependencies: + acorn "^7.1.1" + acorn-walk "^7.1.1" + +acorn-walk@^6.0.1: + version "6.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-6.2.0.tgz#123cb8f3b84c2171f1f7fb252615b1c78a6b1a8c" + integrity sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA== + +acorn-walk@^7.1.1: + version "7.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" + integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== + +acorn@^5.5.3: + version "5.7.4" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.4.tgz#3e8d8a9947d0599a1796d10225d7432f4a4acf5e" + integrity sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg== + +acorn@^6.0.1: + version "6.4.2" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6" + integrity sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ== + +acorn@^7.1.1: + version "7.4.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" + integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== + +acorn@^8.7.1: + version "8.8.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" + integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== + +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + +airbnb-prop-types@^2.16.0: + version "2.16.0" + resolved "https://registry.yarnpkg.com/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz#b96274cefa1abb14f623f804173ee97c13971dc2" + integrity sha512-7WHOFolP/6cS96PhKNrslCLMYAI8yB1Pp6u6XmxozQOiZbsI5ycglZr5cHhBFfuRcQQjzCMith5ZPZdYiJCxUg== + dependencies: + array.prototype.find "^2.1.1" + function.prototype.name "^1.1.2" + is-regex "^1.1.0" + object-is "^1.1.2" + object.assign "^4.1.0" + object.entries "^1.1.2" + prop-types "^15.7.2" + prop-types-exact "^1.2.0" + react-is "^16.13.1" + +ajv@^6.12.3: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-escapes@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" + integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== + +ansi-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" + integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + +ansi-regex@^4.0.0, ansi-regex@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" + integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== + +ansi-styles@^3.2.0, ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +anymatch@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" + integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== + dependencies: + micromatch "^3.1.4" + normalize-path "^2.1.1" + +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= + +arr-flatten@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" + integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== + +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= + +array-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93" + integrity sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM= + +array-filter@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-1.0.0.tgz#baf79e62e6ef4c2a4c0b831232daffec251f9d83" + integrity sha1-uveeYubvTCpMC4MSMtr/7CUfnYM= + +array-unique@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" + integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= + +array.prototype.find@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/array.prototype.find/-/array.prototype.find-2.1.1.tgz#3baca26108ca7affb08db06bf0be6cb3115a969c" + integrity sha512-mi+MYNJYLTx2eNYy+Yh6raoQacCsNeeMUaspFPh9Y141lFSsWxxB8V9mM2ye+eqiRs917J6/pJ4M9ZPzenWckA== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.4" + +array.prototype.flat@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.3.tgz#0de82b426b0318dbfdb940089e38b043d37f6c7b" + integrity sha512-gBlRZV0VSmfPIeWfuuy56XZMvbVfbEUnOXUvt3F/eUUUSyzlgLxhEX4YAEpxNAogRGehPSnfXyPtYyKAhkzQhQ== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + +asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= + +asn1@~0.2.3: + version "0.2.4" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" + integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== + dependencies: + safer-buffer "~2.1.0" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= + +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= + +astral-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" + integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== + +async-limiter@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" + integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +atob@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" + integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== + +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= + +aws4@^1.8.0: + version "1.10.1" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.10.1.tgz#e1e82e4f3e999e2cfd61b161280d16a111f86428" + integrity sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA== + +babel-jest@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-24.9.0.tgz#3fc327cb8467b89d14d7bc70e315104a783ccd54" + integrity sha512-ntuddfyiN+EhMw58PTNL1ph4C9rECiQXjI4nMMBKBaNjXvqLdkXpPRcMSr4iyBrJg/+wz9brFUD6RhOAT6r4Iw== + dependencies: + "@jest/transform" "^24.9.0" + "@jest/types" "^24.9.0" + "@types/babel__core" "^7.1.0" + babel-plugin-istanbul "^5.1.0" + babel-preset-jest "^24.9.0" + chalk "^2.4.2" + slash "^2.0.0" + +babel-plugin-istanbul@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-5.2.0.tgz#df4ade83d897a92df069c4d9a25cf2671293c854" + integrity sha512-5LphC0USA8t4i1zCtjbbNb6jJj/9+X6P37Qfirc/70EQ34xKlMW+a1RHGwxGI+SwWpNwZ27HqvzAobeqaXwiZw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + find-up "^3.0.0" + istanbul-lib-instrument "^3.3.0" + test-exclude "^5.2.3" + +babel-plugin-jest-hoist@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-24.9.0.tgz#4f837091eb407e01447c8843cbec546d0002d756" + integrity sha512-2EMA2P8Vp7lG0RAzr4HXqtYwacfMErOuv1U3wrvxHX6rD1sV6xS3WXG3r8TRQ2r6w8OhvSdWt+z41hQNwNm3Xw== + dependencies: + "@types/babel__traverse" "^7.0.6" + +babel-plugin-transform-amd-to-commonjs@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-amd-to-commonjs/-/babel-plugin-transform-amd-to-commonjs-1.4.0.tgz#d9bc5003eaa26dbdd4e854e453f84903852af2ca" + integrity sha512-Xx0kYPn0LPyms+8n2KLn9yd2R5XMb2P1sNe4qn64/UQY5F2KFYlhhhyYUNm/BThfODAzl7rbaOsEfpU2M8iDKQ== + +babel-preset-jest@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-24.9.0.tgz#192b521e2217fb1d1f67cf73f70c336650ad3cdc" + integrity sha512-izTUuhE4TMfTRPF92fFwD2QfdXaZW08qvWTFCI51V8rW5x00UuPgc3ajRoWofXOuxjfcOM5zzSYsQS3H8KGCAg== + dependencies: + "@babel/plugin-syntax-object-rest-spread" "^7.0.0" + babel-plugin-jest-hoist "^24.9.0" + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + +base@^0.11.1: + version "0.11.2" + resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== + dependencies: + cache-base "^1.0.1" + class-utils "^0.3.5" + component-emitter "^1.2.1" + define-property "^1.0.0" + isobject "^3.0.1" + mixin-deep "^1.2.0" + pascalcase "^0.1.1" + +bcrypt-pbkdf@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= + dependencies: + tweetnacl "^0.14.3" + +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + +boolbase@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" + integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + +browser-process-hrtime@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" + integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== + +browser-resolve@^1.11.3: + version "1.11.3" + resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-1.11.3.tgz#9b7cbb3d0f510e4cb86bdbd796124d28b5890af6" + integrity sha512-exDi1BYWB/6raKHmDTCicQfTkqwN5fioMFV4j8BsfMU4R2DK/QfZfK7kOVkmWCNANf0snkBzqGqAJBao9gZMdQ== + dependencies: + resolve "1.1.7" + +bser@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" + integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== + dependencies: + node-int64 "^0.4.0" + +buffer-from@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" + integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== + +cache-base@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" + integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== + dependencies: + collection-visit "^1.0.0" + component-emitter "^1.2.1" + get-value "^2.0.6" + has-value "^1.0.0" + isobject "^3.0.1" + set-value "^2.0.0" + to-object-path "^0.3.0" + union-value "^1.0.0" + unset-value "^1.0.0" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase@^5.0.0, camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +capture-exit@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-2.0.0.tgz#fb953bfaebeb781f62898239dabb426d08a509a4" + integrity sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g== + dependencies: + rsvp "^4.8.4" + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= + +chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +cheerio@^1.0.0-rc.3: + version "1.0.0-rc.3" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.3.tgz#094636d425b2e9c0f4eb91a46c05630c9a1a8bf6" + integrity sha512-0td5ijfUPuubwLUu0OBoe98gZj8C/AA+RW3v67GPlGOrvxWjZmBXiBCRU+I8VEiNyJzjth40POfHiz2RB3gImA== + dependencies: + css-select "~1.2.0" + dom-serializer "~0.1.1" + entities "~1.1.1" + htmlparser2 "^3.9.1" + lodash "^4.15.0" + parse5 "^3.0.1" + +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + +class-utils@^0.3.5: + version "0.3.6" + resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" + integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== + dependencies: + arr-union "^3.1.0" + define-property "^0.2.5" + isobject "^3.0.0" + static-extend "^0.1.1" + +cliui@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" + integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== + dependencies: + string-width "^3.1.0" + strip-ansi "^5.2.0" + wrap-ansi "^5.1.0" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= + +collection-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" + integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= + dependencies: + map-visit "^1.0.0" + object-visit "^1.0.0" + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@^2.19.0: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +component-emitter@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +convert-source-map@^1.4.0, convert-source-map@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" + integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA== + dependencies: + safe-buffer "~5.1.1" + +copy-descriptor@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" + integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= + +core-js@^1.0.0: + version "1.2.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" + integrity sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY= + +core-util-is@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + +cross-spawn@^6.0.0: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + +css-select@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" + integrity sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg= + dependencies: + boolbase "~1.0.0" + css-what "2.1" + domutils "1.5.1" + nth-check "~1.0.1" + +css-what@2.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2" + integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== + +cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0", cssom@~0.3.6: + version "0.3.8" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" + integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== + +cssom@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36" + integrity sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw== + +cssstyle@^1.0.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-1.4.0.tgz#9d31328229d3c565c61e586b02041a28fccdccf1" + integrity sha512-GBrLZYZ4X4x6/QEoBnIrqb8B/f5l4+8me2dkom/j1Gtbxy0kBv6OGzKuAsGM75bkGwGAFkt56Iwg28S3XTZgSA== + dependencies: + cssom "0.3.x" + +cssstyle@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-2.3.0.tgz#ff665a0ddbdc31864b09647f34163443d90b0852" + integrity sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A== + dependencies: + cssom "~0.3.6" + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= + dependencies: + assert-plus "^1.0.0" + +data-urls@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-1.1.0.tgz#15ee0582baa5e22bb59c77140da8f9c76963bbfe" + integrity sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ== + dependencies: + abab "^2.0.0" + whatwg-mimetype "^2.2.0" + whatwg-url "^7.0.0" + +data-urls@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143" + integrity sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ== + dependencies: + abab "^2.0.6" + whatwg-mimetype "^3.0.0" + whatwg-url "^11.0.0" + +debug@4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +debug@^2.2.0, debug@^2.3.3: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@^4.1.0, debug@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.2.0.tgz#7f150f93920e94c58f5574c2fd01a3110effe7f1" + integrity sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg== + dependencies: + ms "2.1.2" + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + +decimal.js@^10.3.1: + version "10.4.0" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.0.tgz#97a7448873b01e92e5ff9117d89a7bca8e63e0fe" + integrity sha512-Nv6ENEzyPQ6AItkGwLE2PGKinZZ9g59vSh2BeH6NqPu0OTKZ5ruJsVqh/orbAnqXc9pBbgXAIrc2EyaCj8NpGg== + +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" + integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= + +deep-is@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" + integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= + +define-properties@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" + integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== + dependencies: + object-keys "^1.0.12" + +define-property@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" + integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= + dependencies: + is-descriptor "^0.1.0" + +define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" + integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY= + dependencies: + is-descriptor "^1.0.0" + +define-property@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" + integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== + dependencies: + is-descriptor "^1.0.2" + isobject "^3.0.1" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + +detect-newline@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" + integrity sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I= + +diff-sequences@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.9.0.tgz#5715d6244e2aa65f48bba0bc972db0b0b11e95b5" + integrity sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew== + +discontinuous-range@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a" + integrity sha1-44Mx8IRLukm5qctxx3FYWqsbxlo= + +dom-serializer@0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" + integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g== + dependencies: + domelementtype "^2.0.1" + entities "^2.0.0" + +dom-serializer@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0" + integrity sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA== + dependencies: + domelementtype "^1.3.0" + entities "^1.1.1" + +domelementtype@1, domelementtype@^1.3.0, domelementtype@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" + integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== + +domelementtype@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.0.2.tgz#f3b6e549201e46f588b59463dd77187131fe6971" + integrity sha512-wFwTwCVebUrMgGeAwRL/NhZtHAUyT9n9yg4IMDwf10+6iCMxSkVq9MGCVEH+QZWo1nNidy8kNvwmv4zWHDTqvA== + +domexception@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" + integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug== + dependencies: + webidl-conversions "^4.0.2" + +domexception@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673" + integrity sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw== + dependencies: + webidl-conversions "^7.0.0" + +domhandler@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" + integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA== + dependencies: + domelementtype "1" + +domutils@1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" + integrity sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8= + dependencies: + dom-serializer "0" + domelementtype "1" + +domutils@^1.5.1: + version "1.7.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" + integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== + dependencies: + dom-serializer "0" + domelementtype "1" + +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + +encoding@^0.1.11: + version "0.1.13" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" + integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== + dependencies: + iconv-lite "^0.6.2" + +end-of-stream@^1.1.0: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +entities@^1.1.1, entities@~1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" + integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== + +entities@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f" + integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ== + +entities@^4.3.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.3.1.tgz#c34062a94c865c322f9d67b4384e4169bcede6a4" + integrity sha512-o4q/dYJlmyjP2zfnaWDUC6A3BQFmVTX+tZPezK7k0GLSU9QYCauscf5Y+qcEPzKL+EixVouYDgLQK5H9GrLpkg== + +enzyme-adapter-react-16@^1.13.2: + version "1.15.5" + resolved "https://registry.yarnpkg.com/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.5.tgz#7a6f0093d3edd2f7025b36e7fbf290695473ee04" + integrity sha512-33yUJGT1nHFQlbVI5qdo5Pfqvu/h4qPwi1o0a6ZZsjpiqq92a3HjynDhwd1IeED+Su60HDWV8mxJqkTnLYdGkw== + dependencies: + enzyme-adapter-utils "^1.13.1" + enzyme-shallow-equal "^1.0.4" + has "^1.0.3" + object.assign "^4.1.0" + object.values "^1.1.1" + prop-types "^15.7.2" + react-is "^16.13.1" + react-test-renderer "^16.0.0-0" + semver "^5.7.0" + +enzyme-adapter-utils@^1.13.1: + version "1.13.1" + resolved "https://registry.yarnpkg.com/enzyme-adapter-utils/-/enzyme-adapter-utils-1.13.1.tgz#59c1b734b0927543e3d8dc477299ec957feb312d" + integrity sha512-5A9MXXgmh/Tkvee3bL/9RCAAgleHqFnsurTYCbymecO4ohvtNO5zqIhHxV370t7nJAwaCfkgtffarKpC0GPt0g== + dependencies: + airbnb-prop-types "^2.16.0" + function.prototype.name "^1.1.2" + object.assign "^4.1.0" + object.fromentries "^2.0.2" + prop-types "^15.7.2" + semver "^5.7.1" + +enzyme-shallow-equal@^1.0.1, enzyme-shallow-equal@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.4.tgz#b9256cb25a5f430f9bfe073a84808c1d74fced2e" + integrity sha512-MttIwB8kKxypwHvRynuC3ahyNc+cFbR8mjVIltnmzQ0uKGqmsfO4bfBuLxb0beLNPhjblUEYvEbsg+VSygvF1Q== + dependencies: + has "^1.0.3" + object-is "^1.1.2" + +enzyme-to-json@^3.3.5: + version "3.6.1" + resolved "https://registry.yarnpkg.com/enzyme-to-json/-/enzyme-to-json-3.6.1.tgz#d60740950bc7ca6384dfe6fe405494ec5df996bc" + integrity sha512-15tXuONeq5ORoZjV/bUo2gbtZrN2IH+Z6DvL35QmZyKHgbY1ahn6wcnLd9Xv9OjiwbAXiiP8MRZwbZrCv1wYNg== + dependencies: + "@types/cheerio" "^0.22.22" + lodash "^4.17.15" + react-is "^16.12.0" + +enzyme@^3.9.0: + version "3.11.0" + resolved "https://registry.yarnpkg.com/enzyme/-/enzyme-3.11.0.tgz#71d680c580fe9349f6f5ac6c775bc3e6b7a79c28" + integrity sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw== + dependencies: + array.prototype.flat "^1.2.3" + cheerio "^1.0.0-rc.3" + enzyme-shallow-equal "^1.0.1" + function.prototype.name "^1.1.2" + has "^1.0.3" + html-element-map "^1.2.0" + is-boolean-object "^1.0.1" + is-callable "^1.1.5" + is-number-object "^1.0.4" + is-regex "^1.0.5" + is-string "^1.0.5" + is-subset "^0.1.1" + lodash.escape "^4.0.1" + lodash.isequal "^4.5.0" + object-inspect "^1.7.0" + object-is "^1.0.2" + object.assign "^4.1.0" + object.entries "^1.1.1" + object.values "^1.1.1" + raf "^3.4.1" + rst-selector-parser "^2.2.3" + string.prototype.trim "^1.2.1" + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + dependencies: + is-arrayish "^0.2.1" + +es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.4, es-abstract@^1.17.5: + version "1.17.7" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c" + integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g== + dependencies: + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + is-callable "^1.2.2" + is-regex "^1.1.1" + object-inspect "^1.8.0" + object-keys "^1.1.1" + object.assign "^4.1.1" + string.prototype.trimend "^1.0.1" + string.prototype.trimstart "^1.0.1" + +es-abstract@^1.18.0-next.0, es-abstract@^1.18.0-next.1: + version "1.18.0-next.1" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.1.tgz#6e3a0a4bda717e5023ab3b8e90bec36108d22c68" + integrity sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA== + dependencies: + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + is-callable "^1.2.2" + is-negative-zero "^2.0.0" + is-regex "^1.1.1" + object-inspect "^1.8.0" + object-keys "^1.1.1" + object.assign "^4.1.1" + string.prototype.trimend "^1.0.1" + string.prototype.trimstart "^1.0.1" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +escodegen@^1.9.1: + version "1.14.3" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" + integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw== + dependencies: + esprima "^4.0.1" + estraverse "^4.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + +escodegen@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.0.0.tgz#5e32b12833e8aa8fa35e1bf0befa89380484c7dd" + integrity sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw== + dependencies: + esprima "^4.0.1" + estraverse "^5.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + +esprima@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +estraverse@^4.2.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + +estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +exec-sh@^0.3.2: + version "0.3.4" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.4.tgz#3a018ceb526cc6f6df2bb504b2bfe8e3a4934ec5" + integrity sha512-sEFIkc61v75sWeOe72qyrqg2Qg0OuLESziUDk/O/z2qgS15y2gWVFrI6f2Qn/qw/0/NCfCEsmNA4zOjkwEZT1A== + +execa@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" + integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== + dependencies: + cross-spawn "^6.0.0" + get-stream "^4.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +exit@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" + integrity sha1-BjJjj42HfMghB9MKD/8aF8uhzQw= + +expand-brackets@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" + integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI= + dependencies: + debug "^2.3.3" + define-property "^0.2.5" + extend-shallow "^2.0.1" + posix-character-classes "^0.1.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +expect@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-24.9.0.tgz#b75165b4817074fa4a157794f46fe9f1ba15b6ca" + integrity sha512-wvVAx8XIol3Z5m9zvZXiyZOQ+sRJqNTIm6sGjdWlaZIeupQGO3WbYI+15D/AmEwZywL6wtJkbAbJtzkOfBuR0Q== + dependencies: + "@jest/types" "^24.9.0" + ansi-styles "^3.2.0" + jest-get-type "^24.9.0" + jest-matcher-utils "^24.9.0" + jest-message-util "^24.9.0" + jest-regex-util "^24.9.0" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= + dependencies: + is-extendable "^0.1.0" + +extend-shallow@^3.0.0, extend-shallow@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + +extend@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +extglob@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" + integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== + dependencies: + array-unique "^0.3.2" + define-property "^1.0.0" + expand-brackets "^2.1.4" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +extsprintf@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= + +extsprintf@^1.2.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" + integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= + +fast-deep-equal@^3.1.1: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@~2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= + +fb-watchman@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85" + integrity sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg== + dependencies: + bser "2.1.1" + +fbjs@^0.8.16: + version "0.8.17" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" + integrity sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90= + dependencies: + core-js "^1.0.0" + isomorphic-fetch "^2.1.1" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.18" + +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + +fill-range@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" + integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= + dependencies: + extend-shallow "^2.0.1" + is-number "^3.0.0" + repeat-string "^1.6.1" + to-regex-range "^2.1.0" + +find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + dependencies: + locate-path "^3.0.0" + +for-in@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +form-data@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" + integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + +fragment-cache@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" + integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= + dependencies: + map-cache "^0.2.2" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +fsevents@^1.2.7: + version "1.2.13" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38" + integrity sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw== + dependencies: + bindings "^1.5.0" + nan "^2.12.1" + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + +function.prototype.name@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.2.tgz#5cdf79d7c05db401591dfde83e3b70c5123e9a45" + integrity sha512-C8A+LlHBJjB2AdcRPorc5JvJ5VUoWlXdEHLOJdCI7kjHEtGTpHQUiqMvCIKUwIsGwZX2jZJy761AXsn356bJQg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + functions-have-names "^1.2.0" + +functions-have-names@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.1.tgz#a981ac397fa0c9964551402cdc5533d7a4d52f91" + integrity sha512-j48B/ZI7VKs3sgeI2cZp7WXWmZXu7Iq5pl5/vptV5N2mq+DGFuS/ulaDjtaoLpYzuD6u8UgrUKHfgo7fDTSiBA== + +gensync@^1.0.0-beta.1: + version "1.0.0-beta.1" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269" + integrity sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg== + +get-caller-file@^2.0.1: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-stream@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== + dependencies: + pump "^3.0.0" + +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + dependencies: + assert-plus "^1.0.0" + +glob@^7.1.1, glob@^7.1.2, glob@^7.1.3: + version "7.1.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2: + version "4.2.4" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" + integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== + +growly@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" + integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= + +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= + +har-validator@~5.1.3: + version "5.1.5" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" + integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== + dependencies: + ajv "^6.12.3" + har-schema "^2.0.0" + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + +has-symbols@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" + integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== + +has-value@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" + integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= + dependencies: + get-value "^2.0.3" + has-values "^0.1.4" + isobject "^2.0.0" + +has-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" + integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= + dependencies: + get-value "^2.0.6" + has-values "^1.0.0" + isobject "^3.0.0" + +has-values@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" + integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E= + +has-values@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" + integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + +has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +hosted-git-info@^2.1.4: + version "2.8.8" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" + integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + +html-element-map@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/html-element-map/-/html-element-map-1.2.0.tgz#dfbb09efe882806af63d990cf6db37993f099f22" + integrity sha512-0uXq8HsuG1v2TmQ8QkIhzbrqeskE4kn52Q18QJ9iAA/SnHoEKXWiUxHQtclRsCFWEUD2So34X+0+pZZu862nnw== + dependencies: + array-filter "^1.0.0" + +html-encoding-sniffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz#e70d84b94da53aa375e11fe3a351be6642ca46f8" + integrity sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw== + dependencies: + whatwg-encoding "^1.0.1" + +html-encoding-sniffer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" + integrity sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA== + dependencies: + whatwg-encoding "^2.0.0" + +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + +htmlparser2@^3.9.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" + integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== + dependencies: + domelementtype "^1.3.1" + domhandler "^2.3.0" + domutils "^1.5.1" + entities "^1.1.1" + inherits "^2.0.1" + readable-stream "^3.1.1" + +http-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" + integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== + dependencies: + "@tootallnate/once" "2" + agent-base "6" + debug "4" + +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +https-proxy-agent@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +iconv-lite@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.2.tgz#ce13d1875b0c3a674bd6a04b7f76b01b1b6ded01" + integrity sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +import-local@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d" + integrity sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ== + dependencies: + pkg-dir "^3.0.0" + resolve-cwd "^2.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@^2.0.1, inherits@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +invariant@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" + integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== + dependencies: + loose-envify "^1.0.0" + +is-accessor-descriptor@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" + integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= + dependencies: + kind-of "^3.0.2" + +is-accessor-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" + integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== + dependencies: + kind-of "^6.0.0" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= + +is-boolean-object@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.0.1.tgz#10edc0900dd127697a92f6f9807c7617d68ac48e" + integrity sha512-TqZuVwa/sppcrhUCAYkGBk7w0yxfQQnxq28fjkO53tnK9FQXmdwz2JS5+GjsWQ6RByES1K40nI+yDic5c9/aAQ== + +is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-callable@^1.1.4, is-callable@^1.1.5, is-callable@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9" + integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== + +is-ci@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + dependencies: + ci-info "^2.0.0" + +is-data-descriptor@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" + integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= + dependencies: + kind-of "^3.0.2" + +is-data-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" + integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== + dependencies: + kind-of "^6.0.0" + +is-date-object@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e" + integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g== + +is-descriptor@^0.1.0: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" + integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== + dependencies: + is-accessor-descriptor "^0.1.6" + is-data-descriptor "^0.1.4" + kind-of "^5.0.0" + +is-descriptor@^1.0.0, is-descriptor@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" + integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== + dependencies: + is-accessor-descriptor "^1.0.0" + is-data-descriptor "^1.0.0" + kind-of "^6.0.2" + +is-extendable@^0.1.0, is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= + +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== + dependencies: + is-plain-object "^2.0.4" + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= + +is-generator-fn@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" + integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== + +is-negative-zero@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461" + integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE= + +is-number-object@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197" + integrity sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw== + +is-number@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" + integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= + dependencies: + kind-of "^3.0.2" + +is-plain-object@^2.0.3, is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + +is-regex@^1.0.5, is-regex@^1.1.0, is-regex@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" + integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg== + dependencies: + has-symbols "^1.0.1" + +is-stream@^1.0.1, is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + +is-string@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6" + integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ== + +is-subset@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-subset/-/is-subset-0.1.1.tgz#8a59117d932de1de00f245fcdd39ce43f1e939a6" + integrity sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY= + +is-symbol@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937" + integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ== + dependencies: + has-symbols "^1.0.1" + +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + +is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== + +is-wsl@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" + integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= + +isarray@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= + dependencies: + isarray "1.0.0" + +isobject@^3.0.0, isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= + +isomorphic-fetch@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" + integrity sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk= + dependencies: + node-fetch "^1.0.1" + whatwg-fetch ">=0.10.0" + +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + +istanbul-lib-coverage@^2.0.2, istanbul-lib-coverage@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz#675f0ab69503fad4b1d849f736baaca803344f49" + integrity sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA== + +istanbul-lib-instrument@^3.0.1, istanbul-lib-instrument@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-3.3.0.tgz#a5f63d91f0bbc0c3e479ef4c5de027335ec6d630" + integrity sha512-5nnIN4vo5xQZHdXno/YDXJ0G+I3dAm4XgzfSVTPLQpj/zAV2dV6Juy0yaf10/zrJOJeHoN3fraFe+XRq2bFVZA== + dependencies: + "@babel/generator" "^7.4.0" + "@babel/parser" "^7.4.3" + "@babel/template" "^7.4.0" + "@babel/traverse" "^7.4.3" + "@babel/types" "^7.4.0" + istanbul-lib-coverage "^2.0.5" + semver "^6.0.0" + +istanbul-lib-report@^2.0.4: + version "2.0.8" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-2.0.8.tgz#5a8113cd746d43c4889eba36ab10e7d50c9b4f33" + integrity sha512-fHBeG573EIihhAblwgxrSenp0Dby6tJMFR/HvlerBsrCTD5bkUuoNtn3gVh29ZCS824cGGBPn7Sg7cNk+2xUsQ== + dependencies: + istanbul-lib-coverage "^2.0.5" + make-dir "^2.1.0" + supports-color "^6.1.0" + +istanbul-lib-source-maps@^3.0.1: + version "3.0.6" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-3.0.6.tgz#284997c48211752ec486253da97e3879defba8c8" + integrity sha512-R47KzMtDJH6X4/YW9XTx+jrLnZnscW4VpNN+1PViSYTejLVPWv7oov+Duf8YQSPyVRUvueQqz1TcsC6mooZTXw== + dependencies: + debug "^4.1.1" + istanbul-lib-coverage "^2.0.5" + make-dir "^2.1.0" + rimraf "^2.6.3" + source-map "^0.6.1" + +istanbul-reports@^2.2.6: + version "2.2.7" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-2.2.7.tgz#5d939f6237d7b48393cc0959eab40cd4fd056931" + integrity sha512-uu1F/L1o5Y6LzPVSVZXNOoD/KXpJue9aeLRd0sM9uMXfZvzomB0WxVamWb5ue8kA2vVWEmW7EG+A5n3f1kqHKg== + dependencies: + html-escaper "^2.0.0" + +jest-changed-files@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.9.0.tgz#08d8c15eb79a7fa3fc98269bc14b451ee82f8039" + integrity sha512-6aTWpe2mHF0DhL28WjdkO8LyGjs3zItPET4bMSeXU6T3ub4FPMw+mcOcbdGXQOAfmLcxofD23/5Bl9Z4AkFwqg== + dependencies: + "@jest/types" "^24.9.0" + execa "^1.0.0" + throat "^4.0.0" + +jest-cli@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-24.9.0.tgz#ad2de62d07472d419c6abc301fc432b98b10d2af" + integrity sha512-+VLRKyitT3BWoMeSUIHRxV/2g8y9gw91Jh5z2UmXZzkZKpbC08CSehVxgHUwTpy+HwGcns/tqafQDJW7imYvGg== + dependencies: + "@jest/core" "^24.9.0" + "@jest/test-result" "^24.9.0" + "@jest/types" "^24.9.0" + chalk "^2.0.1" + exit "^0.1.2" + import-local "^2.0.0" + is-ci "^2.0.0" + jest-config "^24.9.0" + jest-util "^24.9.0" + jest-validate "^24.9.0" + prompts "^2.0.1" + realpath-native "^1.1.0" + yargs "^13.3.0" + +jest-config@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-24.9.0.tgz#fb1bbc60c73a46af03590719efa4825e6e4dd1b5" + integrity sha512-RATtQJtVYQrp7fvWg6f5y3pEFj9I+H8sWw4aKxnDZ96mob5i5SD6ZEGWgMLXQ4LE8UurrjbdlLWdUeo+28QpfQ== + dependencies: + "@babel/core" "^7.1.0" + "@jest/test-sequencer" "^24.9.0" + "@jest/types" "^24.9.0" + babel-jest "^24.9.0" + chalk "^2.0.1" + glob "^7.1.1" + jest-environment-jsdom "^24.9.0" + jest-environment-node "^24.9.0" + jest-get-type "^24.9.0" + jest-jasmine2 "^24.9.0" + jest-regex-util "^24.3.0" + jest-resolve "^24.9.0" + jest-util "^24.9.0" + jest-validate "^24.9.0" + micromatch "^3.1.10" + pretty-format "^24.9.0" + realpath-native "^1.1.0" + +jest-diff@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-24.9.0.tgz#931b7d0d5778a1baf7452cb816e325e3724055da" + integrity sha512-qMfrTs8AdJE2iqrTp0hzh7kTd2PQWrsFyj9tORoKmu32xjPjeE4NyjVRDz8ybYwqS2ik8N4hsIpiVTyFeo2lBQ== + dependencies: + chalk "^2.0.1" + diff-sequences "^24.9.0" + jest-get-type "^24.9.0" + pretty-format "^24.9.0" + +jest-docblock@^24.3.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-24.9.0.tgz#7970201802ba560e1c4092cc25cbedf5af5a8ce2" + integrity sha512-F1DjdpDMJMA1cN6He0FNYNZlo3yYmOtRUnktrT9Q37njYzC5WEaDdmbynIgy0L/IvXvvgsG8OsqhLPXTpfmZAA== + dependencies: + detect-newline "^2.1.0" + +jest-each@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-24.9.0.tgz#eb2da602e2a610898dbc5f1f6df3ba86b55f8b05" + integrity sha512-ONi0R4BvW45cw8s2Lrx8YgbeXL1oCQ/wIDwmsM3CqM/nlblNCPmnC3IPQlMbRFZu3wKdQ2U8BqM6lh3LJ5Bsog== + dependencies: + "@jest/types" "^24.9.0" + chalk "^2.0.1" + jest-get-type "^24.9.0" + jest-util "^24.9.0" + pretty-format "^24.9.0" + +jest-environment-jsdom@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-24.9.0.tgz#4b0806c7fc94f95edb369a69cc2778eec2b7375b" + integrity sha512-Zv9FV9NBRzLuALXjvRijO2351DRQeLYXtpD4xNvfoVFw21IOKNhZAEUKcbiEtjTkm2GsJ3boMVgkaR7rN8qetA== + dependencies: + "@jest/environment" "^24.9.0" + "@jest/fake-timers" "^24.9.0" + "@jest/types" "^24.9.0" + jest-mock "^24.9.0" + jest-util "^24.9.0" + jsdom "^11.5.1" + +jest-environment-node@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-24.9.0.tgz#333d2d2796f9687f2aeebf0742b519f33c1cbfd3" + integrity sha512-6d4V2f4nxzIzwendo27Tr0aFm+IXWa0XEUnaH6nU0FMaozxovt+sfRvh4J47wL1OvF83I3SSTu0XK+i4Bqe7uA== + dependencies: + "@jest/environment" "^24.9.0" + "@jest/fake-timers" "^24.9.0" + "@jest/types" "^24.9.0" + jest-mock "^24.9.0" + jest-util "^24.9.0" + +jest-get-type@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-24.9.0.tgz#1684a0c8a50f2e4901b6644ae861f579eed2ef0e" + integrity sha512-lUseMzAley4LhIcpSP9Jf+fTrQ4a1yHQwLNeeVa2cEmbCGeoZAtYPOIv8JaxLD/sUpKxetKGP+gsHl8f8TSj8Q== + +jest-haste-map@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-24.9.0.tgz#b38a5d64274934e21fa417ae9a9fbeb77ceaac7d" + integrity sha512-kfVFmsuWui2Sj1Rp1AJ4D9HqJwE4uwTlS/vO+eRUaMmd54BFpli2XhMQnPC2k4cHFVbB2Q2C+jtI1AGLgEnCjQ== + dependencies: + "@jest/types" "^24.9.0" + anymatch "^2.0.0" + fb-watchman "^2.0.0" + graceful-fs "^4.1.15" + invariant "^2.2.4" + jest-serializer "^24.9.0" + jest-util "^24.9.0" + jest-worker "^24.9.0" + micromatch "^3.1.10" + sane "^4.0.3" + walker "^1.0.7" + optionalDependencies: + fsevents "^1.2.7" + +jest-jasmine2@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-24.9.0.tgz#1f7b1bd3242c1774e62acabb3646d96afc3be6a0" + integrity sha512-Cq7vkAgaYKp+PsX+2/JbTarrk0DmNhsEtqBXNwUHkdlbrTBLtMJINADf2mf5FkowNsq8evbPc07/qFO0AdKTzw== + dependencies: + "@babel/traverse" "^7.1.0" + "@jest/environment" "^24.9.0" + "@jest/test-result" "^24.9.0" + "@jest/types" "^24.9.0" + chalk "^2.0.1" + co "^4.6.0" + expect "^24.9.0" + is-generator-fn "^2.0.0" + jest-each "^24.9.0" + jest-matcher-utils "^24.9.0" + jest-message-util "^24.9.0" + jest-runtime "^24.9.0" + jest-snapshot "^24.9.0" + jest-util "^24.9.0" + pretty-format "^24.9.0" + throat "^4.0.0" + +jest-leak-detector@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-24.9.0.tgz#b665dea7c77100c5c4f7dfcb153b65cf07dcf96a" + integrity sha512-tYkFIDsiKTGwb2FG1w8hX9V0aUb2ot8zY/2nFg087dUageonw1zrLMP4W6zsRO59dPkTSKie+D4rhMuP9nRmrA== + dependencies: + jest-get-type "^24.9.0" + pretty-format "^24.9.0" + +jest-matcher-utils@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-24.9.0.tgz#f5b3661d5e628dffe6dd65251dfdae0e87c3a073" + integrity sha512-OZz2IXsu6eaiMAwe67c1T+5tUAtQyQx27/EMEkbFAGiw52tB9em+uGbzpcgYVpA8wl0hlxKPZxrly4CXU/GjHA== + dependencies: + chalk "^2.0.1" + jest-diff "^24.9.0" + jest-get-type "^24.9.0" + pretty-format "^24.9.0" + +jest-message-util@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-24.9.0.tgz#527f54a1e380f5e202a8d1149b0ec872f43119e3" + integrity sha512-oCj8FiZ3U0hTP4aSui87P4L4jC37BtQwUMqk+zk/b11FR19BJDeZsZAvIHutWnmtw7r85UmR3CEWZ0HWU2mAlw== + dependencies: + "@babel/code-frame" "^7.0.0" + "@jest/test-result" "^24.9.0" + "@jest/types" "^24.9.0" + "@types/stack-utils" "^1.0.1" + chalk "^2.0.1" + micromatch "^3.1.10" + slash "^2.0.0" + stack-utils "^1.0.1" + +jest-mock@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-24.9.0.tgz#c22835541ee379b908673ad51087a2185c13f1c6" + integrity sha512-3BEYN5WbSq9wd+SyLDES7AHnjH9A/ROBwmz7l2y+ol+NtSFO8DYiEBzoO1CeFc9a8DYy10EO4dDFVv/wN3zl1w== + dependencies: + "@jest/types" "^24.9.0" + +jest-pnp-resolver@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz#b704ac0ae028a89108a4d040b3f919dfddc8e33c" + integrity sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w== + +jest-regex-util@^24.3.0, jest-regex-util@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-24.9.0.tgz#c13fb3380bde22bf6575432c493ea8fe37965636" + integrity sha512-05Cmb6CuxaA+Ys6fjr3PhvV3bGQmO+2p2La4hFbU+W5uOc479f7FdLXUWXw4pYMAhhSZIuKHwSXSu6CsSBAXQA== + +jest-resolve-dependencies@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-24.9.0.tgz#ad055198959c4cfba8a4f066c673a3f0786507ab" + integrity sha512-Fm7b6AlWnYhT0BXy4hXpactHIqER7erNgIsIozDXWl5dVm+k8XdGVe1oTg1JyaFnOxarMEbax3wyRJqGP2Pq+g== + dependencies: + "@jest/types" "^24.9.0" + jest-regex-util "^24.3.0" + jest-snapshot "^24.9.0" + +jest-resolve@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-24.9.0.tgz#dff04c7687af34c4dd7e524892d9cf77e5d17321" + integrity sha512-TaLeLVL1l08YFZAt3zaPtjiVvyy4oSA6CRe+0AFPPVX3Q/VI0giIWWoAvoS5L96vj9Dqxj4fB5p2qrHCmTU/MQ== + dependencies: + "@jest/types" "^24.9.0" + browser-resolve "^1.11.3" + chalk "^2.0.1" + jest-pnp-resolver "^1.2.1" + realpath-native "^1.1.0" + +jest-runner@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-24.9.0.tgz#574fafdbd54455c2b34b4bdf4365a23857fcdf42" + integrity sha512-KksJQyI3/0mhcfspnxxEOBueGrd5E4vV7ADQLT9ESaCzz02WnbdbKWIf5Mkaucoaj7obQckYPVX6JJhgUcoWWg== + dependencies: + "@jest/console" "^24.7.1" + "@jest/environment" "^24.9.0" + "@jest/test-result" "^24.9.0" + "@jest/types" "^24.9.0" + chalk "^2.4.2" + exit "^0.1.2" + graceful-fs "^4.1.15" + jest-config "^24.9.0" + jest-docblock "^24.3.0" + jest-haste-map "^24.9.0" + jest-jasmine2 "^24.9.0" + jest-leak-detector "^24.9.0" + jest-message-util "^24.9.0" + jest-resolve "^24.9.0" + jest-runtime "^24.9.0" + jest-util "^24.9.0" + jest-worker "^24.6.0" + source-map-support "^0.5.6" + throat "^4.0.0" + +jest-runtime@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-24.9.0.tgz#9f14583af6a4f7314a6a9d9f0226e1a781c8e4ac" + integrity sha512-8oNqgnmF3v2J6PVRM2Jfuj8oX3syKmaynlDMMKQ4iyzbQzIG6th5ub/lM2bCMTmoTKM3ykcUYI2Pw9xwNtjMnw== + dependencies: + "@jest/console" "^24.7.1" + "@jest/environment" "^24.9.0" + "@jest/source-map" "^24.3.0" + "@jest/transform" "^24.9.0" + "@jest/types" "^24.9.0" + "@types/yargs" "^13.0.0" + chalk "^2.0.1" + exit "^0.1.2" + glob "^7.1.3" + graceful-fs "^4.1.15" + jest-config "^24.9.0" + jest-haste-map "^24.9.0" + jest-message-util "^24.9.0" + jest-mock "^24.9.0" + jest-regex-util "^24.3.0" + jest-resolve "^24.9.0" + jest-snapshot "^24.9.0" + jest-util "^24.9.0" + jest-validate "^24.9.0" + realpath-native "^1.1.0" + slash "^2.0.0" + strip-bom "^3.0.0" + yargs "^13.3.0" + +jest-serializer@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-24.9.0.tgz#e6d7d7ef96d31e8b9079a714754c5d5c58288e73" + integrity sha512-DxYipDr8OvfrKH3Kel6NdED3OXxjvxXZ1uIY2I9OFbGg+vUkkg7AGvi65qbhbWNPvDckXmzMPbK3u3HaDO49bQ== + +jest-snapshot@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-24.9.0.tgz#ec8e9ca4f2ec0c5c87ae8f925cf97497b0e951ba" + integrity sha512-uI/rszGSs73xCM0l+up7O7a40o90cnrk429LOiK3aeTvfC0HHmldbd81/B7Ix81KSFe1lwkbl7GnBGG4UfuDew== + dependencies: + "@babel/types" "^7.0.0" + "@jest/types" "^24.9.0" + chalk "^2.0.1" + expect "^24.9.0" + jest-diff "^24.9.0" + jest-get-type "^24.9.0" + jest-matcher-utils "^24.9.0" + jest-message-util "^24.9.0" + jest-resolve "^24.9.0" + mkdirp "^0.5.1" + natural-compare "^1.4.0" + pretty-format "^24.9.0" + semver "^6.2.0" + +jest-util@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-24.9.0.tgz#7396814e48536d2e85a37de3e4c431d7cb140162" + integrity sha512-x+cZU8VRmOJxbA1K5oDBdxQmdq0OIdADarLxk0Mq+3XS4jgvhG/oKGWcIDCtPG0HgjxOYvF+ilPJQsAyXfbNOg== + dependencies: + "@jest/console" "^24.9.0" + "@jest/fake-timers" "^24.9.0" + "@jest/source-map" "^24.9.0" + "@jest/test-result" "^24.9.0" + "@jest/types" "^24.9.0" + callsites "^3.0.0" + chalk "^2.0.1" + graceful-fs "^4.1.15" + is-ci "^2.0.0" + mkdirp "^0.5.1" + slash "^2.0.0" + source-map "^0.6.0" + +jest-validate@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-24.9.0.tgz#0775c55360d173cd854e40180756d4ff52def8ab" + integrity sha512-HPIt6C5ACwiqSiwi+OfSSHbK8sG7akG8eATl+IPKaeIjtPOeBUd/g3J7DghugzxrGjI93qS/+RPKe1H6PqvhRQ== + dependencies: + "@jest/types" "^24.9.0" + camelcase "^5.3.1" + chalk "^2.0.1" + jest-get-type "^24.9.0" + leven "^3.1.0" + pretty-format "^24.9.0" + +jest-watcher@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-24.9.0.tgz#4b56e5d1ceff005f5b88e528dc9afc8dd4ed2b3b" + integrity sha512-+/fLOfKPXXYJDYlks62/4R4GoT+GU1tYZed99JSCOsmzkkF7727RqKrjNAxtfO4YpGv11wybgRvCjR73lK2GZw== + dependencies: + "@jest/test-result" "^24.9.0" + "@jest/types" "^24.9.0" + "@types/yargs" "^13.0.0" + ansi-escapes "^3.0.0" + chalk "^2.0.1" + jest-util "^24.9.0" + string-length "^2.0.0" + +jest-worker@^24.6.0, jest-worker@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-24.9.0.tgz#5dbfdb5b2d322e98567898238a9697bcce67b3e5" + integrity sha512-51PE4haMSXcHohnSMdM42anbvZANYTqMrr52tVKPqqsPJMzoP6FYYDVqahX/HrAoKEKz3uUPzSvKs9A3qR4iVw== + dependencies: + merge-stream "^2.0.0" + supports-color "^6.1.0" + +jest@^24.6.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest/-/jest-24.9.0.tgz#987d290c05a08b52c56188c1002e368edb007171" + integrity sha512-YvkBL1Zm7d2B1+h5fHEOdyjCG+sGMz4f8D86/0HiqJ6MB4MnDc8FgP5vdWsGnemOQro7lnYo8UakZ3+5A0jxGw== + dependencies: + import-local "^2.0.0" + jest-cli "^24.9.0" + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= + +jsdom@20.0.0: + version "20.0.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-20.0.0.tgz#882825ac9cc5e5bbee704ba16143e1fa78361ebf" + integrity sha512-x4a6CKCgx00uCmP+QakBDFXwjAJ69IkkIWHmtmjd3wvXPcdOS44hfX2vqkOQrVrq8l9DhNNADZRXaCEWvgXtVA== + dependencies: + abab "^2.0.6" + acorn "^8.7.1" + acorn-globals "^6.0.0" + cssom "^0.5.0" + cssstyle "^2.3.0" + data-urls "^3.0.2" + decimal.js "^10.3.1" + domexception "^4.0.0" + escodegen "^2.0.0" + form-data "^4.0.0" + html-encoding-sniffer "^3.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.1" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.0" + parse5 "^7.0.0" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^4.0.0" + w3c-hr-time "^1.0.2" + w3c-xmlserializer "^3.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^2.0.0" + whatwg-mimetype "^3.0.0" + whatwg-url "^11.0.0" + ws "^8.8.0" + xml-name-validator "^4.0.0" + +jsdom@^11.5.1: + version "11.12.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-11.12.0.tgz#1a80d40ddd378a1de59656e9e6dc5a3ba8657bc8" + integrity sha512-y8Px43oyiBM13Zc1z780FrfNLJCXTL40EWlty/LXUtcjykRBNgLlCjWXpfSPBl2iv+N7koQN+dvqszHZgT/Fjw== + dependencies: + abab "^2.0.0" + acorn "^5.5.3" + acorn-globals "^4.1.0" + array-equal "^1.0.0" + cssom ">= 0.3.2 < 0.4.0" + cssstyle "^1.0.0" + data-urls "^1.0.0" + domexception "^1.0.1" + escodegen "^1.9.1" + html-encoding-sniffer "^1.0.2" + left-pad "^1.3.0" + nwsapi "^2.0.7" + parse5 "4.0.0" + pn "^1.1.0" + request "^2.87.0" + request-promise-native "^1.0.5" + sax "^1.2.4" + symbol-tree "^3.2.2" + tough-cookie "^2.3.4" + w3c-hr-time "^1.0.1" + webidl-conversions "^4.0.2" + whatwg-encoding "^1.0.3" + whatwg-mimetype "^2.1.0" + whatwg-url "^6.4.1" + ws "^5.2.0" + xml-name-validator "^3.0.0" + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + +json-parse-better-errors@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" + integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema@0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" + integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= + +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= + +json5@^2.1.2: + version "2.1.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" + integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA== + dependencies: + minimist "^1.2.5" + +jsprim@^1.2.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" + integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.2.3" + verror "1.10.0" + +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= + dependencies: + is-buffer "^1.1.5" + +kind-of@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" + integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== + +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" + integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + +left-pad@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" + integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== + +leven@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" + integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + +levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + +load-json-file@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b" + integrity sha1-L19Fq5HjMhYjT9U62rZo607AmTs= + dependencies: + graceful-fs "^4.1.2" + parse-json "^4.0.0" + pify "^3.0.0" + strip-bom "^3.0.0" + +locate-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" + integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== + dependencies: + p-locate "^3.0.0" + path-exists "^3.0.0" + +lodash.escape@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.escape/-/lodash.escape-4.0.1.tgz#c9044690c21e04294beaa517712fded1fa88de98" + integrity sha1-yQRGkMIeBClL6qUXcS/e0fqI3pg= + +lodash.flattendeep@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" + integrity sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI= + +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= + +lodash.sortby@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" + integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= + +lodash@^4.15.0, lodash@^4.17.15, lodash@^4.17.19: + version "4.17.20" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== + +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +make-dir@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" + integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA== + dependencies: + pify "^4.0.1" + semver "^5.6.0" + +makeerror@1.0.x: + version "1.0.11" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" + integrity sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw= + dependencies: + tmpl "1.0.x" + +map-cache@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" + integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= + +map-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" + integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= + dependencies: + object-visit "^1.0.0" + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +micromatch@^3.1.10, micromatch@^3.1.4: + version "3.1.10" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" + integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.1" + define-property "^2.0.2" + extend-shallow "^3.0.2" + extglob "^2.0.4" + fragment-cache "^0.2.1" + kind-of "^6.0.2" + nanomatch "^1.2.9" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.2" + +mime-db@1.44.0: + version "1.44.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" + integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== + +mime-types@^2.1.12, mime-types@~2.1.19: + version "2.1.27" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" + integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== + dependencies: + mime-db "1.44.0" + +minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + +mixin-deep@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" + integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + +mkdirp@^0.5.1: + version "0.5.5" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" + integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== + dependencies: + minimist "^1.2.5" + +moo@^0.5.0: + version "0.5.1" + resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4" + integrity sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +nan@^2.12.1: + version "2.14.1" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" + integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== + +nanomatch@^1.2.9: + version "1.2.13" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" + integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^2.0.2" + extend-shallow "^3.0.2" + fragment-cache "^0.2.1" + is-windows "^1.0.2" + kind-of "^6.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= + +nearley@^2.7.10: + version "2.19.7" + resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.19.7.tgz#eafbe3e2d8ccfe70adaa5c026ab1f9709c116218" + integrity sha512-Y+KNwhBPcSJKeyQCFjn8B/MIe+DDlhaaDgjVldhy5xtFewIbiQgcbZV8k2gCVwkI1ZsKCnjIYZbR+0Fim5QYgg== + dependencies: + commander "^2.19.0" + moo "^0.5.0" + railroad-diagrams "^1.0.0" + randexp "0.4.6" + semver "^5.4.1" + +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== + +node-fetch@^1.0.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + integrity sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs= + +node-modules-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40" + integrity sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA= + +node-notifier@^5.4.2: + version "5.4.3" + resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-5.4.3.tgz#cb72daf94c93904098e28b9c590fd866e464bd50" + integrity sha512-M4UBGcs4jeOK9CjTsYwkvH6/MzuUmGCyTW+kCY7uO+1ZVr0+FHGdPdIf5CCLqAaxnRrWidyoQlNkMIIVwbKB8Q== + dependencies: + growly "^1.3.0" + is-wsl "^1.1.0" + semver "^5.5.0" + shellwords "^0.1.1" + which "^1.3.0" + +normalize-package-data@^2.3.2: + version "2.5.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" + integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== + dependencies: + hosted-git-info "^2.1.4" + resolve "^1.10.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + +normalize-path@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= + dependencies: + remove-trailing-separator "^1.0.1" + +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= + dependencies: + path-key "^2.0.0" + +nth-check@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" + integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg== + dependencies: + boolbase "~1.0.0" + +nwsapi@^2.0.7: + version "2.2.0" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7" + integrity sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ== + +nwsapi@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.1.tgz#10a9f268fbf4c461249ebcfe38e359aa36e2577c" + integrity sha512-JYOWTeFoS0Z93587vRJgASD5Ut11fYl5NyihP3KrYBvMe1FRRs6RN7m20SA/16GM4P6hTnZjT+UmDOt38UeXNg== + +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== + +object-assign@^4.1.0, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + +object-copy@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" + integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw= + dependencies: + copy-descriptor "^0.1.0" + define-property "^0.2.5" + kind-of "^3.0.3" + +object-inspect@^1.7.0, object-inspect@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0" + integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA== + +object-is@^1.0.2, object-is@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.3.tgz#2e3b9e65560137455ee3bd62aec4d90a2ea1cc81" + integrity sha512-teyqLvFWzLkq5B9ki8FVWA902UER2qkxmdA4nLf+wjOLAWgxzCWZNCxpDq9MvE8MmhWNr+I8w3BN49Vx36Y6Xg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.18.0-next.1" + +object-keys@^1.0.12, object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object-visit@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" + integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= + dependencies: + isobject "^3.0.0" + +object.assign@^4.1.0, object.assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.1.tgz#303867a666cdd41936ecdedfb1f8f3e32a478cdd" + integrity sha512-VT/cxmx5yaoHSOTSyrCygIDFco+RsibY2NM0a4RdEeY/4KgqezwFtK1yr3U67xYhqJSlASm2pKhLVzPj2lr4bA== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.18.0-next.0" + has-symbols "^1.0.1" + object-keys "^1.1.1" + +object.entries@^1.1.1, object.entries@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.2.tgz#bc73f00acb6b6bb16c203434b10f9a7e797d3add" + integrity sha512-BQdB9qKmb/HyNdMNWVr7O3+z5MUIx3aiegEIJqjMBbBf0YT9RRxTJSim4mzFqtyr7PDAHigq0N9dO0m0tRakQA== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + has "^1.0.3" + +object.fromentries@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.2.tgz#4a09c9b9bb3843dd0f89acdb517a794d4f355ac9" + integrity sha512-r3ZiBH7MQppDJVLx6fhD618GKNG40CZYH9wgwdhKxBDDbQgjeWGGd4AtkZad84d291YxvWe7bJGuE65Anh0dxQ== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + function-bind "^1.1.1" + has "^1.0.3" + +object.getownpropertydescriptors@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649" + integrity sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + +object.pick@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= + dependencies: + isobject "^3.0.1" + +object.values@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.1.tgz#68a99ecde356b7e9295a3c5e0ce31dc8c953de5e" + integrity sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + function-bind "^1.1.1" + has "^1.0.3" + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +optionator@^0.8.1: + version "0.8.3" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" + integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.6" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + word-wrap "~1.2.3" + +p-each-series@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-1.0.0.tgz#930f3d12dd1f50e7434457a22cd6f04ac6ad7f71" + integrity sha1-kw89Et0fUOdDRFeiLNbwSsatf3E= + dependencies: + p-reduce "^1.0.0" + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= + +p-limit@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-locate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" + integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== + dependencies: + p-limit "^2.0.0" + +p-reduce@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-1.0.0.tgz#18c2b0dd936a4690a529f8231f58a0fdb6a47dfa" + integrity sha1-GMKw3ZNqRpClKfgjH1ig/bakffo= + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +parse-json@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" + integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA= + dependencies: + error-ex "^1.3.1" + json-parse-better-errors "^1.0.1" + +parse5@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608" + integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA== + +parse5@^3.0.1: + version "3.0.3" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-3.0.3.tgz#042f792ffdd36851551cf4e9e066b3874ab45b5c" + integrity sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA== + dependencies: + "@types/node" "*" + +parse5@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.0.0.tgz#51f74a5257f5fcc536389e8c2d0b3802e1bfa91a" + integrity sha512-y/t8IXSPWTuRZqXc0ajH/UwDj4mnqLEbSttNbThcFhGrZuOyoyvNBO85PBp2jQa55wY9d07PBNjsK8ZP3K5U6g== + dependencies: + entities "^4.3.0" + +pascalcase@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" + integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-key@^2.0.0, path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= + +path-parse@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" + integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + +path-type@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" + integrity sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg== + dependencies: + pify "^3.0.0" + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= + +pify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" + integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= + +pify@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" + integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== + +pirates@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.1.tgz#643a92caf894566f91b2b986d2c66950a8e2fb87" + integrity sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA== + dependencies: + node-modules-regexp "^1.0.0" + +pkg-dir@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" + integrity sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw== + dependencies: + find-up "^3.0.0" + +pn@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" + integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA== + +posix-character-classes@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" + integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= + +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= + +pretty-format@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.9.0.tgz#12fac31b37019a4eea3c11aa9a959eb7628aa7c9" + integrity sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA== + dependencies: + "@jest/types" "^24.9.0" + ansi-regex "^4.0.0" + ansi-styles "^3.2.0" + react-is "^16.8.4" + +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== + dependencies: + asap "~2.0.3" + +prompts@^2.0.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.3.2.tgz#480572d89ecf39566d2bd3fe2c9fccb7c4c0b068" + integrity sha512-Q06uKs2CkNYVID0VqwfAl9mipo99zkBv/n2JtWY89Yxa3ZabWSrs0e2KTudKVa3peLUvYXMefDqIleLPVUBZMA== + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.4" + +prop-types-exact@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/prop-types-exact/-/prop-types-exact-1.2.0.tgz#825d6be46094663848237e3925a98c6e944e9869" + integrity sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA== + dependencies: + has "^1.0.3" + object.assign "^4.1.0" + reflect.ownkeys "^0.2.0" + +prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2: + version "15.7.2" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" + integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.8.1" + +psl@^1.1.28: + version "1.8.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" + integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== + +psl@^1.1.33: + version "1.9.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" + integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +punycode@^2.1.0, punycode@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== + +qs@~6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== + +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + +raf@^3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" + integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== + dependencies: + performance-now "^2.1.0" + +railroad-diagrams@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e" + integrity sha1-635iZ1SN3t+4mcG5Dlc3RVnN234= + +randexp@0.4.6: + version "0.4.6" + resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3" + integrity sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ== + dependencies: + discontinuous-range "1.0.0" + ret "~0.1.10" + +react-dom-factories@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/react-dom-factories/-/react-dom-factories-1.0.2.tgz#eb7705c4db36fb501b3aa38ff759616aa0ff96e0" + integrity sha1-63cFxNs2+1AbOqOP91lhaqD/luA= + +react-dom@16.4.1: + version "16.4.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.4.1.tgz#7f8b0223b3a5fbe205116c56deb85de32685dad6" + integrity sha512-1Gin+wghF/7gl4Cqcvr1DxFX2Osz7ugxSwl6gBqCMpdrxHjIFUS7GYxrFftZ9Ln44FHw0JxCFD9YtZsrbR5/4A== + dependencies: + fbjs "^0.8.16" + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.0" + +react-is@^16.12.0, react-is@^16.13.1, react-is@^16.4.1, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + +react-test-renderer@16.4.1: + version "16.4.1" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.4.1.tgz#f2fb30c2c7b517db6e5b10ed20bb6b0a7ccd8d70" + integrity sha512-wyyiPxRZOTpKnNIgUBOB6xPLTpIzwcQMIURhZvzUqZzezvHjaGNsDPBhMac5fIY3Jf5NuKxoGvV64zDSOECPPQ== + dependencies: + fbjs "^0.8.16" + object-assign "^4.1.1" + prop-types "^15.6.0" + react-is "^16.4.1" + +react-test-renderer@^16.0.0-0: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1" + integrity sha512-Sn2VRyOK2YJJldOqoh8Tn/lWQ+ZiKhyZTPtaO0Q6yNj+QDbmRkVFap6pZPy3YQk8DScRDfyqm/KxKYP9gCMRiQ== + dependencies: + object-assign "^4.1.1" + prop-types "^15.6.2" + react-is "^16.8.6" + scheduler "^0.19.1" + +react@16.4.1: + version "16.4.1" + resolved "https://registry.yarnpkg.com/react/-/react-16.4.1.tgz#de51ba5764b5dbcd1f9079037b862bd26b82fe32" + integrity sha512-3GEs0giKp6E0Oh/Y9ZC60CmYgUPnp7voH9fbjWsvXtYFb4EWtgQub0ADSq0sJR0BbHc4FThLLtzlcFaFXIorwg== + dependencies: + fbjs "^0.8.16" + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.0" + +read-pkg-up@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-4.0.0.tgz#1b221c6088ba7799601c808f91161c66e58f8978" + integrity sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA== + dependencies: + find-up "^3.0.0" + read-pkg "^3.0.0" + +read-pkg@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389" + integrity sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k= + dependencies: + load-json-file "^4.0.0" + normalize-package-data "^2.3.2" + path-type "^3.0.0" + +readable-stream@^3.1.1: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +realpath-native@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c" + integrity sha512-wlgPA6cCIIg9gKz0fgAPjnzh4yR/LnXovwuo9hvyGvx3h8nX4+/iLZplfUWasXpqD8BdnGnP5njOFjkUwPzvjA== + dependencies: + util.promisify "^1.0.0" + +reflect.ownkeys@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460" + integrity sha1-dJrO7H8/34tj+SegSAnpDFwLNGA= + +regex-not@^1.0.0, regex-not@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" + integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== + dependencies: + extend-shallow "^3.0.2" + safe-regex "^1.1.0" + +remove-trailing-separator@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= + +repeat-element@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" + integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== + +repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= + +request-promise-core@1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.4.tgz#3eedd4223208d419867b78ce815167d10593a22f" + integrity sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw== + dependencies: + lodash "^4.17.19" + +request-promise-native@^1.0.5: + version "1.0.9" + resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.9.tgz#e407120526a5efdc9a39b28a5679bf47b9d9dc28" + integrity sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g== + dependencies: + request-promise-core "1.1.4" + stealthy-require "^1.1.1" + tough-cookie "^2.3.3" + +request@^2.87.0: + version "2.88.2" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" + integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.3" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.5.0" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + +resolve-cwd@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" + integrity sha1-AKn3OHVW4nA46uIyyqNypqWbZlo= + dependencies: + resolve-from "^3.0.0" + +resolve-from@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" + integrity sha1-six699nWiBvItuZTM17rywoYh0g= + +resolve-url@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" + integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= + +resolve@1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" + integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= + +resolve@^1.10.0, resolve@^1.3.2: + version "1.17.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" + integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== + dependencies: + path-parse "^1.0.6" + +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== + +rimraf@^2.5.4, rimraf@^2.6.3: + version "2.7.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" + integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== + dependencies: + glob "^7.1.3" + +rst-selector-parser@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz#81b230ea2fcc6066c89e3472de794285d9b03d91" + integrity sha1-gbIw6i/MYGbInjRy3nlChdmwPZE= + dependencies: + lodash.flattendeep "^4.4.0" + nearley "^2.7.10" + +rsvp@^4.8.4: + version "4.8.5" + resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" + integrity sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA== + +safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" + integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4= + dependencies: + ret "~0.1.10" + +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sane@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/sane/-/sane-4.1.0.tgz#ed881fd922733a6c461bc189dc2b6c006f3ffded" + integrity sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA== + dependencies: + "@cnakazawa/watch" "^1.0.3" + anymatch "^2.0.0" + capture-exit "^2.0.0" + exec-sh "^0.3.2" + execa "^1.0.0" + fb-watchman "^2.0.0" + micromatch "^3.1.4" + minimist "^1.1.1" + walker "~1.0.5" + +sax@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== + +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + +scheduler@^0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" + integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + +"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + +semver@^6.0.0, semver@^6.2.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= + +set-value@^2.0.0, set-value@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" + integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw== + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= + +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= + dependencies: + shebang-regex "^1.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= + +shellwords@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" + integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== + +signal-exit@^3.0.0, signal-exit@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" + integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== + +sisteransi@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + +slash@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" + integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== + +snapdragon-node@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" + integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== + dependencies: + define-property "^1.0.0" + isobject "^3.0.0" + snapdragon-util "^3.0.1" + +snapdragon-util@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" + integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== + dependencies: + kind-of "^3.2.0" + +snapdragon@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" + integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== + dependencies: + base "^0.11.1" + debug "^2.2.0" + define-property "^0.2.5" + extend-shallow "^2.0.1" + map-cache "^0.2.2" + source-map "^0.5.6" + source-map-resolve "^0.5.0" + use "^3.1.0" + +source-map-resolve@^0.5.0: + version "0.5.3" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" + integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw== + dependencies: + atob "^2.1.2" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + +source-map-support@^0.5.6: + version "0.5.19" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" + integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map-url@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" + integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= + +source-map@^0.5.0, source-map@^0.5.6: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= + +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +spdx-correct@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" + integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w== + dependencies: + spdx-expression-parse "^3.0.0" + spdx-license-ids "^3.0.0" + +spdx-exceptions@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" + integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== + +spdx-expression-parse@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" + integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.6" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.6.tgz#c80757383c28abf7296744998cbc106ae8b854ce" + integrity sha512-+orQK83kyMva3WyPf59k1+Y525csj5JejicWut55zeTWANuN17qSiSLUXWtzHeNWORSvT7GLDJ/E/XiIWoXBTw== + +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" + integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== + dependencies: + extend-shallow "^3.0.0" + +sshpk@^1.7.0: + version "1.16.1" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" + integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + bcrypt-pbkdf "^1.0.0" + dashdash "^1.12.0" + ecc-jsbn "~0.1.1" + getpass "^0.1.1" + jsbn "~0.1.0" + safer-buffer "^2.0.2" + tweetnacl "~0.14.0" + +stack-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.2.tgz#33eba3897788558bebfc2db059dc158ec36cebb8" + integrity sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA== + +static-extend@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" + integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= + dependencies: + define-property "^0.2.5" + object-copy "^0.1.0" + +stealthy-require@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" + integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= + +string-length@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed" + integrity sha1-1A27aGo6zpYMHP/KVivyxF+DY+0= + dependencies: + astral-regex "^1.0.0" + strip-ansi "^4.0.0" + +string-width@^3.0.0, string-width@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + +string.prototype.trim@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.2.tgz#f538d0bacd98fc4297f0bef645226d5aaebf59f3" + integrity sha512-b5yrbl3BXIjHau9Prk7U0RRYcUYdN4wGSVaqoBQS50CCE3KBuYU0TYRNPFCP7aVoNMX87HKThdMRVIP3giclKg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.18.0-next.0" + +string.prototype.trimend@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913" + integrity sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + +string.prototype.trimstart@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54" + integrity sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= + dependencies: + ansi-regex "^3.0.0" + +strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + dependencies: + ansi-regex "^4.1.0" + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= + +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" + integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== + dependencies: + has-flag "^3.0.0" + +symbol-tree@^3.2.2, symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + +test-exclude@^5.2.3: + version "5.2.3" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.2.3.tgz#c3d3e1e311eb7ee405e092dac10aefd09091eac0" + integrity sha512-M+oxtseCFO3EDtAaGH7iiej3CBkzXqFMbzqYAACdzKui4eZA+pq3tZEwChvOdNfa7xxy8BfbmgJSIr43cC/+2g== + dependencies: + glob "^7.1.3" + minimatch "^3.0.4" + read-pkg-up "^4.0.0" + require-main-filename "^2.0.0" + +throat@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a" + integrity sha1-iQN8vJLFarGJJua6TLsgDhVnKmo= + +tmpl@1.0.x: + version "1.0.4" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" + integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= + +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= + dependencies: + kind-of "^3.0.2" + +to-regex-range@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" + integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= + dependencies: + is-number "^3.0.0" + repeat-string "^1.6.1" + +to-regex@^3.0.1, to-regex@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" + integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== + dependencies: + define-property "^2.0.2" + extend-shallow "^3.0.2" + regex-not "^1.0.2" + safe-regex "^1.1.0" + +tough-cookie@^2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" + integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== + dependencies: + psl "^1.1.28" + punycode "^2.1.1" + +tough-cookie@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.2.tgz#e53e84b85f24e0b65dd526f46628db6c85f6b874" + integrity sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" + +tr46@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" + integrity sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk= + dependencies: + punycode "^2.1.0" + +tr46@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9" + integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA== + dependencies: + punycode "^2.1.1" + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= + dependencies: + safe-buffer "^5.0.1" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= + dependencies: + prelude-ls "~1.1.2" + +ua-parser-js@^0.7.18: + version "0.7.22" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.22.tgz#960df60a5f911ea8f1c818f3747b99c6e177eae3" + integrity sha512-YUxzMjJ5T71w6a8WWVcMGM6YWOTX27rCoIQgLXiWaxqXSx9D7DNjiGWn1aJIRSQ5qr0xuhra77bSIh6voR/46Q== + +union-value@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" + integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg== + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^2.0.1" + +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + +unset-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" + integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= + dependencies: + has-value "^0.3.1" + isobject "^3.0.0" + +uri-js@^4.2.2: + version "4.4.0" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.0.tgz#aa714261de793e8a82347a7bcc9ce74e86f28602" + integrity sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g== + dependencies: + punycode "^2.1.0" + +urix@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" + integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= + +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + +use@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" + integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== + +util-deprecate@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + +util.promisify@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.1.tgz#6baf7774b80eeb0f7520d8b81d07982a59abbaee" + integrity sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.2" + has-symbols "^1.0.1" + object.getownpropertydescriptors "^2.1.0" + +uuid@^3.3.2: + version "3.4.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + +validate-npm-package-license@^3.0.1: + version "3.0.4" + resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" + integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== + dependencies: + spdx-correct "^3.0.0" + spdx-expression-parse "^3.0.0" + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +w3c-hr-time@^1.0.1, w3c-hr-time@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" + integrity sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ== + dependencies: + browser-process-hrtime "^1.0.0" + +w3c-xmlserializer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz#06cdc3eefb7e4d0b20a560a5a3aeb0d2d9a65923" + integrity sha512-3WFqGEgSXIyGhOmAFtlicJNMjEps8b1MG31NCA0/vOF9+nKMUW1ckhi9cnNHmf88Rzw5V+dwIwsm2C7X8k9aQg== + dependencies: + xml-name-validator "^4.0.0" + +walker@^1.0.7, walker@~1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb" + integrity sha1-L3+bj9ENZ3JisYqITijRlhjgKPs= + dependencies: + makeerror "1.0.x" + +webidl-conversions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" + integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== + +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + +whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3: + version "1.0.5" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" + integrity sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw== + dependencies: + iconv-lite "0.4.24" + +whatwg-encoding@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53" + integrity sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg== + dependencies: + iconv-lite "0.6.3" + +whatwg-fetch@>=0.10.0: + version "3.4.1" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.4.1.tgz#e5f871572d6879663fa5674c8f833f15a8425ab3" + integrity sha512-sofZVzE1wKwO+EYPbWfiwzaKovWiZXf4coEzjGP9b2GBVgQRLQUZ2QcuPpQExGDAW5GItpEm6Tl4OU5mywnAoQ== + +whatwg-mimetype@^2.1.0, whatwg-mimetype@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" + integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== + +whatwg-mimetype@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" + integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== + +whatwg-url@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018" + integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ== + dependencies: + tr46 "^3.0.0" + webidl-conversions "^7.0.0" + +whatwg-url@^6.4.1: + version "6.5.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8" + integrity sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + +whatwg-url@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" + integrity sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + +which-module@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" + integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= + +which@^1.2.9, which@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +word-wrap@~1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" + integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + +wrap-ansi@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" + integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== + dependencies: + ansi-styles "^3.2.0" + string-width "^3.0.0" + strip-ansi "^5.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +write-file-atomic@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.1.tgz#d0b05463c188ae804396fd5ab2a370062af87529" + integrity sha512-TGHFeZEZMnv+gBFRfjAcxL5bPHrsGKtnb4qsFAws7/vlh+QfwAaySIw4AXP9ZskTTh5GWu3FLuJhsWVdiJPGvg== + dependencies: + graceful-fs "^4.1.11" + imurmurhash "^0.1.4" + signal-exit "^3.0.2" + +ws@^5.2.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-5.2.2.tgz#dffef14866b8e8dc9133582514d1befaf96e980f" + integrity sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA== + dependencies: + async-limiter "~1.0.0" + +ws@^8.8.0: + version "8.8.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.8.1.tgz#5dbad0feb7ade8ecc99b830c1d77c913d4955ff0" + integrity sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA== + +xml-name-validator@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" + integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== + +xml-name-validator@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" + integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw== + +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + +y18n@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" + integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== + +yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs@^13.3.0: + version "13.3.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.2" diff --git a/devtools/client/shared/components/throttling/NetworkThrottlingMenu.js b/devtools/client/shared/components/throttling/NetworkThrottlingMenu.js new file mode 100644 index 0000000000..d74016686b --- /dev/null +++ b/devtools/client/shared/components/throttling/NetworkThrottlingMenu.js @@ -0,0 +1,100 @@ +/* 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 { + 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 throttlingProfiles = require("resource://devtools/client/shared/components/throttling/profiles.js"); +const Types = require("resource://devtools/client/shared/components/throttling/types.js"); + +// Localization +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/network-throttling.properties" +); +const NO_THROTTLING_LABEL = L10N.getStr("responsive.noThrottling"); + +loader.lazyRequireGetter( + this, + "showMenu", + "resource://devtools/client/shared/components/menu/utils.js", + true +); + +/** + * This component represents selector button that can be used + * to throttle network bandwidth. + */ +class NetworkThrottlingMenu extends PureComponent { + static get propTypes() { + return { + networkThrottling: PropTypes.shape(Types.networkThrottling).isRequired, + onChangeNetworkThrottling: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + this.onShowThrottlingMenu = this.onShowThrottlingMenu.bind(this); + } + + onShowThrottlingMenu(event) { + const { networkThrottling, onChangeNetworkThrottling } = this.props; + + const menuItems = throttlingProfiles.profiles.map(profile => { + return { + label: profile.id, + type: "checkbox", + checked: + networkThrottling.enabled && profile.id == networkThrottling.profile, + click: () => onChangeNetworkThrottling(true, profile.id), + }; + }); + + menuItems.unshift("-"); + + menuItems.unshift({ + label: NO_THROTTLING_LABEL, + type: "checkbox", + checked: !networkThrottling.enabled, + click: () => onChangeNetworkThrottling(false, ""), + }); + + showMenu(menuItems, { button: event.target }); + } + + render() { + const { networkThrottling } = this.props; + const label = networkThrottling.enabled + ? networkThrottling.profile + : NO_THROTTLING_LABEL; + + let title = NO_THROTTLING_LABEL; + + if (networkThrottling.enabled) { + const id = networkThrottling.profile; + const selectedProfile = throttlingProfiles.profiles.find( + profile => profile.id === id + ); + title = selectedProfile.description; + } + + return dom.button( + { + id: "network-throttling-menu", + className: "devtools-button devtools-dropdown-button", + title, + onClick: this.onShowThrottlingMenu, + }, + dom.span({ className: "title" }, label) + ); + } +} + +module.exports = NetworkThrottlingMenu; diff --git a/devtools/client/shared/components/throttling/actions.js b/devtools/client/shared/components/throttling/actions.js new file mode 100644 index 0000000000..b6a686ab22 --- /dev/null +++ b/devtools/client/shared/components/throttling/actions.js @@ -0,0 +1,22 @@ +/* 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 actionTypes = { + CHANGE_NETWORK_THROTTLING: "CHANGE_NETWORK_THROTTLING", +}; + +function changeNetworkThrottling(enabled, profile) { + return { + type: actionTypes.CHANGE_NETWORK_THROTTLING, + enabled, + profile, + }; +} + +module.exports = { + ...actionTypes, + changeNetworkThrottling, +}; diff --git a/devtools/client/shared/components/throttling/moz.build b/devtools/client/shared/components/throttling/moz.build new file mode 100644 index 0000000000..2c178219fc --- /dev/null +++ b/devtools/client/shared/components/throttling/moz.build @@ -0,0 +1,13 @@ +# -*- 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( + "actions.js", + "NetworkThrottlingMenu.js", + "profiles.js", + "reducer.js", + "types.js", +) diff --git a/devtools/client/shared/components/throttling/profiles.js b/devtools/client/shared/components/throttling/profiles.js new file mode 100644 index 0000000000..cd61f70772 --- /dev/null +++ b/devtools/client/shared/components/throttling/profiles.js @@ -0,0 +1,122 @@ +/* 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 K = 1024; +const M = 1024 * 1024; +const Bps = 1 / 8; +const KBps = K * Bps; +const MBps = M * Bps; + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/network-throttling.properties" +); + +/** + * Predefined network throttling profiles. + * Speeds are in bytes per second. Latency is in ms. + */ + +class ThrottlingProfile { + constructor({ id, download, upload, latency }) { + this.id = id; + this.download = download; + this.upload = upload; + this.latency = latency; + } + + get description() { + const download = this.#toDescriptionData(this.download); + const upload = this.#toDescriptionData(this.upload); + return L10N.getFormatStr( + "throttling.profile.description", + download.value, + download.unit, + upload.value, + upload.unit, + this.latency + ); + } + + #toDescriptionData(val) { + if (val % MBps === 0) { + return { value: val / MBps, unit: "Mbps" }; + } + return { value: val / KBps, unit: "Kbps" }; + } +} + +const PROFILE_CONSTANTS = { + GPRS: "GPRS", + REGULAR_2G: "Regular 2G", + GOOD_2G: "Good 2G", + REGULAR_3G: "Regular 3G", + GOOD_3G: "Good 3G", + REGULAR_4G_LTE: "Regular 4G / LTE", + DSL: "DSL", + WIFI: "Wi-Fi", + OFFLINE: "Offline", +}; + +// Should be synced with devtools/docs/user/network_monitor/throttling/index.rst +const profiles = [ + { + id: PROFILE_CONSTANTS.GPRS, + download: 50 * KBps, + upload: 20 * KBps, + latency: 500, + }, + { + id: PROFILE_CONSTANTS.REGULAR_2G, + download: 250 * KBps, + upload: 50 * KBps, + latency: 300, + }, + { + id: PROFILE_CONSTANTS.GOOD_2G, + download: 450 * KBps, + upload: 150 * KBps, + latency: 150, + }, + { + id: PROFILE_CONSTANTS.REGULAR_3G, + download: 750 * KBps, + upload: 250 * KBps, + latency: 100, + }, + { + id: PROFILE_CONSTANTS.GOOD_3G, + download: 1.5 * MBps, + upload: 750 * KBps, + latency: 40, + }, + { + id: PROFILE_CONSTANTS.REGULAR_4G_LTE, + download: 4 * MBps, + upload: 3 * MBps, + latency: 20, + }, + { + id: PROFILE_CONSTANTS.DSL, + download: 2 * MBps, + upload: 1 * MBps, + latency: 5, + }, + { + id: PROFILE_CONSTANTS.WIFI, + download: 30 * MBps, + upload: 15 * MBps, + latency: 2, + }, + { + id: PROFILE_CONSTANTS.OFFLINE, + download: 0, + upload: 0, + latency: 5, + }, +].map(profile => new ThrottlingProfile(profile)); + +module.exports = { profiles, PROFILE_CONSTANTS }; diff --git a/devtools/client/shared/components/throttling/reducer.js b/devtools/client/shared/components/throttling/reducer.js new file mode 100644 index 0000000000..ea6408fe9f --- /dev/null +++ b/devtools/client/shared/components/throttling/reducer.js @@ -0,0 +1,29 @@ +/* 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 { + CHANGE_NETWORK_THROTTLING, +} = require("resource://devtools/client/shared/components/throttling/actions.js"); + +const INITIAL_STATE = { + enabled: false, + profile: "", +}; + +function throttlingReducer(state = INITIAL_STATE, action) { + switch (action.type) { + case CHANGE_NETWORK_THROTTLING: { + return { + enabled: action.enabled, + profile: action.profile, + }; + } + default: + return state; + } +} + +module.exports = throttlingReducer; diff --git a/devtools/client/shared/components/throttling/types.js b/devtools/client/shared/components/throttling/types.js new file mode 100644 index 0000000000..a54797057d --- /dev/null +++ b/devtools/client/shared/components/throttling/types.js @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +/** + * Network throttling state. + */ +exports.networkThrottling = { + // Whether or not network throttling is enabled + enabled: PropTypes.bool, + // Name of the selected throttling profile + profile: PropTypes.string, +}; diff --git a/devtools/client/shared/components/tree/LabelCell.js b/devtools/client/shared/components/tree/LabelCell.js new file mode 100644 index 0000000000..e42a9dfd1c --- /dev/null +++ b/devtools/client/shared/components/tree/LabelCell.js @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + const { Component } = require("devtools/client/shared/vendor/react"); + const dom = require("devtools/client/shared/vendor/react-dom-factories"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + /** + * Render the default cell used for toggle buttons + */ + class LabelCell extends Component { + // See the TreeView component for details related + // to the 'member' object. + static get propTypes() { + return { + id: PropTypes.string.isRequired, + title: PropTypes.string, + member: PropTypes.object.isRequired, + renderSuffix: PropTypes.func, + }; + } + + render() { + const id = this.props.id; + const title = this.props.title; + const member = this.props.member; + const level = member.level || 0; + const renderSuffix = this.props.renderSuffix; + + const iconClassList = ["treeIcon"]; + if (member.hasChildren && member.loading) { + iconClassList.push("devtools-throbber"); + } else if (member.hasChildren) { + iconClassList.push("theme-twisty"); + } + if (member.open) { + iconClassList.push("open"); + } + + return dom.td( + { + className: "treeLabelCell", + title, + style: { + // Compute indentation dynamically. The deeper the item is + // inside the hierarchy, the bigger is the left padding. + "--tree-label-cell-indent": `${level * 16}px`, + }, + key: "default", + role: "presentation", + }, + dom.span({ + className: iconClassList.join(" "), + role: "presentation", + }), + dom.span( + { + className: "treeLabel " + member.type + "Label", + title, + "aria-labelledby": id, + "data-level": level, + }, + member.name + ), + renderSuffix && renderSuffix(member) + ); + } + } + + // Exports from this module + module.exports = LabelCell; +}); diff --git a/devtools/client/shared/components/tree/ObjectProvider.js b/devtools/client/shared/components/tree/ObjectProvider.js new file mode 100644 index 0000000000..48d577ff4d --- /dev/null +++ b/devtools/client/shared/components/tree/ObjectProvider.js @@ -0,0 +1,86 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + /** + * Implementation of the default data provider. A provider is state less + * object responsible for transformation data (usually a state) to + * a structure that can be directly consumed by the tree-view component. + */ + const ObjectProvider = { + getChildren(object) { + const children = []; + + if (object instanceof ObjectProperty) { + object = object.value; + } + + if (!object) { + return []; + } + + if (typeof object == "string") { + return []; + } + + for (const prop in object) { + try { + children.push(new ObjectProperty(prop, object[prop])); + } catch (e) { + console.error(e); + } + } + return children; + }, + + hasChildren(object) { + if (object instanceof ObjectProperty) { + object = object.value; + } + + if (!object) { + return false; + } + + if (typeof object == "string") { + return false; + } + + if (typeof object !== "object") { + return false; + } + + return !!Object.keys(object).length; + }, + + getLabel(object) { + return object instanceof ObjectProperty ? object.name : null; + }, + + getValue(object) { + return object instanceof ObjectProperty ? object.value : null; + }, + + getKey(object) { + return object instanceof ObjectProperty ? object.name : null; + }, + + getType(object) { + return object instanceof ObjectProperty + ? typeof object.value + : typeof object; + }, + }; + + function ObjectProperty(name, value) { + this.name = name; + this.value = value; + } + + // Exports from this module + exports.ObjectProperty = ObjectProperty; + exports.ObjectProvider = ObjectProvider; +}); diff --git a/devtools/client/shared/components/tree/TreeCell.js b/devtools/client/shared/components/tree/TreeCell.js new file mode 100644 index 0000000000..1df830431f --- /dev/null +++ b/devtools/client/shared/components/tree/TreeCell.js @@ -0,0 +1,139 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + const { Component } = require("devtools/client/shared/vendor/react"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const dom = require("devtools/client/shared/vendor/react-dom-factories"); + const { input, span, td } = dom; + + /** + * This template represents a cell in TreeView row. It's rendered + * using <td> element (the row is <tr> and the entire tree is <table>). + */ + class TreeCell extends Component { + // See TreeView component for detailed property explanation. + static get propTypes() { + return { + value: PropTypes.any, + decorator: PropTypes.object, + id: PropTypes.string.isRequired, + member: PropTypes.object.isRequired, + renderValue: PropTypes.func.isRequired, + enableInput: PropTypes.bool, + }; + } + + constructor(props) { + super(props); + + this.state = { + inputEnabled: false, + }; + + this.getCellClass = this.getCellClass.bind(this); + this.updateInputEnabled = this.updateInputEnabled.bind(this); + } + + /** + * Optimize cell rendering. Rerender cell content only if + * the value or expanded state changes. + */ + shouldComponentUpdate(nextProps, nextState) { + return ( + this.props.value !== nextProps.value || + this.state !== nextState || + this.props.member.open !== nextProps.member.open + ); + } + + getCellClass(object, id) { + const decorator = this.props.decorator; + if (!decorator || !decorator.getCellClass) { + return []; + } + + // Decorator can return a simple string or array of strings. + let classNames = decorator.getCellClass(object, id); + if (!classNames) { + return []; + } + + if (typeof classNames == "string") { + classNames = [classNames]; + } + + return classNames; + } + + updateInputEnabled(evt) { + this.setState( + Object.assign({}, this.state, { + inputEnabled: evt.target.nodeName.toLowerCase() !== "input", + }) + ); + } + + render() { + let { member, id, value, decorator, renderValue, enableInput } = + this.props; + const type = member.type || ""; + + // Compute class name list for the <td> element. + const classNames = this.getCellClass(member.object, id) || []; + classNames.push("treeValueCell"); + classNames.push(type + "Cell"); + + // Render value using a default render function or custom + // provided function from props or a decorator. + renderValue = renderValue || defaultRenderValue; + if (decorator?.renderValue) { + renderValue = decorator.renderValue(member.object, id) || renderValue; + } + + const props = Object.assign({}, this.props, { + object: value, + }); + + let cellElement; + if (enableInput && this.state.inputEnabled && type !== "object") { + classNames.push("inputEnabled"); + cellElement = input({ + autoFocus: true, + onBlur: this.updateInputEnabled, + readOnly: true, + value, + "aria-labelledby": id, + }); + } else { + cellElement = span( + { + onClick: type !== "object" ? this.updateInputEnabled : null, + "aria-labelledby": id, + }, + renderValue(props) + ); + } + + // Render me! + return td( + { + className: classNames.join(" "), + role: "presentation", + }, + cellElement + ); + } + } + + // Default value rendering. + const defaultRenderValue = props => { + return props.object + ""; + }; + + // Exports from this module + module.exports = TreeCell; +}); diff --git a/devtools/client/shared/components/tree/TreeHeader.js b/devtools/client/shared/components/tree/TreeHeader.js new file mode 100644 index 0000000000..41986210fc --- /dev/null +++ b/devtools/client/shared/components/tree/TreeHeader.js @@ -0,0 +1,120 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + const { Component } = require("devtools/client/shared/vendor/react"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const dom = require("devtools/client/shared/vendor/react-dom-factories"); + const { thead, tr, td, div } = dom; + + /** + * This component is responsible for rendering tree header. + * It's based on <thead> element. + */ + class TreeHeader extends Component { + // See also TreeView component for detailed info about properties. + static get propTypes() { + return { + // Custom tree decorator + decorator: PropTypes.object, + // True if the header should be visible + header: PropTypes.bool, + // Array with column definition + columns: PropTypes.array, + }; + } + + static get defaultProps() { + return { + columns: [ + { + id: "default", + }, + ], + }; + } + + constructor(props) { + super(props); + this.getHeaderClass = this.getHeaderClass.bind(this); + } + + getHeaderClass(colId) { + const decorator = this.props.decorator; + if (!decorator || !decorator.getHeaderClass) { + return []; + } + + // Decorator can return a simple string or array of strings. + let classNames = decorator.getHeaderClass(colId); + if (!classNames) { + return []; + } + + if (typeof classNames == "string") { + classNames = [classNames]; + } + + return classNames; + } + + render() { + const cells = []; + const visible = this.props.header; + + // Render the rest of the columns (if any) + this.props.columns.forEach(col => { + const cellStyle = { + width: col.width ? col.width : "", + }; + + let classNames = []; + + if (visible) { + classNames = this.getHeaderClass(col.id); + classNames.push("treeHeaderCell"); + } + + cells.push( + td( + { + className: classNames.join(" "), + style: cellStyle, + role: "presentation", + id: col.id, + key: col.id, + }, + visible + ? div( + { + className: "treeHeaderCellBox", + role: "presentation", + }, + col.title + ) + : null + ) + ); + }); + + return thead( + { + role: "presentation", + }, + tr( + { + className: visible ? "treeHeaderRow" : "", + role: "presentation", + }, + cells + ) + ); + } + } + + // Exports from this module + module.exports = TreeHeader; +}); diff --git a/devtools/client/shared/components/tree/TreeRow.js b/devtools/client/shared/components/tree/TreeRow.js new file mode 100644 index 0000000000..23fe0227f7 --- /dev/null +++ b/devtools/client/shared/components/tree/TreeRow.js @@ -0,0 +1,304 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + const { + Component, + createFactory, + createRef, + } = require("devtools/client/shared/vendor/react"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const dom = require("devtools/client/shared/vendor/react-dom-factories"); + const { findDOMNode } = require("devtools/client/shared/vendor/react-dom"); + const { tr } = dom; + + // Tree + const TreeCell = createFactory( + require("devtools/client/shared/components/tree/TreeCell") + ); + const LabelCell = createFactory( + require("devtools/client/shared/components/tree/LabelCell") + ); + + const { + wrapMoveFocus, + getFocusableElements, + } = require("devtools/client/shared/focus"); + + const UPDATE_ON_PROPS = [ + "name", + "open", + "value", + "loading", + "level", + "selected", + "active", + "hasChildren", + ]; + + /** + * This template represents a node in TreeView component. It's rendered + * using <tr> element (the entire tree is one big <table>). + */ + class TreeRow extends Component { + // See TreeView component for more details about the props and + // the 'member' object. + static get propTypes() { + return { + member: PropTypes.shape({ + object: PropTypes.object, + name: PropTypes.string, + type: PropTypes.string.isRequired, + rowClass: PropTypes.string.isRequired, + level: PropTypes.number.isRequired, + hasChildren: PropTypes.bool, + value: PropTypes.any, + open: PropTypes.bool.isRequired, + path: PropTypes.string.isRequired, + hidden: PropTypes.bool, + selected: PropTypes.bool, + active: PropTypes.bool, + loading: PropTypes.bool, + }), + decorator: PropTypes.object, + renderCell: PropTypes.func, + renderLabelCell: PropTypes.func, + columns: PropTypes.array.isRequired, + id: PropTypes.string.isRequired, + provider: PropTypes.object.isRequired, + onClick: PropTypes.func.isRequired, + onContextMenu: PropTypes.func, + onMouseOver: PropTypes.func, + onMouseOut: PropTypes.func, + }; + } + + constructor(props) { + super(props); + + this.treeRowRef = createRef(); + + this.getRowClass = this.getRowClass.bind(this); + this._onKeyDown = this._onKeyDown.bind(this); + } + + componentDidMount() { + this._setTabbableState(); + + // Child components might add/remove new focusable elements, watch for the + // additions/removals of descendant nodes and update focusable state. + const win = this.treeRowRef.current.ownerDocument.defaultView; + const { MutationObserver } = win; + this.observer = new MutationObserver(() => { + this._setTabbableState(); + }); + this.observer.observe(this.treeRowRef.current, { + childList: true, + subtree: true, + }); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + // I don't like accessing the underlying DOM elements directly, + // but this optimization makes the filtering so damn fast! + // The row doesn't have to be re-rendered, all we really need + // to do is toggling a class name. + // The important part is that DOM elements don't need to be + // re-created when they should appear again. + if (nextProps.member.hidden != this.props.member.hidden) { + const row = findDOMNode(this); + row.classList.toggle("hidden"); + } + } + + /** + * Optimize row rendering. If props are the same do not render. + * This makes the rendering a lot faster! + */ + shouldComponentUpdate(nextProps) { + for (const prop of UPDATE_ON_PROPS) { + if (nextProps.member[prop] !== this.props.member[prop]) { + return true; + } + } + + return false; + } + + componentWillUnmount() { + this.observer.disconnect(); + this.observer = null; + } + + /** + * Makes sure that none of the focusable elements inside the row container + * are tabbable if the row is not active. If the row is active and focus + * is outside its container, focus on the first focusable element inside. + */ + _setTabbableState() { + const elms = getFocusableElements(this.treeRowRef.current); + if (elms.length === 0) { + return; + } + + const { active } = this.props.member; + if (!active) { + elms.forEach(elm => elm.setAttribute("tabindex", "-1")); + return; + } + + if (!elms.includes(document.activeElement)) { + elms[0].focus(); + } + } + + _onKeyDown(e) { + const { target, key, shiftKey } = e; + + if (key !== "Tab") { + return; + } + + const focusMoved = !!wrapMoveFocus( + getFocusableElements(this.treeRowRef.current), + target, + shiftKey + ); + if (focusMoved) { + // Focus was moved to the begining/end of the list, so we need to + // prevent the default focus change that would happen here. + e.preventDefault(); + } + + e.stopPropagation(); + } + + getRowClass(object) { + const decorator = this.props.decorator; + if (!decorator || !decorator.getRowClass) { + return []; + } + + // Decorator can return a simple string or array of strings. + let classNames = decorator.getRowClass(object); + if (!classNames) { + return []; + } + + if (typeof classNames == "string") { + classNames = [classNames]; + } + + return classNames; + } + + render() { + const member = this.props.member; + const decorator = this.props.decorator; + + const props = { + id: this.props.id, + ref: this.treeRowRef, + role: "treeitem", + "aria-level": member.level + 1, + "aria-selected": !!member.selected, + onClick: this.props.onClick, + onContextMenu: this.props.onContextMenu, + onKeyDownCapture: member.active ? this._onKeyDown : undefined, + onMouseOver: this.props.onMouseOver, + onMouseOut: this.props.onMouseOut, + }; + + // Compute class name list for the <tr> element. + const classNames = this.getRowClass(member.object) || []; + classNames.push("treeRow"); + classNames.push(member.type + "Row"); + + if (member.hasChildren) { + classNames.push("hasChildren"); + + // There are 2 situations where hasChildren is true: + // 1. it is an object with children. Only set aria-expanded in this situation + // 2. It is a long string (> 50 chars) that can be expanded to fully display it + if (member.type !== "string") { + props["aria-expanded"] = member.open; + } + } + + if (member.open) { + classNames.push("opened"); + } + + if (member.loading) { + classNames.push("loading"); + } + + if (member.selected) { + classNames.push("selected"); + } + + if (member.hidden) { + classNames.push("hidden"); + } + + props.className = classNames.join(" "); + + // The label column (with toggle buttons) is usually + // the first one, but there might be cases (like in + // the Memory panel) where the toggling is done + // in the last column. + const cells = []; + + // Get components for rendering cells. + let renderCell = this.props.renderCell || RenderCell; + let renderLabelCell = this.props.renderLabelCell || RenderLabelCell; + if (decorator?.renderLabelCell) { + renderLabelCell = + decorator.renderLabelCell(member.object) || renderLabelCell; + } + + // Render a cell for every column. + this.props.columns.forEach(col => { + const cellProps = Object.assign({}, this.props, { + key: col.id, + id: col.id, + value: this.props.provider.getValue(member.object, col.id), + }); + + if (decorator?.renderCell) { + renderCell = decorator.renderCell(member.object, col.id); + } + + const render = col.id == "default" ? renderLabelCell : renderCell; + + // Some cells don't have to be rendered. This happens when some + // other cells span more columns. Note that the label cells contains + // toggle buttons and should be usually there unless we are rendering + // a simple non-expandable table. + if (render) { + cells.push(render(cellProps)); + } + }); + + // Render tree row + return tr(props, cells); + } + } + + // Helpers + + const RenderCell = props => { + return TreeCell(props); + }; + + const RenderLabelCell = props => { + return LabelCell(props); + }; + + // Exports from this module + module.exports = TreeRow; +}); diff --git a/devtools/client/shared/components/tree/TreeView.css b/devtools/client/shared/components/tree/TreeView.css new file mode 100644 index 0000000000..c09df2f557 --- /dev/null +++ b/devtools/client/shared/components/tree/TreeView.css @@ -0,0 +1,198 @@ +/* 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/. */ + +@import url('chrome://devtools/content/shared/components/reps/reps.css'); + +/******************************************************************************/ +/* TreeView Colors */ + +:root { + --tree-header-background: #C8D2DC; + --tree-header-sorted-background: #AAC3DC; +} + +/******************************************************************************/ +/* TreeView Table*/ + +.treeTable { + color: var(--theme-highlight-blue); +} + +.treeTable .treeLabelCell, +.treeTable .treeValueCell { + padding: 2px 0; + padding-inline-start: 4px; + line-height: 16px; /* make rows 20px tall */ + vertical-align: top; + overflow: hidden; +} + +.treeTable .treeLabelCell { + white-space: nowrap; + cursor: default; + padding-inline-start: var(--tree-label-cell-indent); +} + +.treeTable .treeLabelCell::after { + content: ":"; + color: var(--object-color); +} + +.treeTable .treeValueCell.inputEnabled { + padding-block: 0; +} + +.treeTable .treeValueCell.inputEnabled input { + width: 100%; + height: 20px; + margin: 0; + margin-inline-start: -2px; + border: solid 1px transparent; + outline: none; + box-shadow: none; + padding: 0 1px; + color: var(--theme-text-color-strong); + background: var(--theme-sidebar-background); +} + +.treeTable .treeValueCell.inputEnabled input:focus { + transition: all 150ms ease-in-out; +} + +.treeTable .treeValueCell > [aria-labelledby], +.treeTable .treeLabelCell > .treeLabel { + unicode-bidi: plaintext; + text-align: match-parent; +} + +/* No padding if there is actually no label */ +.treeTable .treeLabel:empty { + padding-inline-start: 0; +} + +.treeTable .treeRow.hasChildren > .treeLabelCell > .treeLabel:hover { + cursor: pointer; + text-decoration: underline; +} + +/* :not(.selected) is used because row selection styles should have + more precedence than row hovering. */ +.treeTable .treeRow:not(.selected):hover { + background-color: var(--theme-selection-background-hover) !important; +} + +.treeTable .treeRow.selected { + background-color: var(--theme-selection-background); +} + +.treeTable .treeRow.selected :where(:not(.objectBox-jsonml)), +.treeTable .treeRow.selected .treeLabelCell::after { + color: var(--theme-selection-color); + fill: currentColor; +} + +/* Invert text selection color in selected rows */ +.treeTable .treeRow.selected :not(input, textarea)::selection { + color: var(--theme-selection-background); + background-color: var(--theme-selection-color); +} + +/* Filtering */ +.treeTable .treeRow.hidden { + display: none !important; +} + +.treeTable .treeValueCellDivider { + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} + +/* Learn More link */ +.treeTable .treeValueCell .learn-more-link { + user-select: none; + color: var(--theme-link-color); + cursor: pointer; + margin: 0 5px; +} + +.treeTable .treeValueCell .learn-more-link:hover { + text-decoration: underline; +} + +/******************************************************************************/ +/* Toggle Icon */ + +.treeTable .treeRow .treeIcon { + box-sizing: content-box; + height: 14px; + width: 14px; + padding: 1px; + /* Set the size of loading spinner (see .devtools-throbber) */ + font-size: 10px; + line-height: 14px; + display: inline-block; + vertical-align: bottom; + /* Use a total width of 20px (margins + padding + width) */ + margin-inline: 3px 1px; +} + +/* All expanded/collapsed styles need to apply on immediate children + since there might be nested trees within a tree. */ +.treeTable .treeRow.hasChildren > .treeLabelCell > .treeIcon { + cursor: pointer; + background-repeat: no-repeat; +} + +/******************************************************************************/ +/* Header */ + +.treeTable .treeHeaderRow { + height: 18px; +} + +.treeTable .treeHeaderCell { + cursor: pointer; + user-select: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.2); + padding: 0 !important; + background: linear-gradient( + rgba(255, 255, 255, 0.05), + rgba(0, 0, 0, 0.05)), + radial-gradient(1px 60% at right, + rgba(0, 0, 0, 0.8) 0%, + transparent 80%) repeat-x var(--tree-header-background); + color: var(--theme-body-color); + white-space: nowrap; +} + +.treeTable .treeHeaderCellBox { + padding-block: 2px; + padding-inline: 10px 14px; +} + +.treeTable .treeHeaderRow > .treeHeaderCell:first-child > .treeHeaderCellBox { + padding: 0; +} + +.treeTable .treeHeaderSorted { + background-color: var(--tree-header-sorted-background); +} + +.treeTable .treeHeaderSorted > .treeHeaderCellBox { + background: url(chrome://devtools/skin/images/sort-descending-arrow.svg) no-repeat calc(100% - 4px); +} + +.treeTable .treeHeaderSorted.sortedAscending > .treeHeaderCellBox { + background-image: url(chrome://devtools/skin/images/sort-ascending-arrow.svg); +} + +.treeTable .treeHeaderCell:hover:active { + background-image: linear-gradient( + rgba(0, 0, 0, 0.1), + transparent), + radial-gradient(1px 60% at right, + rgba(0, 0, 0, 0.8) 0%, + transparent 80%); +} diff --git a/devtools/client/shared/components/tree/TreeView.js b/devtools/client/shared/components/tree/TreeView.js new file mode 100644 index 0000000000..d9ef7c0088 --- /dev/null +++ b/devtools/client/shared/components/tree/TreeView.js @@ -0,0 +1,799 @@ +/* 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"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + const { + cloneElement, + Component, + createFactory, + createRef, + } = require("devtools/client/shared/vendor/react"); + const { findDOMNode } = require("devtools/client/shared/vendor/react-dom"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const dom = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + ObjectProvider, + } = require("devtools/client/shared/components/tree/ObjectProvider"); + const TreeRow = createFactory( + require("devtools/client/shared/components/tree/TreeRow") + ); + const TreeHeader = createFactory( + require("devtools/client/shared/components/tree/TreeHeader") + ); + + const { scrollIntoView } = require("devtools/client/shared/scroll"); + + const SUPPORTED_KEYS = [ + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "End", + "Home", + "Enter", + " ", + "Escape", + ]; + + const defaultProps = { + object: null, + renderRow: null, + provider: ObjectProvider, + expandedNodes: new Set(), + selected: null, + defaultSelectFirstNode: true, + active: null, + expandableStrings: true, + columns: [], + }; + + /** + * This component represents a tree view with expandable/collapsible nodes. + * The tree is rendered using <table> element where every node is represented + * by <tr> element. The tree is one big table where nodes (rows) are properly + * indented from the left to mimic hierarchical structure of the data. + * + * The tree can have arbitrary number of columns and so, might be use + * as an expandable tree-table UI widget as well. By default, there is + * one column for node label and one for node value. + * + * The tree is maintaining its (presentation) state, which consists + * from list of expanded nodes and list of columns. + * + * Complete data provider interface: + * var TreeProvider = { + * getChildren: function(object); + * hasChildren: function(object); + * getLabel: function(object, colId); + * getLevel: function(object); // optional + * getValue: function(object, colId); + * getKey: function(object); + * getType: function(object); + * } + * + * Complete tree decorator interface: + * var TreeDecorator = { + * getRowClass: function(object); + * getCellClass: function(object, colId); + * getHeaderClass: function(colId); + * renderValue: function(object, colId); + * renderRow: function(object); + * renderCell: function(object, colId); + * renderLabelCell: function(object); + * } + */ + class TreeView extends Component { + // The only required property (not set by default) is the input data + // object that is used to populate the tree. + static get propTypes() { + return { + // The input data object. + object: PropTypes.any, + className: PropTypes.string, + label: PropTypes.string, + // Data provider (see also the interface above) + provider: PropTypes.shape({ + getChildren: PropTypes.func, + hasChildren: PropTypes.func, + getLabel: PropTypes.func, + getValue: PropTypes.func, + getKey: PropTypes.func, + getLevel: PropTypes.func, + getType: PropTypes.func, + }).isRequired, + // Tree decorator (see also the interface above) + decorator: PropTypes.shape({ + getRowClass: PropTypes.func, + getCellClass: PropTypes.func, + getHeaderClass: PropTypes.func, + renderValue: PropTypes.func, + renderRow: PropTypes.func, + renderCell: PropTypes.func, + renderLabelCell: PropTypes.func, + }), + // Custom tree row (node) renderer + renderRow: PropTypes.func, + // Custom cell renderer + renderCell: PropTypes.func, + // Custom value renderer + renderValue: PropTypes.func, + // Custom tree label (including a toggle button) renderer + renderLabelCell: PropTypes.func, + // Set of expanded nodes + expandedNodes: PropTypes.object, + // Selected node + selected: PropTypes.string, + // Select first node by default + defaultSelectFirstNode: PropTypes.bool, + // The currently active (keyboard) item, if any such item exists. + active: PropTypes.string, + // Custom filtering callback + onFilter: PropTypes.func, + // Custom sorting callback + onSort: PropTypes.func, + // Custom row click callback + onClickRow: PropTypes.func, + // Row context menu event handler + onContextMenuRow: PropTypes.func, + // Tree context menu event handler + onContextMenuTree: PropTypes.func, + // A header is displayed if set to true + header: PropTypes.bool, + // Long string is expandable by a toggle button + expandableStrings: PropTypes.bool, + // Array of columns + columns: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + title: PropTypes.string, + width: PropTypes.string, + }) + ), + }; + } + + static get defaultProps() { + return defaultProps; + } + + static subPath(path, subKey) { + return path + "/" + String(subKey).replace(/[\\/]/g, "\\$&"); + } + + /** + * Creates a set with the paths of the nodes that should be expanded by default + * according to the passed options. + * @param {Object} The root node of the tree. + * @param {Object} [optional] An object with the following optional parameters: + * - maxLevel: nodes nested deeper than this level won't be expanded. + * - maxNodes: maximum number of nodes that can be expanded. The traversal is + breadth-first, so expanding nodes nearer to the root will be preferred. + Sibling nodes will either be all expanded or none expanded. + * } + */ + static getExpandedNodes( + rootObj, + { maxLevel = Infinity, maxNodes = Infinity } = {} + ) { + const expandedNodes = new Set(); + const queue = [ + { + object: rootObj, + level: 1, + path: "", + }, + ]; + while (queue.length) { + const { object, level, path } = queue.shift(); + if (Object(object) !== object) { + continue; + } + const keys = Object.keys(object); + if (expandedNodes.size + keys.length > maxNodes) { + // Avoid having children half expanded. + break; + } + for (const key of keys) { + const nodePath = TreeView.subPath(path, key); + expandedNodes.add(nodePath); + if (level < maxLevel) { + queue.push({ + object: object[key], + level: level + 1, + path: nodePath, + }); + } + } + } + return expandedNodes; + } + + constructor(props) { + super(props); + + this.state = { + expandedNodes: props.expandedNodes, + columns: ensureDefaultColumn(props.columns), + selected: props.selected, + active: props.active, + lastSelectedIndex: props.defaultSelectFirstNode ? 0 : null, + mouseDown: false, + }; + + this.treeRef = createRef(); + + this.toggle = this.toggle.bind(this); + this.isExpanded = this.isExpanded.bind(this); + this.onFocus = this.onFocus.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onClickRow = this.onClickRow.bind(this); + this.getSelectedRow = this.getSelectedRow.bind(this); + this.selectRow = this.selectRow.bind(this); + this.activateRow = this.activateRow.bind(this); + this.isSelected = this.isSelected.bind(this); + this.onFilter = this.onFilter.bind(this); + this.onSort = this.onSort.bind(this); + this.getMembers = this.getMembers.bind(this); + this.renderRows = this.renderRows.bind(this); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { expandedNodes, selected } = nextProps; + const state = { + expandedNodes, + lastSelectedIndex: this.getSelectedRowIndex(), + }; + + if (selected) { + state.selected = selected; + } + + this.setState(Object.assign({}, this.state, state)); + } + + shouldComponentUpdate(nextProps, nextState) { + const { + expandedNodes, + columns, + selected, + active, + lastSelectedIndex, + mouseDown, + } = this.state; + + return ( + expandedNodes !== nextState.expandedNodes || + columns !== nextState.columns || + selected !== nextState.selected || + active !== nextState.active || + lastSelectedIndex !== nextState.lastSelectedIndex || + mouseDown === nextState.mouseDown + ); + } + + componentDidUpdate() { + const selected = this.getSelectedRow(); + if (selected || this.state.active) { + return; + } + + const rows = this.visibleRows; + if (rows.length === 0) { + return; + } + + // Only select a row if there is a previous lastSelected Index + // This mostly happens when the treeview is loaded the first time + if (this.state.lastSelectedIndex !== null) { + this.selectRow( + rows[Math.min(this.state.lastSelectedIndex, rows.length - 1)], + { alignTo: "top" } + ); + } + } + + /** + * Get rows that are currently visible. Some rows can be filtered and made + * invisible, in which case, when navigating around the tree we need to + * ignore the ones that are not reachable by the user. + */ + get visibleRows() { + return this.rows.filter(row => { + const rowEl = findDOMNode(row); + return rowEl?.offsetParent; + }); + } + + // Node expand/collapse + + toggle(nodePath) { + const nodes = this.state.expandedNodes; + if (this.isExpanded(nodePath)) { + nodes.delete(nodePath); + } else { + nodes.add(nodePath); + } + + // Compute new state and update the tree. + this.setState( + Object.assign({}, this.state, { + expandedNodes: nodes, + }) + ); + } + + isExpanded(nodePath) { + return this.state.expandedNodes.has(nodePath); + } + + // Event Handlers + + onFocus(_event) { + if (this.state.mouseDown) { + return; + } + // Set focus to the first element, if none is selected or activated + // This is needed because keyboard navigation won't work without an element being selected + this.componentDidUpdate(); + } + + // eslint-disable-next-line complexity + onKeyDown(event) { + const keyEligibleForFirstLetterNavigation = event.key.length === 1; + if ( + (!SUPPORTED_KEYS.includes(event.key) && + !keyEligibleForFirstLetterNavigation) || + event.shiftKey || + event.ctrlKey || + event.metaKey || + event.altKey + ) { + return; + } + + const row = this.getSelectedRow(); + if (!row) { + return; + } + + const rows = this.visibleRows; + const index = rows.indexOf(row); + const { hasChildren, open } = row.props.member; + + switch (event.key) { + case "ArrowRight": + if (hasChildren) { + if (open) { + const firstChildRow = this.rows + .slice(index + 1) + .find(r => r.props.member.level > row.props.member.level); + if (firstChildRow) { + this.selectRow(firstChildRow, { alignTo: "bottom" }); + } + } else { + this.toggle(this.state.selected); + } + } + break; + case "ArrowLeft": + if (hasChildren && open) { + this.toggle(this.state.selected); + } else { + const parentRow = rows + .slice(0, index) + .reverse() + .find(r => r.props.member.level < row.props.member.level); + if (parentRow) { + this.selectRow(parentRow, { alignTo: "top" }); + } + } + break; + case "ArrowDown": + const nextRow = rows[index + 1]; + if (nextRow) { + this.selectRow(nextRow, { alignTo: "bottom" }); + } + break; + case "ArrowUp": + const previousRow = rows[index - 1]; + if (previousRow) { + this.selectRow(previousRow, { alignTo: "top" }); + } + break; + case "Home": + const firstRow = rows[0]; + + if (firstRow) { + this.selectRow(firstRow, { alignTo: "top" }); + } + break; + case "End": + const lastRow = rows[rows.length - 1]; + if (lastRow) { + this.selectRow(lastRow, { alignTo: "bottom" }); + } + break; + case "Enter": + case " ": + // On space or enter make selected row active. This means keyboard + // focus handling is passed on to the tree row itself. + if (this.treeRef.current === document.activeElement) { + event.stopPropagation(); + event.preventDefault(); + if (this.state.active !== this.state.selected) { + this.activateRow(this.state.selected); + } + + return; + } + break; + case "Escape": + event.stopPropagation(); + if (this.state.active != null) { + this.activateRow(null); + } + break; + } + + if (keyEligibleForFirstLetterNavigation) { + const next = rows + .slice(index + 1) + .find(r => r.props.member.name.startsWith(event.key)); + if (next) { + this.selectRow(next, { alignTo: "bottom" }); + } + } + + // Focus should always remain on the tree container itself. + this.treeRef.current.focus(); + event.preventDefault(); + } + + onClickRow(nodePath, event) { + const onClickRow = this.props.onClickRow; + const row = this.visibleRows.find(r => r.props.member.path === nodePath); + + // Call custom click handler and bail out if it returns true. + if ( + onClickRow && + onClickRow.call(this, nodePath, event, row.props.member) + ) { + return; + } + + event.stopPropagation(); + + const cell = event.target.closest("td"); + if (cell && cell.classList.contains("treeLabelCell")) { + this.toggle(nodePath); + } + + this.selectRow(row, { preventAutoScroll: true }); + } + + onContextMenu(member, event) { + const onContextMenuRow = this.props.onContextMenuRow; + if (onContextMenuRow) { + onContextMenuRow.call(this, member, event); + } + } + + getSelectedRow() { + const rows = this.visibleRows; + if (!this.state.selected || rows.length === 0) { + return null; + } + return rows.find(row => this.isSelected(row.props.member.path)); + } + + getSelectedRowIndex() { + const row = this.getSelectedRow(); + if (!row) { + return this.props.defaultSelectFirstNode ? 0 : null; + } + + return this.visibleRows.indexOf(row); + } + + _scrollIntoView(row, options = {}) { + const treeEl = this.treeRef.current; + if (!treeEl || !row) { + return; + } + + const { props: { member: { path } = {} } = {} } = row; + if (!path) { + return; + } + + const element = treeEl.ownerDocument.getElementById(path); + if (!element) { + return; + } + + scrollIntoView(element, { ...options }); + } + + selectRow(row, options = {}) { + const { props: { member: { path } = {} } = {} } = row; + if (this.isSelected(path)) { + return; + } + + if (this.state.active != null) { + const treeEl = this.treeRef.current; + if (treeEl && treeEl !== treeEl.ownerDocument.activeElement) { + treeEl.focus(); + } + } + + if (!options.preventAutoScroll) { + this._scrollIntoView(row, options); + } + + this.setState({ + ...this.state, + selected: path, + active: null, + }); + } + + activateRow(active) { + this.setState({ + ...this.state, + active, + }); + } + + isSelected(nodePath) { + return nodePath === this.state.selected; + } + + isActive(nodePath) { + return nodePath === this.state.active; + } + + // Filtering & Sorting + + /** + * Filter out nodes that don't correspond to the current filter. + * @return {Boolean} true if the node should be visible otherwise false. + */ + onFilter(object) { + const onFilter = this.props.onFilter; + return onFilter ? onFilter(object) : true; + } + + onSort(parent, children) { + const onSort = this.props.onSort; + return onSort ? onSort(parent, children) : children; + } + + // Members + + /** + * Return children node objects (so called 'members') for given + * parent object. + */ + getMembers(parent, level, path) { + // Strings don't have children. Note that 'long' strings are using + // the expander icon (+/-) to display the entire original value, + // but there are no child items. + if (typeof parent == "string") { + return []; + } + + const { expandableStrings, provider } = this.props; + let children = provider.getChildren(parent) || []; + + // If the return value is non-array, the children + // are being loaded asynchronously. + if (!Array.isArray(children)) { + return children; + } + + children = this.onSort(parent, children) || children; + + return children.map(child => { + const key = provider.getKey(child); + const nodePath = TreeView.subPath(path, key); + const type = provider.getType(child); + let hasChildren = provider.hasChildren(child); + + // Value with no column specified is used for optimization. + // The row is re-rendered only if this value changes. + // Value for actual column is get when a cell is rendered. + const value = provider.getValue(child); + + if (expandableStrings && isLongString(value)) { + hasChildren = true; + } + + // Return value is a 'member' object containing meta-data about + // tree node. It describes node label, value, type, etc. + return { + // An object associated with this node. + object: child, + // A label for the child node + name: provider.getLabel(child), + // Data type of the child node (used for CSS customization) + type, + // Class attribute computed from the type. + rowClass: "treeRow-" + type, + // Level of the child within the hierarchy (top == 0) + level: provider.getLevel ? provider.getLevel(child, level) : level, + // True if this node has children. + hasChildren, + // Value associated with this node (as provided by the data provider) + value, + // True if the node is expanded. + open: this.isExpanded(nodePath), + // Node path + path: nodePath, + // True if the node is hidden (used for filtering) + hidden: !this.onFilter(child), + // True if the node is selected with keyboard + selected: this.isSelected(nodePath), + // True if the node is activated with keyboard + active: this.isActive(nodePath), + }; + }); + } + + /** + * Render tree rows/nodes. + */ + renderRows(parent, level = 0, path = "") { + let rows = []; + const decorator = this.props.decorator; + let renderRow = this.props.renderRow || TreeRow; + + // Get children for given parent node, iterate over them and render + // a row for every one. Use row template (a component) from properties. + // If the return value is non-array, the children are being loaded + // asynchronously. + const members = this.getMembers(parent, level, path); + if (!Array.isArray(members)) { + return members; + } + + members.forEach(member => { + if (decorator?.renderRow) { + renderRow = decorator.renderRow(member.object) || renderRow; + } + + const props = Object.assign({}, this.props, { + key: `${member.path}-${member.active ? "active" : "inactive"}`, + member, + columns: this.state.columns, + id: member.path, + ref: row => row && this.rows.push(row), + onClick: this.onClickRow.bind(this, member.path), + onContextMenu: this.onContextMenu.bind(this, member), + }); + + // Render single row. + rows.push(renderRow(props)); + + // If a child node is expanded render its rows too. + if (member.hasChildren && member.open) { + const childRows = this.renderRows( + member.object, + level + 1, + member.path + ); + + // If children needs to be asynchronously fetched first, + // set 'loading' property to the parent row. Otherwise + // just append children rows to the array of all rows. + if (!Array.isArray(childRows)) { + const lastIndex = rows.length - 1; + props.member.loading = true; + rows[lastIndex] = cloneElement(rows[lastIndex], props); + } else { + rows = rows.concat(childRows); + } + } + }); + + return rows; + } + + render() { + const root = this.props.object; + const classNames = ["treeTable"]; + this.rows = []; + + const { className, onContextMenuTree } = this.props; + // Use custom class name from props. + if (className) { + classNames.push(...className.split(" ")); + } + + // Alright, let's render all tree rows. The tree is one big <table>. + let rows = this.renderRows(root, 0, ""); + + // This happens when the view needs to do initial asynchronous + // fetch for the root object. The tree might provide a hook API + // for rendering animated spinner (just like for tree nodes). + if (!Array.isArray(rows)) { + rows = []; + } + + const props = Object.assign({}, this.props, { + columns: this.state.columns, + }); + + return dom.table( + { + className: classNames.join(" "), + role: "tree", + ref: this.treeRef, + tabIndex: 0, + onFocus: this.onFocus, + onKeyDown: this.onKeyDown, + onContextMenu: onContextMenuTree && onContextMenuTree.bind(this), + onMouseDown: () => this.setState({ mouseDown: true }), + onMouseUp: () => this.setState({ mouseDown: false }), + onClick: () => { + // Focus should always remain on the tree container itself. + this.treeRef.current.focus(); + }, + onBlur: event => { + if (this.state.active != null) { + const { relatedTarget } = event; + if (!this.treeRef.current.contains(relatedTarget)) { + this.activateRow(null); + } + } + }, + "aria-label": this.props.label || "", + "aria-activedescendant": this.state.selected, + cellPadding: 0, + cellSpacing: 0, + }, + TreeHeader(props), + dom.tbody( + { + role: "presentation", + tabIndex: -1, + }, + rows + ) + ); + } + } + + // Helpers + + /** + * There should always be at least one column (the one with toggle buttons) + * and this function ensures that it's true. + */ + function ensureDefaultColumn(columns) { + if (!columns) { + columns = []; + } + + const defaultColumn = columns.filter(col => col.id == "default"); + if (defaultColumn.length) { + return columns; + } + + // The default column is usually the first one. + return [{ id: "default" }, ...columns]; + } + + function isLongString(value) { + return typeof value == "string" && value.length > 50; + } + + // Exports from this module + module.exports = TreeView; +}); diff --git a/devtools/client/shared/components/tree/moz.build b/devtools/client/shared/components/tree/moz.build new file mode 100644 index 0000000000..0700575f17 --- /dev/null +++ b/devtools/client/shared/components/tree/moz.build @@ -0,0 +1,13 @@ +# 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( + "LabelCell.js", + "ObjectProvider.js", + "TreeCell.js", + "TreeHeader.js", + "TreeRow.js", + "TreeView.js", +) |