diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/client/storage | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/storage')
105 files changed, 14599 insertions, 0 deletions
diff --git a/devtools/client/storage/VariablesView.sys.mjs b/devtools/client/storage/VariablesView.sys.mjs new file mode 100644 index 0000000000..4cd03edb18 --- /dev/null +++ b/devtools/client/storage/VariablesView.sys.mjs @@ -0,0 +1,4445 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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-disable mozilla/no-aArgs */ + +const DBG_STRINGS_URI = "devtools/client/locales/debugger.properties"; +const LAZY_EMPTY_DELAY = 150; // ms +const SCROLL_PAGE_SIZE_DEFAULT = 0; +const PAGE_SIZE_SCROLL_HEIGHT_RATIO = 100; +const PAGE_SIZE_MAX_JUMPS = 30; +const SEARCH_ACTION_MAX_DELAY = 300; // ms +const ITEM_FLASH_DURATION = 300; // ms + +import { require } from "resource://devtools/shared/loader/Loader.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); +const { + getSourceNames, +} = require("resource://devtools/client/shared/source-utils.js"); +const { extend } = require("resource://devtools/shared/extend.js"); +const { + ViewHelpers, + setNamedTimeout, +} = require("resource://devtools/client/shared/widgets/view-helpers.js"); +const nodeConstants = require("resource://devtools/shared/dom-node-constants.js"); +const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); +const { PluralForm } = require("resource://devtools/shared/plural-form.js"); +const { + LocalizationHelper, + ELLIPSIS, +} = require("resource://devtools/shared/l10n.js"); + +const L10N = new LocalizationHelper(DBG_STRINGS_URI); +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "clipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +/** + * A tree view for inspecting scopes, objects and properties. + * Iterable via "for (let [id, scope] of instance) { }". + * Requires the devtools common.css and debugger.css skin stylesheets. + * + * To allow replacing variable or property values in this view, provide an + * "eval" function property. To allow replacing variable or property names, + * provide a "switch" function. To handle deleting variables or properties, + * provide a "delete" function. + * + * @param Node aParentNode + * The parent node to hold this view. + * @param object aFlags [optional] + * An object contaning initialization options for this view. + * e.g. { lazyEmpty: true, searchEnabled: true ... } + */ +export function VariablesView(aParentNode, aFlags = {}) { + this._store = []; // Can't use a Map because Scope names needn't be unique. + this._itemsByElement = new WeakMap(); + this._prevHierarchy = new Map(); + this._currHierarchy = new Map(); + + this._parent = aParentNode; + this._parent.classList.add("variables-view-container"); + this._parent.classList.add("theme-body"); + this._appendEmptyNotice(); + + this._onSearchboxInput = this._onSearchboxInput.bind(this); + this._onSearchboxKeyDown = this._onSearchboxKeyDown.bind(this); + this._onViewKeyDown = this._onViewKeyDown.bind(this); + + // Create an internal scrollbox container. + this._list = this.document.createXULElement("scrollbox"); + this._list.setAttribute("orient", "vertical"); + this._list.addEventListener("keydown", this._onViewKeyDown); + this._parent.appendChild(this._list); + + for (const name in aFlags) { + this[name] = aFlags[name]; + } + + EventEmitter.decorate(this); +} + +VariablesView.prototype = { + /** + * Helper setter for populating this container with a raw object. + * + * @param object aObject + * The raw object to display. You can only provide this object + * if you want the variables view to work in sync mode. + */ + set rawObject(aObject) { + this.empty(); + this.addScope() + .addItem(undefined, { enumerable: true }) + .populate(aObject, { sorted: true }); + }, + + /** + * Adds a scope to contain any inspected variables. + * + * This new scope will be considered the parent of any other scope + * added afterwards. + * + * @param string l10nId + * The scope localized string id. + * @param string aCustomClass + * An additional class name for the containing element. + * @return Scope + * The newly created Scope instance. + */ + addScope(l10nId = "", aCustomClass = "") { + this._removeEmptyNotice(); + this._toggleSearchVisibility(true); + + const scope = new Scope(this, l10nId, { customClass: aCustomClass }); + this._store.push(scope); + this._itemsByElement.set(scope._target, scope); + this._currHierarchy.set(l10nId, scope); + scope.header = !!l10nId; + + return scope; + }, + + /** + * Removes all items from this container. + * + * @param number aTimeout [optional] + * The number of milliseconds to delay the operation if + * lazy emptying of this container is enabled. + */ + empty(aTimeout = this.lazyEmptyDelay) { + // If there are no items in this container, emptying is useless. + if (!this._store.length) { + return; + } + + this._store.length = 0; + this._itemsByElement = new WeakMap(); + this._prevHierarchy = this._currHierarchy; + this._currHierarchy = new Map(); // Don't clear, this is just simple swapping. + + // Check if this empty operation may be executed lazily. + if (this.lazyEmpty && aTimeout > 0) { + this._emptySoon(aTimeout); + return; + } + + while (this._list.hasChildNodes()) { + this._list.firstChild.remove(); + } + + this._appendEmptyNotice(); + this._toggleSearchVisibility(false); + }, + + /** + * Emptying this container and rebuilding it immediately afterwards would + * result in a brief redraw flicker, because the previously expanded nodes + * may get asynchronously re-expanded, after fetching the prototype and + * properties from a server. + * + * To avoid such behaviour, a normal container list is rebuild, but not + * immediately attached to the parent container. The old container list + * is kept around for a short period of time, hopefully accounting for the + * data fetching delay. In the meantime, any operations can be executed + * normally. + * + * @see VariablesView.empty + * @see VariablesView.commitHierarchy + */ + _emptySoon(aTimeout) { + const prevList = this._list; + const currList = (this._list = this.document.createXULElement("scrollbox")); + + this.window.setTimeout(() => { + prevList.removeEventListener("keydown", this._onViewKeyDown); + currList.addEventListener("keydown", this._onViewKeyDown); + currList.setAttribute("orient", "vertical"); + + this._parent.removeChild(prevList); + this._parent.appendChild(currList); + + if (!this._store.length) { + this._appendEmptyNotice(); + this._toggleSearchVisibility(false); + } + }, aTimeout); + }, + + /** + * Optional DevTools toolbox containing this VariablesView. Used to + * communicate with the inspector and highlighter. + */ + toolbox: null, + + /** + * The controller for this VariablesView, if it has one. + */ + controller: null, + + /** + * The amount of time (in milliseconds) it takes to empty this view lazily. + */ + lazyEmptyDelay: LAZY_EMPTY_DELAY, + + /** + * Specifies if this view may be emptied lazily. + * @see VariablesView.prototype.empty + */ + lazyEmpty: false, + + /** + * Specifies if nodes in this view may be searched lazily. + */ + lazySearch: true, + + /** + * The number of elements in this container to jump when Page Up or Page Down + * keys are pressed. If falsy, then the page size will be based on the + * container height. + */ + scrollPageSize: SCROLL_PAGE_SIZE_DEFAULT, + + /** + * Function called each time a variable or property's value is changed via + * user interaction. If null, then value changes are disabled. + * + * This property is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + eval: null, + + /** + * Function called each time a variable or property's name is changed via + * user interaction. If null, then name changes are disabled. + * + * This property is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + switch: null, + + /** + * Function called each time a variable or property is deleted via + * user interaction. If null, then deletions are disabled. + * + * This property is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + delete: null, + + /** + * Function called each time a property is added via user interaction. If + * null, then property additions are disabled. + * + * This property is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + new: null, + + /** + * Specifies if after an eval or switch operation, the variable or property + * which has been edited should be disabled. + */ + preventDisableOnChange: false, + + /** + * Specifies if, whenever a variable or property descriptor is available, + * configurable, enumerable, writable, frozen, sealed and extensible + * attributes should not affect presentation. + * + * This flag is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + preventDescriptorModifiers: false, + + /** + * The tooltip text shown on a variable or property's value if an |eval| + * function is provided, in order to change the variable or property's value. + * + * This flag is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + editableValueTooltip: L10N.getStr("variablesEditableValueTooltip"), + + /** + * The tooltip text shown on a variable or property's name if a |switch| + * function is provided, in order to change the variable or property's name. + * + * This flag is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + editableNameTooltip: L10N.getStr("variablesEditableNameTooltip"), + + /** + * The tooltip text shown on a variable or property's edit button if an + * |eval| function is provided and a getter/setter descriptor is present, + * in order to change the variable or property to a plain value. + * + * This flag is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + editButtonTooltip: L10N.getStr("variablesEditButtonTooltip"), + + /** + * The tooltip text shown on a variable or property's value if that value is + * a DOMNode that can be highlighted and selected in the inspector. + * + * This flag is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + domNodeValueTooltip: L10N.getStr("variablesDomNodeValueTooltip"), + + /** + * The tooltip text shown on a variable or property's delete button if a + * |delete| function is provided, in order to delete the variable or property. + * + * This flag is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + deleteButtonTooltip: L10N.getStr("variablesCloseButtonTooltip"), + + /** + * Specifies the context menu attribute set on variables and properties. + * + * This flag is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + contextMenuId: "", + + /** + * The separator label between the variables or properties name and value. + * + * This flag is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + separatorStr: L10N.getStr("variablesSeparatorLabel"), + + /** + * Specifies if enumerable properties and variables should be displayed. + * These variables and properties are visible by default. + * @param boolean aFlag + */ + set enumVisible(aFlag) { + this._enumVisible = aFlag; + + for (const scope of this._store) { + scope._enumVisible = aFlag; + } + }, + + /** + * Specifies if non-enumerable properties and variables should be displayed. + * These variables and properties are visible by default. + * @param boolean aFlag + */ + set nonEnumVisible(aFlag) { + this._nonEnumVisible = aFlag; + + for (const scope of this._store) { + scope._nonEnumVisible = aFlag; + } + }, + + /** + * Specifies if only enumerable properties and variables should be displayed. + * Both types of these variables and properties are visible by default. + * @param boolean aFlag + */ + set onlyEnumVisible(aFlag) { + if (aFlag) { + this.enumVisible = true; + this.nonEnumVisible = false; + } else { + this.enumVisible = true; + this.nonEnumVisible = true; + } + }, + + /** + * Sets if the variable and property searching is enabled. + * @param boolean aFlag + */ + set searchEnabled(aFlag) { + aFlag ? this._enableSearch() : this._disableSearch(); + }, + + /** + * Gets if the variable and property searching is enabled. + * @return boolean + */ + get searchEnabled() { + return !!this._searchboxContainer; + }, + + /** + * Enables variable and property searching in this view. + * Use the "searchEnabled" setter to enable searching. + */ + _enableSearch() { + // If searching was already enabled, no need to re-enable it again. + if (this._searchboxContainer) { + return; + } + const document = this.document; + const ownerNode = this._parent.parentNode; + + const container = (this._searchboxContainer = + document.createXULElement("hbox")); + container.className = "devtools-toolbar devtools-input-toolbar"; + + // Hide the variables searchbox container if there are no variables or + // properties to display. + container.hidden = !this._store.length; + + const searchbox = (this._searchboxNode = document.createElementNS( + HTML_NS, + "input" + )); + searchbox.className = "variables-view-searchinput devtools-filterinput"; + document.l10n.setAttributes(searchbox, "storage-variable-view-search-box"); + searchbox.addEventListener("input", this._onSearchboxInput); + searchbox.addEventListener("keydown", this._onSearchboxKeyDown); + + container.appendChild(searchbox); + ownerNode.insertBefore(container, this._parent); + }, + + /** + * Disables variable and property searching in this view. + * Use the "searchEnabled" setter to disable searching. + */ + _disableSearch() { + // If searching was already disabled, no need to re-disable it again. + if (!this._searchboxContainer) { + return; + } + this._searchboxContainer.remove(); + this._searchboxNode.removeEventListener("input", this._onSearchboxInput); + this._searchboxNode.removeEventListener( + "keydown", + this._onSearchboxKeyDown + ); + + this._searchboxContainer = null; + this._searchboxNode = null; + }, + + /** + * Sets the variables searchbox container hidden or visible. + * It's hidden by default. + * + * @param boolean aVisibleFlag + * Specifies the intended visibility. + */ + _toggleSearchVisibility(aVisibleFlag) { + // If searching was already disabled, there's no need to hide it. + if (!this._searchboxContainer) { + return; + } + this._searchboxContainer.hidden = !aVisibleFlag; + }, + + /** + * Listener handling the searchbox input event. + */ + _onSearchboxInput() { + this.scheduleSearch(this._searchboxNode.value); + }, + + /** + * Listener handling the searchbox keydown event. + */ + _onSearchboxKeyDown(e) { + switch (e.keyCode) { + case KeyCodes.DOM_VK_RETURN: + this._onSearchboxInput(); + return; + case KeyCodes.DOM_VK_ESCAPE: + this._searchboxNode.value = ""; + this._onSearchboxInput(); + } + }, + + /** + * Schedules searching for variables or properties matching the query. + * + * @param string aToken + * The variable or property to search for. + * @param number aWait + * The amount of milliseconds to wait until draining. + */ + scheduleSearch(aToken, aWait) { + // Check if this search operation may not be executed lazily. + if (!this.lazySearch) { + this._doSearch(aToken); + return; + } + + // The amount of time to wait for the requests to settle. + const maxDelay = SEARCH_ACTION_MAX_DELAY; + const delay = aWait === undefined ? maxDelay / aToken.length : aWait; + + // Allow requests to settle down first. + setNamedTimeout("vview-search", delay, () => this._doSearch(aToken)); + }, + + /** + * Performs a case insensitive search for variables or properties matching + * the query, and hides non-matched items. + * + * If aToken is falsy, then all the scopes are unhidden and expanded, + * while the available variables and properties inside those scopes are + * just unhidden. + * + * @param string aToken + * The variable or property to search for. + */ + _doSearch(aToken) { + if (this.controller && this.controller.supportsSearch()) { + // Retrieve the main Scope in which we add attributes + const scope = this._store[0]._store.get(undefined); + if (!aToken) { + // Prune the view from old previous content + // so that we delete the intermediate search results + // we created in previous searches + for (const property of scope._store.values()) { + property.remove(); + } + } + // Retrieve new attributes eventually hidden in splits + this.controller.performSearch(scope, aToken); + // Filter already displayed attributes + if (aToken) { + scope._performSearch(aToken.toLowerCase()); + } + return; + } + for (const scope of this._store) { + switch (aToken) { + case "": + case null: + case undefined: + scope.expand(); + scope._performSearch(""); + break; + default: + scope._performSearch(aToken.toLowerCase()); + break; + } + } + }, + + /** + * Find the first item in the tree of visible items in this container that + * matches the predicate. Searches in visual order (the order seen by the + * user). Descends into each scope to check the scope and its children. + * + * @param function aPredicate + * A function that returns true when a match is found. + * @return Scope | Variable | Property + * The first visible scope, variable or property, or null if nothing + * is found. + */ + _findInVisibleItems(aPredicate) { + for (const scope of this._store) { + const result = scope._findInVisibleItems(aPredicate); + if (result) { + return result; + } + } + return null; + }, + + /** + * Find the last item in the tree of visible items in this container that + * matches the predicate. Searches in reverse visual order (opposite of the + * order seen by the user). Descends into each scope to check the scope and + * its children. + * + * @param function aPredicate + * A function that returns true when a match is found. + * @return Scope | Variable | Property + * The last visible scope, variable or property, or null if nothing + * is found. + */ + _findInVisibleItemsReverse(aPredicate) { + for (let i = this._store.length - 1; i >= 0; i--) { + const scope = this._store[i]; + const result = scope._findInVisibleItemsReverse(aPredicate); + if (result) { + return result; + } + } + return null; + }, + + /** + * Gets the scope at the specified index. + * + * @param number aIndex + * The scope's index. + * @return Scope + * The scope if found, undefined if not. + */ + getScopeAtIndex(aIndex) { + return this._store[aIndex]; + }, + + /** + * Recursively searches this container for the scope, variable or property + * displayed by the specified node. + * + * @param Node aNode + * The node to search for. + * @return Scope | Variable | Property + * The matched scope, variable or property, or null if nothing is found. + */ + getItemForNode(aNode) { + return this._itemsByElement.get(aNode); + }, + + /** + * Gets the scope owning a Variable or Property. + * + * @param Variable | Property + * The variable or property to retrieven the owner scope for. + * @return Scope + * The owner scope. + */ + getOwnerScopeForVariableOrProperty(aItem) { + if (!aItem) { + return null; + } + // If this is a Scope, return it. + if (!(aItem instanceof Variable)) { + return aItem; + } + // If this is a Variable or Property, find its owner scope. + if (aItem instanceof Variable && aItem.ownerView) { + return this.getOwnerScopeForVariableOrProperty(aItem.ownerView); + } + return null; + }, + + /** + * Gets the parent scopes for a specified Variable or Property. + * The returned list will not include the owner scope. + * + * @param Variable | Property + * The variable or property for which to find the parent scopes. + * @return array + * A list of parent Scopes. + */ + getParentScopesForVariableOrProperty(aItem) { + const scope = this.getOwnerScopeForVariableOrProperty(aItem); + return this._store.slice(0, Math.max(this._store.indexOf(scope), 0)); + }, + + /** + * Gets the currently focused scope, variable or property in this view. + * + * @return Scope | Variable | Property + * The focused scope, variable or property, or null if nothing is found. + */ + getFocusedItem() { + const focused = this.document.commandDispatcher.focusedElement; + return this.getItemForNode(focused); + }, + + /** + * Focuses the first visible scope, variable, or property in this container. + */ + focusFirstVisibleItem() { + const focusableItem = this._findInVisibleItems(item => item.focusable); + if (focusableItem) { + this._focusItem(focusableItem); + } + this._parent.scrollTop = 0; + this._parent.scrollLeft = 0; + }, + + /** + * Focuses the last visible scope, variable, or property in this container. + */ + focusLastVisibleItem() { + const focusableItem = this._findInVisibleItemsReverse( + item => item.focusable + ); + if (focusableItem) { + this._focusItem(focusableItem); + } + this._parent.scrollTop = this._parent.scrollHeight; + this._parent.scrollLeft = 0; + }, + + /** + * Focuses the next scope, variable or property in this view. + */ + focusNextItem() { + this.focusItemAtDelta(+1); + }, + + /** + * Focuses the previous scope, variable or property in this view. + */ + focusPrevItem() { + this.focusItemAtDelta(-1); + }, + + /** + * Focuses another scope, variable or property in this view, based on + * the index distance from the currently focused item. + * + * @param number aDelta + * A scalar specifying by how many items should the selection change. + */ + focusItemAtDelta(aDelta) { + const direction = aDelta > 0 ? "advanceFocus" : "rewindFocus"; + let distance = Math.abs(Math[aDelta > 0 ? "ceil" : "floor"](aDelta)); + while (distance--) { + if (!this._focusChange(direction)) { + break; // Out of bounds. + } + } + }, + + /** + * Focuses the next or previous scope, variable or property in this view. + * + * @param string aDirection + * Either "advanceFocus" or "rewindFocus". + * @return boolean + * False if the focus went out of bounds and the first or last element + * in this view was focused instead. + */ + _focusChange(aDirection) { + const commandDispatcher = this.document.commandDispatcher; + const prevFocusedElement = commandDispatcher.focusedElement; + let currFocusedItem = null; + + do { + commandDispatcher[aDirection](); + + // Make sure the newly focused item is a part of this view. + // If the focus goes out of bounds, revert the previously focused item. + if (!(currFocusedItem = this.getFocusedItem())) { + prevFocusedElement.focus(); + return false; + } + } while (!currFocusedItem.focusable); + + // Focus remained within bounds. + return true; + }, + + /** + * Focuses a scope, variable or property and makes sure it's visible. + * + * @param aItem Scope | Variable | Property + * The item to focus. + * @param boolean aCollapseFlag + * True if the focused item should also be collapsed. + * @return boolean + * True if the item was successfully focused. + */ + _focusItem(aItem, aCollapseFlag) { + if (!aItem.focusable) { + return false; + } + if (aCollapseFlag) { + aItem.collapse(); + } + aItem._target.focus(); + aItem._arrow.scrollIntoView({ block: "nearest" }); + return true; + }, + + /** + * Copy current selection to clipboard. + */ + _copyItem() { + const item = this.getFocusedItem(); + lazy.clipboardHelper.copyString( + item._nameString + item.separatorStr + item._valueString + ); + }, + + /** + * Listener handling a key down event on the view. + */ + // eslint-disable-next-line complexity + _onViewKeyDown(e) { + const item = this.getFocusedItem(); + + // Prevent scrolling when pressing navigation keys. + ViewHelpers.preventScrolling(e); + + switch (e.keyCode) { + case KeyCodes.DOM_VK_C: + if (e.ctrlKey || e.metaKey) { + this._copyItem(); + } + return; + + case KeyCodes.DOM_VK_UP: + // Always rewind focus. + this.focusPrevItem(true); + return; + + case KeyCodes.DOM_VK_DOWN: + // Always advance focus. + this.focusNextItem(true); + return; + + case KeyCodes.DOM_VK_LEFT: + // Collapse scopes, variables and properties before rewinding focus. + if (item._isExpanded && item._isArrowVisible) { + item.collapse(); + } else { + this._focusItem(item.ownerView); + } + return; + + case KeyCodes.DOM_VK_RIGHT: + // Nothing to do here if this item never expands. + if (!item._isArrowVisible) { + return; + } + // Expand scopes, variables and properties before advancing focus. + if (!item._isExpanded) { + item.expand(); + } else { + this.focusNextItem(true); + } + return; + + case KeyCodes.DOM_VK_PAGE_UP: + // Rewind a certain number of elements based on the container height. + this.focusItemAtDelta( + -( + this.scrollPageSize || + Math.min( + Math.floor( + this._list.scrollHeight / PAGE_SIZE_SCROLL_HEIGHT_RATIO + ), + PAGE_SIZE_MAX_JUMPS + ) + ) + ); + return; + + case KeyCodes.DOM_VK_PAGE_DOWN: + // Advance a certain number of elements based on the container height. + this.focusItemAtDelta( + +( + this.scrollPageSize || + Math.min( + Math.floor( + this._list.scrollHeight / PAGE_SIZE_SCROLL_HEIGHT_RATIO + ), + PAGE_SIZE_MAX_JUMPS + ) + ) + ); + return; + + case KeyCodes.DOM_VK_HOME: + this.focusFirstVisibleItem(); + return; + + case KeyCodes.DOM_VK_END: + this.focusLastVisibleItem(); + return; + + case KeyCodes.DOM_VK_RETURN: + // Start editing the value or name of the Variable or Property. + if (item instanceof Variable) { + if (e.metaKey || e.altKey || e.shiftKey) { + item._activateNameInput(); + } else { + item._activateValueInput(); + } + } + return; + + case KeyCodes.DOM_VK_DELETE: + case KeyCodes.DOM_VK_BACK_SPACE: + // Delete the Variable or Property if allowed. + if (item instanceof Variable) { + item._onDelete(e); + } + return; + + case KeyCodes.DOM_VK_INSERT: + item._onAddProperty(e); + } + }, + + /** + * Sets the text displayed in this container when there are no available items. + * @param string aValue + */ + set emptyText(aValue) { + if (this._emptyTextNode) { + this._emptyTextNode.setAttribute("value", aValue); + } + this._emptyTextValue = aValue; + this._appendEmptyNotice(); + }, + + /** + * Creates and appends a label signaling that this container is empty. + */ + _appendEmptyNotice() { + if (this._emptyTextNode || !this._emptyTextValue) { + return; + } + + const label = this.document.createXULElement("label"); + label.className = "variables-view-empty-notice"; + label.setAttribute("value", this._emptyTextValue); + + this._parent.appendChild(label); + this._emptyTextNode = label; + }, + + /** + * Removes the label signaling that this container is empty. + */ + _removeEmptyNotice() { + if (!this._emptyTextNode) { + return; + } + + this._parent.removeChild(this._emptyTextNode); + this._emptyTextNode = null; + }, + + /** + * Gets if all values should be aligned together. + * @return boolean + */ + get alignedValues() { + return this._alignedValues; + }, + + /** + * Sets if all values should be aligned together. + * @param boolean aFlag + */ + set alignedValues(aFlag) { + this._alignedValues = aFlag; + if (aFlag) { + this._parent.setAttribute("aligned-values", ""); + } else { + this._parent.removeAttribute("aligned-values"); + } + }, + + /** + * Gets if action buttons (like delete) should be placed at the beginning or + * end of a line. + * @return boolean + */ + get actionsFirst() { + return this._actionsFirst; + }, + + /** + * Sets if action buttons (like delete) should be placed at the beginning or + * end of a line. + * @param boolean aFlag + */ + set actionsFirst(aFlag) { + this._actionsFirst = aFlag; + if (aFlag) { + this._parent.setAttribute("actions-first", ""); + } else { + this._parent.removeAttribute("actions-first"); + } + }, + + /** + * Gets the parent node holding this view. + * @return Node + */ + get parentNode() { + return this._parent; + }, + + /** + * Gets the owner document holding this view. + * @return HTMLDocument + */ + get document() { + return this._document || (this._document = this._parent.ownerDocument); + }, + + /** + * Gets the default window holding this view. + * @return nsIDOMWindow + */ + get window() { + return this._window || (this._window = this.document.defaultView); + }, + + _document: null, + _window: null, + + _store: null, + _itemsByElement: null, + _prevHierarchy: null, + _currHierarchy: null, + + _enumVisible: true, + _nonEnumVisible: true, + _alignedValues: false, + _actionsFirst: false, + + _parent: null, + _list: null, + _searchboxNode: null, + _searchboxContainer: null, + _emptyTextNode: null, + _emptyTextValue: "", +}; + +VariablesView.NON_SORTABLE_CLASSES = [ + "Array", + "Int8Array", + "Uint8Array", + "Uint8ClampedArray", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float32Array", + "Float64Array", + "NodeList", +]; + +/** + * Determine whether an object's properties should be sorted based on its class. + * + * @param string aClassName + * The class of the object. + */ +VariablesView.isSortable = function (aClassName) { + return !VariablesView.NON_SORTABLE_CLASSES.includes(aClassName); +}; + +/** + * Generates the string evaluated when performing simple value changes. + * + * @param Variable | Property aItem + * The current variable or property. + * @param string aCurrentString + * The trimmed user inputted string. + * @param string aPrefix [optional] + * Prefix for the symbolic name. + * @return string + * The string to be evaluated. + */ +VariablesView.simpleValueEvalMacro = function ( + aItem, + aCurrentString, + aPrefix = "" +) { + return aPrefix + aItem.symbolicName + "=" + aCurrentString; +}; + +/** + * Generates the string evaluated when overriding getters and setters with + * plain values. + * + * @param Property aItem + * The current getter or setter property. + * @param string aCurrentString + * The trimmed user inputted string. + * @param string aPrefix [optional] + * Prefix for the symbolic name. + * @return string + * The string to be evaluated. + */ +VariablesView.overrideValueEvalMacro = function ( + aItem, + aCurrentString, + aPrefix = "" +) { + const property = escapeString(aItem._nameString); + const parent = aPrefix + aItem.ownerView.symbolicName || "this"; + + return ( + "Object.defineProperty(" + + parent + + "," + + property + + "," + + "{ value: " + + aCurrentString + + ", enumerable: " + + parent + + ".propertyIsEnumerable(" + + property + + ")" + + ", configurable: true" + + ", writable: true" + + "})" + ); +}; + +/** + * Generates the string evaluated when performing getters and setters changes. + * + * @param Property aItem + * The current getter or setter property. + * @param string aCurrentString + * The trimmed user inputted string. + * @param string aPrefix [optional] + * Prefix for the symbolic name. + * @return string + * The string to be evaluated. + */ +VariablesView.getterOrSetterEvalMacro = function ( + aItem, + aCurrentString, + aPrefix = "" +) { + const type = aItem._nameString; + const propertyObject = aItem.ownerView; + const parentObject = propertyObject.ownerView; + const property = escapeString(propertyObject._nameString); + const parent = aPrefix + parentObject.symbolicName || "this"; + + switch (aCurrentString) { + case "": + case "null": + case "undefined": + const mirrorType = type == "get" ? "set" : "get"; + const mirrorLookup = + type == "get" ? "__lookupSetter__" : "__lookupGetter__"; + + // If the parent object will end up without any getter or setter, + // morph it into a plain value. + if ( + (type == "set" && propertyObject.getter.type == "undefined") || + (type == "get" && propertyObject.setter.type == "undefined") + ) { + // Make sure the right getter/setter to value override macro is applied + // to the target object. + return propertyObject.evaluationMacro( + propertyObject, + "undefined", + aPrefix + ); + } + + // Construct and return the getter/setter removal evaluation string. + // e.g: Object.defineProperty(foo, "bar", { + // get: foo.__lookupGetter__("bar"), + // set: undefined, + // enumerable: true, + // configurable: true + // }) + return ( + "Object.defineProperty(" + + parent + + "," + + property + + "," + + "{" + + mirrorType + + ":" + + parent + + "." + + mirrorLookup + + "(" + + property + + ")" + + "," + + type + + ":" + + undefined + + ", enumerable: " + + parent + + ".propertyIsEnumerable(" + + property + + ")" + + ", configurable: true" + + "})" + ); + + default: + // Wrap statements inside a function declaration if not already wrapped. + if (!aCurrentString.startsWith("function")) { + const header = "function(" + (type == "set" ? "value" : "") + ")"; + let body = ""; + // If there's a return statement explicitly written, always use the + // standard function definition syntax + if (aCurrentString.includes("return ")) { + body = "{" + aCurrentString + "}"; + } else if (aCurrentString.startsWith("{")) { + // If block syntax is used, use the whole string as the function body. + body = aCurrentString; + } else { + // Prefer an expression closure. + body = "(" + aCurrentString + ")"; + } + aCurrentString = header + body; + } + + // Determine if a new getter or setter should be defined. + const defineType = + type == "get" ? "__defineGetter__" : "__defineSetter__"; + + // Make sure all quotes are escaped in the expression's syntax, + const defineFunc = + 'eval("(' + aCurrentString.replace(/"/g, "\\$&") + ')")'; + + // Construct and return the getter/setter evaluation string. + // e.g: foo.__defineGetter__("bar", eval("(function() { return 42; })")) + return ( + parent + "." + defineType + "(" + property + "," + defineFunc + ")" + ); + } +}; + +/** + * Function invoked when a getter or setter is deleted. + * + * @param Property aItem + * The current getter or setter property. + */ +VariablesView.getterOrSetterDeleteCallback = function (aItem) { + aItem._disable(); + + // Make sure the right getter/setter to value override macro is applied + // to the target object. + aItem.ownerView.eval(aItem, ""); + + return true; // Don't hide the element. +}; + +/** + * A Scope is an object holding Variable instances. + * Iterable via "for (let [name, variable] of instance) { }". + * + * @param VariablesView aView + * The view to contain this scope. + * @param string l10nId + * The scope localized string id. + * @param object aFlags [optional] + * Additional options or flags for this scope. + */ +function Scope(aView, l10nId, aFlags = {}) { + this.ownerView = aView; + + this._onClick = this._onClick.bind(this); + this._openEnum = this._openEnum.bind(this); + this._openNonEnum = this._openNonEnum.bind(this); + + // Inherit properties and flags from the parent view. You can override + // each of these directly onto any scope, variable or property instance. + this.scrollPageSize = aView.scrollPageSize; + this.eval = aView.eval; + this.switch = aView.switch; + this.delete = aView.delete; + this.new = aView.new; + this.preventDisableOnChange = aView.preventDisableOnChange; + this.preventDescriptorModifiers = aView.preventDescriptorModifiers; + this.editableNameTooltip = aView.editableNameTooltip; + this.editableValueTooltip = aView.editableValueTooltip; + this.editButtonTooltip = aView.editButtonTooltip; + this.deleteButtonTooltip = aView.deleteButtonTooltip; + this.domNodeValueTooltip = aView.domNodeValueTooltip; + this.contextMenuId = aView.contextMenuId; + this.separatorStr = aView.separatorStr; + + this._init(l10nId, aFlags); +} + +Scope.prototype = { + /** + * Whether this Scope should be prefetched when it is remoted. + */ + shouldPrefetch: true, + + /** + * Whether this Scope should paginate its contents. + */ + allowPaginate: false, + + /** + * The class name applied to this scope's target element. + */ + targetClassName: "variables-view-scope", + + /** + * Create a new Variable that is a child of this Scope. + * + * @param string aName + * The name of the new Property. + * @param object aDescriptor + * The variable's descriptor. + * @param object aOptions + * Options of the form accepted by addItem. + * @return Variable + * The newly created child Variable. + */ + _createChild(aName, aDescriptor, aOptions) { + return new Variable(this, aName, aDescriptor, aOptions); + }, + + /** + * Adds a child to contain any inspected properties. + * + * @param string aName + * The child's name. + * @param object aDescriptor + * Specifies the value and/or type & class of the child, + * or 'get' & 'set' accessor properties. If the type is implicit, + * it will be inferred from the value. If this parameter is omitted, + * a property without a value will be added (useful for branch nodes). + * e.g. - { value: 42 } + * - { value: true } + * - { value: "nasu" } + * - { value: { type: "undefined" } } + * - { value: { type: "null" } } + * - { value: { type: "object", class: "Object" } } + * - { get: { type: "object", class: "Function" }, + * set: { type: "undefined" } } + * @param object aOptions + * Specifies some options affecting the new variable. + * Recognized properties are + * * boolean relaxed true if name duplicates should be allowed. + * You probably shouldn't do it. Use this + * with caution. + * * boolean internalItem true if the item is internally generated. + * This is used for special variables + * like <return> or <exception> and distinguishes + * them from ordinary properties that happen + * to have the same name + * @return Variable + * The newly created Variable instance, null if it already exists. + */ + addItem(aName, aDescriptor = {}, aOptions = {}) { + const { relaxed } = aOptions; + if (this._store.has(aName) && !relaxed) { + return this._store.get(aName); + } + + const child = this._createChild(aName, aDescriptor, aOptions); + this._store.set(aName, child); + this._variablesView._itemsByElement.set(child._target, child); + this._variablesView._currHierarchy.set(child.absoluteName, child); + child.header = aName !== undefined; + + return child; + }, + + /** + * Adds items for this variable. + * + * @param object aItems + * An object containing some { name: descriptor } data properties, + * specifying the value and/or type & class of the variable, + * or 'get' & 'set' accessor properties. If the type is implicit, + * it will be inferred from the value. + * e.g. - { someProp0: { value: 42 }, + * someProp1: { value: true }, + * someProp2: { value: "nasu" }, + * someProp3: { value: { type: "undefined" } }, + * someProp4: { value: { type: "null" } }, + * someProp5: { value: { type: "object", class: "Object" } }, + * someProp6: { get: { type: "object", class: "Function" }, + * set: { type: "undefined" } } } + * @param object aOptions [optional] + * Additional options for adding the properties. Supported options: + * - sorted: true to sort all the properties before adding them + * - callback: function invoked after each item is added + */ + addItems(aItems, aOptions = {}) { + const names = Object.keys(aItems); + + // Sort all of the properties before adding them, if preferred. + if (aOptions.sorted) { + names.sort(this._naturalSort); + } + + // Add the properties to the current scope. + for (const name of names) { + const descriptor = aItems[name]; + const item = this.addItem(name, descriptor); + + if (aOptions.callback) { + aOptions.callback(item, descriptor && descriptor.value); + } + } + }, + + /** + * Remove this Scope from its parent and remove all children recursively. + */ + remove() { + const view = this._variablesView; + view._store.splice(view._store.indexOf(this), 1); + view._itemsByElement.delete(this._target); + view._currHierarchy.delete(this._nameString); + + this._target.remove(); + + for (const variable of this._store.values()) { + variable.remove(); + } + }, + + /** + * Gets the variable in this container having the specified name. + * + * @param string aName + * The name of the variable to get. + * @return Variable + * The matched variable, or null if nothing is found. + */ + get(aName) { + return this._store.get(aName); + }, + + /** + * Recursively searches for the variable or property in this container + * displayed by the specified node. + * + * @param Node aNode + * The node to search for. + * @return Variable | Property + * The matched variable or property, or null if nothing is found. + */ + find(aNode) { + for (const [, variable] of this._store) { + let match; + if (variable._target == aNode) { + match = variable; + } else { + match = variable.find(aNode); + } + if (match) { + return match; + } + } + return null; + }, + + /** + * Determines if this scope is a direct child of a parent variables view, + * scope, variable or property. + * + * @param VariablesView | Scope | Variable | Property + * The parent to check. + * @return boolean + * True if the specified item is a direct child, false otherwise. + */ + isChildOf(aParent) { + return this.ownerView == aParent; + }, + + /** + * Determines if this scope is a descendant of a parent variables view, + * scope, variable or property. + * + * @param VariablesView | Scope | Variable | Property + * The parent to check. + * @return boolean + * True if the specified item is a descendant, false otherwise. + */ + isDescendantOf(aParent) { + if (this.isChildOf(aParent)) { + return true; + } + + // Recurse to parent if it is a Scope, Variable, or Property. + if (this.ownerView instanceof Scope) { + return this.ownerView.isDescendantOf(aParent); + } + + return false; + }, + + /** + * Shows the scope. + */ + show() { + this._target.hidden = false; + this._isContentVisible = true; + + if (this.onshow) { + this.onshow(this); + } + }, + + /** + * Hides the scope. + */ + hide() { + this._target.hidden = true; + this._isContentVisible = false; + + if (this.onhide) { + this.onhide(this); + } + }, + + /** + * Expands the scope, showing all the added details. + */ + async expand() { + if (this._isExpanded || this._isLocked) { + return; + } + if (this._variablesView._enumVisible) { + this._openEnum(); + } + if (this._variablesView._nonEnumVisible) { + Services.tm.dispatchToMainThread({ run: this._openNonEnum }); + } + this._isExpanded = true; + + if (this.onexpand) { + // We return onexpand as it sometimes returns a promise + // (up to the user of VariableView to do it) + // that can indicate when the view is done expanding + // and attributes are available. (Mostly used for tests) + await this.onexpand(this); + } + }, + + /** + * Collapses the scope, hiding all the added details. + */ + collapse() { + if (!this._isExpanded || this._isLocked) { + return; + } + this._arrow.removeAttribute("open"); + this._enum.removeAttribute("open"); + this._nonenum.removeAttribute("open"); + this._isExpanded = false; + + if (this.oncollapse) { + this.oncollapse(this); + } + }, + + /** + * Toggles between the scope's collapsed and expanded state. + */ + toggle(e) { + if (e && e.button != 0) { + // Only allow left-click to trigger this event. + return; + } + this.expanded ^= 1; + + // Make sure the scope and its contents are visibile. + for (const [, variable] of this._store) { + variable.header = true; + variable._matched = true; + } + if (this.ontoggle) { + this.ontoggle(this); + } + }, + + /** + * Shows the scope's title header. + */ + showHeader() { + if (this._isHeaderVisible || !this._nameString) { + return; + } + this._target.removeAttribute("untitled"); + this._isHeaderVisible = true; + }, + + /** + * Hides the scope's title header. + * This action will automatically expand the scope. + */ + hideHeader() { + if (!this._isHeaderVisible) { + return; + } + this.expand(); + this._target.setAttribute("untitled", ""); + this._isHeaderVisible = false; + }, + + /** + * Sort in ascending order + * This only needs to compare non-numbers since it is dealing with an array + * which numeric-based indices are placed in order. + * + * @param string a + * @param string b + * @return number + * -1 if a is less than b, 0 if no change in order, +1 if a is greater than 0 + */ + _naturalSort(a, b) { + if (isNaN(parseFloat(a)) && isNaN(parseFloat(b))) { + return a < b ? -1 : 1; + } + return 0; + }, + + /** + * Shows the scope's expand/collapse arrow. + */ + showArrow() { + if (this._isArrowVisible) { + return; + } + this._arrow.removeAttribute("invisible"); + this._isArrowVisible = true; + }, + + /** + * Hides the scope's expand/collapse arrow. + */ + hideArrow() { + if (!this._isArrowVisible) { + return; + } + this._arrow.setAttribute("invisible", ""); + this._isArrowVisible = false; + }, + + /** + * Gets the visibility state. + * @return boolean + */ + get visible() { + return this._isContentVisible; + }, + + /** + * Gets the expanded state. + * @return boolean + */ + get expanded() { + return this._isExpanded; + }, + + /** + * Gets the header visibility state. + * @return boolean + */ + get header() { + return this._isHeaderVisible; + }, + + /** + * Gets the twisty visibility state. + * @return boolean + */ + get twisty() { + return this._isArrowVisible; + }, + + /** + * Gets the expand lock state. + * @return boolean + */ + get locked() { + return this._isLocked; + }, + + /** + * Sets the visibility state. + * @param boolean aFlag + */ + set visible(aFlag) { + aFlag ? this.show() : this.hide(); + }, + + /** + * Sets the expanded state. + * @param boolean aFlag + */ + set expanded(aFlag) { + aFlag ? this.expand() : this.collapse(); + }, + + /** + * Sets the header visibility state. + * @param boolean aFlag + */ + set header(aFlag) { + aFlag ? this.showHeader() : this.hideHeader(); + }, + + /** + * Sets the twisty visibility state. + * @param boolean aFlag + */ + set twisty(aFlag) { + aFlag ? this.showArrow() : this.hideArrow(); + }, + + /** + * Sets the expand lock state. + * @param boolean aFlag + */ + set locked(aFlag) { + this._isLocked = aFlag; + }, + + /** + * Specifies if this target node may be focused. + * @return boolean + */ + get focusable() { + // Check if this target node is actually visibile. + if ( + !this._nameString || + !this._isContentVisible || + !this._isHeaderVisible || + !this._isMatch + ) { + return false; + } + // Check if all parent objects are expanded. + let item = this; + + // Recurse while parent is a Scope, Variable, or Property + while ((item = item.ownerView) && item instanceof Scope) { + if (!item._isExpanded) { + return false; + } + } + return true; + }, + + /** + * Focus this scope. + */ + focus() { + this._variablesView._focusItem(this); + }, + + /** + * Adds an event listener for a certain event on this scope's title. + * @param string aName + * @param function aCallback + * @param boolean aCapture + */ + addEventListener(aName, aCallback, aCapture) { + this._title.addEventListener(aName, aCallback, aCapture); + }, + + /** + * Removes an event listener for a certain event on this scope's title. + * @param string aName + * @param function aCallback + * @param boolean aCapture + */ + removeEventListener(aName, aCallback, aCapture) { + this._title.removeEventListener(aName, aCallback, aCapture); + }, + + /** + * Gets the id associated with this item. + * @return string + */ + get id() { + return this._idString; + }, + + /** + * Gets the name associated with this item. + * @return string + */ + get name() { + return this._nameString; + }, + + /** + * Gets the displayed value for this item. + * @return string + */ + get displayValue() { + return this._valueString; + }, + + /** + * Gets the class names used for the displayed value. + * @return string + */ + get displayValueClassName() { + return this._valueClassName; + }, + + /** + * Gets the element associated with this item. + * @return Node + */ + get target() { + return this._target; + }, + + /** + * Initializes this scope's id, view and binds event listeners. + * + * @param string l10nId + * The scope localized string id. + * @param object aFlags [optional] + * Additional options or flags for this scope. + */ + _init(l10nId, aFlags) { + this._idString = generateId((this._nameString = l10nId)); + this._displayScope({ + l10nId, + targetClassName: `${this.targetClassName} ${aFlags.customClass}`, + titleClassName: "devtools-toolbar", + }); + this._addEventListeners(); + this.parentNode.appendChild(this._target); + }, + + /** + * Creates the necessary nodes for this scope. + * + * @param Object options + * @param string options.l10nId [optional] + * The scope localized string id. + * @param string options.value [optional] + * The scope's name. Either this or l10nId need to be passed + * @param string options.targetClassName + * A custom class name for this scope's target element. + * @param string options.titleClassName [optional] + * A custom class name for this scope's title element. + */ + _displayScope({ l10nId, value, targetClassName, titleClassName = "" }) { + const document = this.document; + + const element = (this._target = document.createXULElement("vbox")); + element.id = this._idString; + element.className = targetClassName; + + const arrow = (this._arrow = document.createXULElement("hbox")); + arrow.className = "arrow theme-twisty"; + + const name = (this._name = document.createXULElement("label")); + name.className = "plain name"; + if (l10nId) { + document.l10n.setAttributes(name, l10nId); + } else { + name.setAttribute("value", value); + } + name.setAttribute("crop", "end"); + + const title = (this._title = document.createXULElement("hbox")); + title.className = "title " + titleClassName; + title.setAttribute("align", "center"); + + const enumerable = (this._enum = document.createXULElement("vbox")); + const nonenum = (this._nonenum = document.createXULElement("vbox")); + enumerable.className = "variables-view-element-details enum"; + nonenum.className = "variables-view-element-details nonenum"; + + title.appendChild(arrow); + title.appendChild(name); + + element.appendChild(title); + element.appendChild(enumerable); + element.appendChild(nonenum); + }, + + /** + * Adds the necessary event listeners for this scope. + */ + _addEventListeners() { + this._title.addEventListener("mousedown", this._onClick); + }, + + /** + * The click listener for this scope's title. + */ + _onClick(e) { + if ( + this.editing || + e.button != 0 || + e.target == this._editNode || + e.target == this._deleteNode || + e.target == this._addPropertyNode + ) { + return; + } + this.toggle(); + this.focus(); + }, + + /** + * Opens the enumerable items container. + */ + _openEnum() { + this._arrow.setAttribute("open", ""); + this._enum.setAttribute("open", ""); + }, + + /** + * Opens the non-enumerable items container. + */ + _openNonEnum() { + this._nonenum.setAttribute("open", ""); + }, + + /** + * Specifies if enumerable properties and variables should be displayed. + * @param boolean aFlag + */ + set _enumVisible(aFlag) { + for (const [, variable] of this._store) { + variable._enumVisible = aFlag; + + if (!this._isExpanded) { + continue; + } + if (aFlag) { + this._enum.setAttribute("open", ""); + } else { + this._enum.removeAttribute("open"); + } + } + }, + + /** + * Specifies if non-enumerable properties and variables should be displayed. + * @param boolean aFlag + */ + set _nonEnumVisible(aFlag) { + for (const [, variable] of this._store) { + variable._nonEnumVisible = aFlag; + + if (!this._isExpanded) { + continue; + } + if (aFlag) { + this._nonenum.setAttribute("open", ""); + } else { + this._nonenum.removeAttribute("open"); + } + } + }, + + /** + * Performs a case insensitive search for variables or properties matching + * the query, and hides non-matched items. + * + * @param string aLowerCaseQuery + * The lowercased name of the variable or property to search for. + */ + _performSearch(aLowerCaseQuery) { + for (let [, variable] of this._store) { + const currentObject = variable; + const lowerCaseName = variable._nameString.toLowerCase(); + const lowerCaseValue = variable._valueString.toLowerCase(); + + // Non-matched variables or properties require a corresponding attribute. + if ( + !lowerCaseName.includes(aLowerCaseQuery) && + !lowerCaseValue.includes(aLowerCaseQuery) + ) { + variable._matched = false; + } else { + // Variable or property is matched. + variable._matched = true; + + // If the variable was ever expanded, there's a possibility it may + // contain some matched properties, so make sure they're visible + // ("expand downwards"). + if (variable._store.size) { + variable.expand(); + } + + // If the variable is contained in another Scope, Variable, or Property, + // the parent may not be a match, thus hidden. It should be visible + // ("expand upwards"). + while ((variable = variable.ownerView) && variable instanceof Scope) { + variable._matched = true; + variable.expand(); + } + } + + // Proceed with the search recursively inside this variable or property. + if ( + currentObject._store.size || + currentObject.getter || + currentObject.setter + ) { + currentObject._performSearch(aLowerCaseQuery); + } + } + }, + + /** + * Sets if this object instance is a matched or non-matched item. + * @param boolean aStatus + */ + set _matched(aStatus) { + if (this._isMatch == aStatus) { + return; + } + if (aStatus) { + this._isMatch = true; + this.target.removeAttribute("unmatched"); + } else { + this._isMatch = false; + this.target.setAttribute("unmatched", ""); + } + }, + + /** + * Find the first item in the tree of visible items in this item that matches + * the predicate. Searches in visual order (the order seen by the user). + * Tests itself, then descends into first the enumerable children and then + * the non-enumerable children (since they are presented in separate groups). + * + * @param function aPredicate + * A function that returns true when a match is found. + * @return Scope | Variable | Property + * The first visible scope, variable or property, or null if nothing + * is found. + */ + _findInVisibleItems(aPredicate) { + if (aPredicate(this)) { + return this; + } + + if (this._isExpanded) { + if (this._variablesView._enumVisible) { + for (const item of this._enumItems) { + const result = item._findInVisibleItems(aPredicate); + if (result) { + return result; + } + } + } + + if (this._variablesView._nonEnumVisible) { + for (const item of this._nonEnumItems) { + const result = item._findInVisibleItems(aPredicate); + if (result) { + return result; + } + } + } + } + + return null; + }, + + /** + * Find the last item in the tree of visible items in this item that matches + * the predicate. Searches in reverse visual order (opposite of the order + * seen by the user). Descends into first the non-enumerable children, then + * the enumerable children (since they are presented in separate groups), and + * finally tests itself. + * + * @param function aPredicate + * A function that returns true when a match is found. + * @return Scope | Variable | Property + * The last visible scope, variable or property, or null if nothing + * is found. + */ + _findInVisibleItemsReverse(aPredicate) { + if (this._isExpanded) { + if (this._variablesView._nonEnumVisible) { + for (let i = this._nonEnumItems.length - 1; i >= 0; i--) { + const item = this._nonEnumItems[i]; + const result = item._findInVisibleItemsReverse(aPredicate); + if (result) { + return result; + } + } + } + + if (this._variablesView._enumVisible) { + for (let i = this._enumItems.length - 1; i >= 0; i--) { + const item = this._enumItems[i]; + const result = item._findInVisibleItemsReverse(aPredicate); + if (result) { + return result; + } + } + } + } + + if (aPredicate(this)) { + return this; + } + + return null; + }, + + /** + * Gets top level variables view instance. + * @return VariablesView + */ + get _variablesView() { + return ( + this._topView || + (this._topView = (() => { + let parentView = this.ownerView; + let topView; + + while ((topView = parentView.ownerView)) { + parentView = topView; + } + return parentView; + })()) + ); + }, + + /** + * Gets the parent node holding this scope. + * @return Node + */ + get parentNode() { + return this.ownerView._list; + }, + + /** + * Gets the owner document holding this scope. + * @return HTMLDocument + */ + get document() { + return this._document || (this._document = this.ownerView.document); + }, + + /** + * Gets the default window holding this scope. + * @return nsIDOMWindow + */ + get window() { + return this._window || (this._window = this.ownerView.window); + }, + + _topView: null, + _document: null, + _window: null, + + ownerView: null, + eval: null, + switch: null, + delete: null, + new: null, + preventDisableOnChange: false, + preventDescriptorModifiers: false, + editing: false, + editableNameTooltip: "", + editableValueTooltip: "", + editButtonTooltip: "", + deleteButtonTooltip: "", + domNodeValueTooltip: "", + contextMenuId: "", + separatorStr: "", + + _store: null, + _enumItems: null, + _nonEnumItems: null, + _fetched: false, + _committed: false, + _isLocked: false, + _isExpanded: false, + _isContentVisible: true, + _isHeaderVisible: true, + _isArrowVisible: true, + _isMatch: true, + _idString: "", + _nameString: "", + _target: null, + _arrow: null, + _name: null, + _title: null, + _enum: null, + _nonenum: null, +}; + +// Creating maps and arrays thousands of times for variables or properties +// with a large number of children fills up a lot of memory. Make sure +// these are instantiated only if needed. +DevToolsUtils.defineLazyPrototypeGetter( + Scope.prototype, + "_store", + () => new Map() +); +DevToolsUtils.defineLazyPrototypeGetter(Scope.prototype, "_enumItems", Array); +DevToolsUtils.defineLazyPrototypeGetter( + Scope.prototype, + "_nonEnumItems", + Array +); + +/** + * A Variable is a Scope holding Property instances. + * Iterable via "for (let [name, property] of instance) { }". + * + * @param Scope aScope + * The scope to contain this variable. + * @param string aName + * The variable's name. + * @param object aDescriptor + * The variable's descriptor. + * @param object aOptions + * Options of the form accepted by Scope.addItem + */ +function Variable(aScope, aName, aDescriptor, aOptions) { + this._setTooltips = this._setTooltips.bind(this); + this._activateNameInput = this._activateNameInput.bind(this); + this._activateValueInput = this._activateValueInput.bind(this); + this.openNodeInInspector = this.openNodeInInspector.bind(this); + this.highlightDomNode = this.highlightDomNode.bind(this); + this.unhighlightDomNode = this.unhighlightDomNode.bind(this); + this._internalItem = aOptions.internalItem; + + // Treat safe getter descriptors as descriptors with a value. + if ("getterValue" in aDescriptor) { + aDescriptor.value = aDescriptor.getterValue; + delete aDescriptor.get; + delete aDescriptor.set; + } + + Scope.call(this, aScope, aName, (this._initialDescriptor = aDescriptor)); + this.setGrip(aDescriptor.value); +} + +Variable.prototype = extend(Scope.prototype, { + /** + * Whether this Variable should be prefetched when it is remoted. + */ + get shouldPrefetch() { + return this.name == "window" || this.name == "this"; + }, + + /** + * Whether this Variable should paginate its contents. + */ + get allowPaginate() { + return this.name != "window" && this.name != "this"; + }, + + /** + * The class name applied to this variable's target element. + */ + targetClassName: "variables-view-variable variable-or-property", + + /** + * Create a new Property that is a child of Variable. + * + * @param string aName + * The name of the new Property. + * @param object aDescriptor + * The property's descriptor. + * @param object aOptions + * Options of the form accepted by Scope.addItem + * @return Property + * The newly created child Property. + */ + _createChild(aName, aDescriptor, aOptions) { + return new Property(this, aName, aDescriptor, aOptions); + }, + + /** + * Remove this Variable from its parent and remove all children recursively. + */ + remove() { + if (this._linkedToInspector) { + this.unhighlightDomNode(); + this._valueLabel.removeEventListener("mouseover", this.highlightDomNode); + this._valueLabel.removeEventListener("mouseout", this.unhighlightDomNode); + this._openInspectorNode.removeEventListener( + "mousedown", + this.openNodeInInspector + ); + } + + this.ownerView._store.delete(this._nameString); + this._variablesView._itemsByElement.delete(this._target); + this._variablesView._currHierarchy.delete(this.absoluteName); + + this._target.remove(); + + for (const property of this._store.values()) { + property.remove(); + } + }, + + /** + * Populates this variable to contain all the properties of an object. + * + * @param object aObject + * The raw object you want to display. + * @param object aOptions [optional] + * Additional options for adding the properties. Supported options: + * - sorted: true to sort all the properties before adding them + * - expanded: true to expand all the properties after adding them + */ + populate(aObject, aOptions = {}) { + // Retrieve the properties only once. + if (this._fetched) { + return; + } + this._fetched = true; + + const propertyNames = Object.getOwnPropertyNames(aObject); + const prototype = Object.getPrototypeOf(aObject); + + // Sort all of the properties before adding them, if preferred. + if (aOptions.sorted) { + propertyNames.sort(this._naturalSort); + } + + // Add all the variable properties. + for (const name of propertyNames) { + const descriptor = Object.getOwnPropertyDescriptor(aObject, name); + if (descriptor.get || descriptor.set) { + const prop = this._addRawNonValueProperty(name, descriptor); + if (aOptions.expanded) { + prop.expanded = true; + } + } else { + const prop = this._addRawValueProperty(name, descriptor, aObject[name]); + if (aOptions.expanded) { + prop.expanded = true; + } + } + } + // Add the variable's __proto__. + if (prototype) { + this._addRawValueProperty("__proto__", {}, prototype); + } + }, + + /** + * Populates a specific variable or property instance to contain all the + * properties of an object + * + * @param Variable | Property aVar + * The target variable to populate. + * @param object aObject [optional] + * The raw object you want to display. If unspecified, the object is + * assumed to be defined in a _sourceValue property on the target. + */ + _populateTarget(aVar, aObject = aVar._sourceValue) { + aVar.populate(aObject); + }, + + /** + * Adds a property for this variable based on a raw value descriptor. + * + * @param string aName + * The property's name. + * @param object aDescriptor + * Specifies the exact property descriptor as returned by a call to + * Object.getOwnPropertyDescriptor. + * @param object aValue + * The raw property value you want to display. + * @return Property + * The newly added property instance. + */ + _addRawValueProperty(aName, aDescriptor, aValue) { + const descriptor = Object.create(aDescriptor); + descriptor.value = VariablesView.getGrip(aValue); + + const propertyItem = this.addItem(aName, descriptor); + propertyItem._sourceValue = aValue; + + // Add an 'onexpand' callback for the property, lazily handling + // the addition of new child properties. + if (!VariablesView.isPrimitive(descriptor)) { + propertyItem.onexpand = this._populateTarget; + } + return propertyItem; + }, + + /** + * Adds a property for this variable based on a getter/setter descriptor. + * + * @param string aName + * The property's name. + * @param object aDescriptor + * Specifies the exact property descriptor as returned by a call to + * Object.getOwnPropertyDescriptor. + * @return Property + * The newly added property instance. + */ + _addRawNonValueProperty(aName, aDescriptor) { + const descriptor = Object.create(aDescriptor); + descriptor.get = VariablesView.getGrip(aDescriptor.get); + descriptor.set = VariablesView.getGrip(aDescriptor.set); + + return this.addItem(aName, descriptor); + }, + + /** + * Gets this variable's path to the topmost scope in the form of a string + * meant for use via eval() or a similar approach. + * For example, a symbolic name may look like "arguments['0']['foo']['bar']". + * @return string + */ + get symbolicName() { + return this._nameString || ""; + }, + + /** + * Gets full path to this variable, including name of the scope. + * @return string + */ + get absoluteName() { + if (this._absoluteName) { + return this._absoluteName; + } + + this._absoluteName = + this.ownerView._nameString + "[" + escapeString(this._nameString) + "]"; + return this._absoluteName; + }, + + /** + * Gets this variable's symbolic path to the topmost scope. + * @return array + * @see Variable._buildSymbolicPath + */ + get symbolicPath() { + if (this._symbolicPath) { + return this._symbolicPath; + } + this._symbolicPath = this._buildSymbolicPath(); + return this._symbolicPath; + }, + + /** + * Build this variable's path to the topmost scope in form of an array of + * strings, one for each segment of the path. + * For example, a symbolic path may look like ["0", "foo", "bar"]. + * @return array + */ + _buildSymbolicPath(path = []) { + if (this.name) { + path.unshift(this.name); + if (this.ownerView instanceof Variable) { + return this.ownerView._buildSymbolicPath(path); + } + } + return path; + }, + + /** + * Returns this variable's value from the descriptor if available. + * @return any + */ + get value() { + return this._initialDescriptor.value; + }, + + /** + * Returns this variable's getter from the descriptor if available. + * @return object + */ + get getter() { + return this._initialDescriptor.get; + }, + + /** + * Returns this variable's getter from the descriptor if available. + * @return object + */ + get setter() { + return this._initialDescriptor.set; + }, + + /** + * Sets the specific grip for this variable (applies the text content and + * class name to the value label). + * + * The grip should contain the value or the type & class, as defined in the + * remote debugger protocol. For convenience, undefined and null are + * both considered types. + * + * @param any aGrip + * Specifies the value and/or type & class of the variable. + * e.g. - 42 + * - true + * - "nasu" + * - { type: "undefined" } + * - { type: "null" } + * - { type: "object", class: "Object" } + */ + setGrip(aGrip) { + // Don't allow displaying grip information if there's no name available + // or the grip is malformed. + if ( + this._nameString === undefined || + aGrip === undefined || + aGrip === null + ) { + return; + } + // Getters and setters should display grip information in sub-properties. + if (this.getter || this.setter) { + return; + } + + const prevGrip = this._valueGrip; + if (prevGrip) { + this._valueLabel.classList.remove(VariablesView.getClass(prevGrip)); + } + this._valueGrip = aGrip; + + if ( + aGrip && + (aGrip.optimizedOut || aGrip.uninitialized || aGrip.missingArguments) + ) { + if (aGrip.optimizedOut) { + this._valueString = L10N.getStr("variablesViewOptimizedOut"); + } else if (aGrip.uninitialized) { + this._valueString = L10N.getStr("variablesViewUninitialized"); + } else if (aGrip.missingArguments) { + this._valueString = L10N.getStr("variablesViewMissingArgs"); + } + this.eval = null; + } else { + this._valueString = VariablesView.getString(aGrip, { + concise: true, + noEllipsis: true, + }); + this.eval = this.ownerView.eval; + } + + this._valueClassName = VariablesView.getClass(aGrip); + + this._valueLabel.classList.add(this._valueClassName); + this._valueLabel.setAttribute("value", this._valueString); + this._separatorLabel.hidden = false; + + // DOMNodes get special treatment since they can be linked to the inspector + if (this._valueGrip.preview && this._valueGrip.preview.kind === "DOMNode") { + this._linkToInspector(); + } + }, + + /** + * Marks this variable as overridden. + * + * @param boolean aFlag + * Whether this variable is overridden or not. + */ + setOverridden(aFlag) { + if (aFlag) { + this._target.setAttribute("overridden", ""); + } else { + this._target.removeAttribute("overridden"); + } + }, + + /** + * Briefly flashes this variable. + * + * @param number aDuration [optional] + * An optional flash animation duration. + */ + flash(aDuration = ITEM_FLASH_DURATION) { + const fadeInDelay = this._variablesView.lazyEmptyDelay + 1; + const fadeOutDelay = fadeInDelay + aDuration; + + setNamedTimeout("vview-flash-in" + this.absoluteName, fadeInDelay, () => + this._target.setAttribute("changed", "") + ); + + setNamedTimeout("vview-flash-out" + this.absoluteName, fadeOutDelay, () => + this._target.removeAttribute("changed") + ); + }, + + /** + * Initializes this variable's id, view and binds event listeners. + * + * @param string aName + * The variable's name. + * @param object aDescriptor + * The variable's descriptor. + */ + _init(aName, aDescriptor) { + this._idString = generateId((this._nameString = aName)); + this._displayScope({ value: aName, targetClassName: this.targetClassName }); + this._displayVariable(); + this._customizeVariable(); + this._prepareTooltips(); + this._setAttributes(); + this._addEventListeners(); + + if ( + this._initialDescriptor.enumerable || + this._nameString == "this" || + this._internalItem + ) { + this.ownerView._enum.appendChild(this._target); + this.ownerView._enumItems.push(this); + } else { + this.ownerView._nonenum.appendChild(this._target); + this.ownerView._nonEnumItems.push(this); + } + }, + + /** + * Creates the necessary nodes for this variable. + */ + _displayVariable() { + const document = this.document; + const descriptor = this._initialDescriptor; + + const separatorLabel = (this._separatorLabel = + document.createXULElement("label")); + separatorLabel.className = "plain separator"; + separatorLabel.setAttribute("value", this.separatorStr + " "); + + const valueLabel = (this._valueLabel = document.createXULElement("label")); + valueLabel.className = "plain value"; + valueLabel.setAttribute("flex", "1"); + valueLabel.setAttribute("crop", "center"); + + this._title.appendChild(separatorLabel); + this._title.appendChild(valueLabel); + + if (VariablesView.isPrimitive(descriptor)) { + this.hideArrow(); + } + + // If no value will be displayed, we don't need the separator. + if (!descriptor.get && !descriptor.set && !("value" in descriptor)) { + separatorLabel.hidden = true; + } + + // If this is a getter/setter property, create two child pseudo-properties + // called "get" and "set" that display the corresponding functions. + if (descriptor.get || descriptor.set) { + separatorLabel.hidden = true; + valueLabel.hidden = true; + + // Changing getter/setter names is never allowed. + this.switch = null; + + // Getter/setter properties require special handling when it comes to + // evaluation and deletion. + if (this.ownerView.eval) { + this.delete = VariablesView.getterOrSetterDeleteCallback; + this.evaluationMacro = VariablesView.overrideValueEvalMacro; + } else { + // Deleting getters and setters individually is not allowed if no + // evaluation method is provided. + this.delete = null; + this.evaluationMacro = null; + } + + const getter = this.addItem("get", { value: descriptor.get }); + const setter = this.addItem("set", { value: descriptor.set }); + getter.evaluationMacro = VariablesView.getterOrSetterEvalMacro; + setter.evaluationMacro = VariablesView.getterOrSetterEvalMacro; + + getter.hideArrow(); + setter.hideArrow(); + this.expand(); + } + }, + + /** + * Adds specific nodes for this variable based on custom flags. + */ + _customizeVariable() { + const ownerView = this.ownerView; + const descriptor = this._initialDescriptor; + + if ((ownerView.eval && this.getter) || this.setter) { + const editNode = (this._editNode = + this.document.createXULElement("toolbarbutton")); + editNode.className = "plain variables-view-edit"; + editNode.addEventListener("mousedown", this._onEdit.bind(this)); + this._title.insertBefore(editNode, this._spacer); + } + + if (ownerView.delete) { + const deleteNode = (this._deleteNode = + this.document.createXULElement("toolbarbutton")); + deleteNode.className = "plain variables-view-delete"; + deleteNode.addEventListener("click", this._onDelete.bind(this)); + this._title.appendChild(deleteNode); + } + + if (ownerView.new) { + const addPropertyNode = (this._addPropertyNode = + this.document.createXULElement("toolbarbutton")); + addPropertyNode.className = "plain variables-view-add-property"; + addPropertyNode.addEventListener( + "mousedown", + this._onAddProperty.bind(this) + ); + this._title.appendChild(addPropertyNode); + + // Can't add properties to primitive values, hide the node in those cases. + if (VariablesView.isPrimitive(descriptor)) { + addPropertyNode.setAttribute("invisible", ""); + } + } + + if (ownerView.contextMenuId) { + this._title.setAttribute("context", ownerView.contextMenuId); + } + + if (ownerView.preventDescriptorModifiers) { + return; + } + + if (!descriptor.writable && !ownerView.getter && !ownerView.setter) { + const nonWritableIcon = this.document.createXULElement("hbox"); + nonWritableIcon.className = + "plain variable-or-property-non-writable-icon"; + nonWritableIcon.setAttribute("optional-visibility", ""); + this._title.appendChild(nonWritableIcon); + } + if (descriptor.value && typeof descriptor.value == "object") { + if (descriptor.value.frozen) { + const frozenLabel = this.document.createXULElement("label"); + frozenLabel.className = "plain variable-or-property-frozen-label"; + frozenLabel.setAttribute("optional-visibility", ""); + frozenLabel.setAttribute("value", "F"); + this._title.appendChild(frozenLabel); + } + if (descriptor.value.sealed) { + const sealedLabel = this.document.createXULElement("label"); + sealedLabel.className = "plain variable-or-property-sealed-label"; + sealedLabel.setAttribute("optional-visibility", ""); + sealedLabel.setAttribute("value", "S"); + this._title.appendChild(sealedLabel); + } + if (!descriptor.value.extensible) { + const nonExtensibleLabel = this.document.createXULElement("label"); + nonExtensibleLabel.className = + "plain variable-or-property-non-extensible-label"; + nonExtensibleLabel.setAttribute("optional-visibility", ""); + nonExtensibleLabel.setAttribute("value", "N"); + this._title.appendChild(nonExtensibleLabel); + } + } + }, + + /** + * Prepares all tooltips for this variable. + */ + _prepareTooltips() { + this._target.addEventListener("mouseover", this._setTooltips); + }, + + /** + * Sets all tooltips for this variable. + */ + _setTooltips() { + this._target.removeEventListener("mouseover", this._setTooltips); + + const ownerView = this.ownerView; + if (ownerView.preventDescriptorModifiers) { + return; + } + + const tooltip = this.document.createXULElement("tooltip"); + tooltip.id = "tooltip-" + this._idString; + tooltip.setAttribute("orient", "horizontal"); + + const labels = [ + "configurable", + "enumerable", + "writable", + "frozen", + "sealed", + "extensible", + "overridden", + "WebIDL", + ]; + + for (const type of labels) { + const labelElement = this.document.createXULElement("label"); + labelElement.className = type; + labelElement.setAttribute("value", L10N.getStr(type + "Tooltip")); + tooltip.appendChild(labelElement); + } + + this._target.appendChild(tooltip); + this._target.setAttribute("tooltip", tooltip.id); + + if (this._editNode && ownerView.eval) { + this._editNode.setAttribute("tooltiptext", ownerView.editButtonTooltip); + } + if (this._openInspectorNode && this._linkedToInspector) { + this._openInspectorNode.setAttribute( + "tooltiptext", + this.ownerView.domNodeValueTooltip + ); + } + if (this._valueLabel && ownerView.eval) { + this._valueLabel.setAttribute( + "tooltiptext", + ownerView.editableValueTooltip + ); + } + if (this._name && ownerView.switch) { + this._name.setAttribute("tooltiptext", ownerView.editableNameTooltip); + } + if (this._deleteNode && ownerView.delete) { + this._deleteNode.setAttribute( + "tooltiptext", + ownerView.deleteButtonTooltip + ); + } + }, + + /** + * Get the parent variablesview toolbox, if any. + */ + get toolbox() { + return this._variablesView.toolbox; + }, + + /** + * Checks if this variable is a DOMNode and is part of a variablesview that + * has been linked to the toolbox, so that highlighting and jumping to the + * inspector can be done. + */ + _isLinkableToInspector() { + const isDomNode = + this._valueGrip && this._valueGrip.preview.kind === "DOMNode"; + const hasBeenLinked = this._linkedToInspector; + const hasToolbox = !!this.toolbox; + + return isDomNode && !hasBeenLinked && hasToolbox; + }, + + /** + * If the variable is a DOMNode, and if a toolbox is set, then link it to the + * inspector (highlight on hover, and jump to markup-view on click) + */ + _linkToInspector() { + if (!this._isLinkableToInspector()) { + return; + } + + // Listen to value mouseover/click events to highlight and jump + this._valueLabel.addEventListener("mouseover", this.highlightDomNode); + this._valueLabel.addEventListener("mouseout", this.unhighlightDomNode); + + // Add a button to open the node in the inspector + this._openInspectorNode = this.document.createXULElement("toolbarbutton"); + this._openInspectorNode.className = "plain variables-view-open-inspector"; + this._openInspectorNode.addEventListener( + "mousedown", + this.openNodeInInspector + ); + this._title.appendChild(this._openInspectorNode); + + this._linkedToInspector = true; + }, + + /** + * In case this variable is a DOMNode and part of a variablesview that has been + * linked to the toolbox's inspector, then select the corresponding node in + * the inspector, and switch the inspector tool in the toolbox + * @return a promise that resolves when the node is selected and the inspector + * has been switched to and is ready + */ + openNodeInInspector(event) { + if (!this.toolbox) { + return Promise.reject(new Error("Toolbox not available")); + } + + event && event.stopPropagation(); + + return async function () { + let nodeFront = this._nodeFront; + if (!nodeFront) { + const inspectorFront = await this.toolbox.target.getFront("inspector"); + nodeFront = await inspectorFront.getNodeFrontFromNodeGrip( + this._valueGrip + ); + } + + if (nodeFront) { + await this.toolbox.selectTool("inspector"); + + const inspectorReady = new Promise(resolve => { + this.toolbox.getPanel("inspector").once("inspector-updated", resolve); + }); + + await this.toolbox.selection.setNodeFront(nodeFront, { + reason: "variables-view", + }); + await inspectorReady; + } + }.bind(this)(); + }, + + /** + * In case this variable is a DOMNode and part of a variablesview that has been + * linked to the toolbox's inspector, then highlight the corresponding node + */ + async highlightDomNode() { + if (!this.toolbox) { + return; + } + + if (!this._nodeFront) { + const inspectorFront = await this.toolbox.target.getFront("inspector"); + this._nodeFront = await inspectorFront.getNodeFrontFromNodeGrip( + this._valueGrip + ); + } + + await this.toolbox.getHighlighter().highlight(this._nodeFront); + }, + + /** + * Unhighlight a previously highlit node + * @see highlightDomNode + */ + unhighlightDomNode() { + if (!this.toolbox) { + return; + } + + this.toolbox.getHighlighter().unhighlight(); + }, + + /** + * Sets a variable's configurable, enumerable and writable attributes, + * and specifies if it's a 'this', '<exception>', '<return>' or '__proto__' + * reference. + */ + // eslint-disable-next-line complexity + _setAttributes() { + const ownerView = this.ownerView; + if (ownerView.preventDescriptorModifiers) { + return; + } + + const descriptor = this._initialDescriptor; + const target = this._target; + const name = this._nameString; + + if (ownerView.eval) { + target.setAttribute("editable", ""); + } + + if (!descriptor.configurable) { + target.setAttribute("non-configurable", ""); + } + if (!descriptor.enumerable) { + target.setAttribute("non-enumerable", ""); + } + if (!descriptor.writable && !ownerView.getter && !ownerView.setter) { + target.setAttribute("non-writable", ""); + } + + if (descriptor.value && typeof descriptor.value == "object") { + if (descriptor.value.frozen) { + target.setAttribute("frozen", ""); + } + if (descriptor.value.sealed) { + target.setAttribute("sealed", ""); + } + if (!descriptor.value.extensible) { + target.setAttribute("non-extensible", ""); + } + } + + if (descriptor && "getterValue" in descriptor) { + target.setAttribute("safe-getter", ""); + } + + if (name == "this") { + target.setAttribute("self", ""); + } else if (this._internalItem && name == "<exception>") { + target.setAttribute("exception", ""); + target.setAttribute("pseudo-item", ""); + } else if (this._internalItem && name == "<return>") { + target.setAttribute("return", ""); + target.setAttribute("pseudo-item", ""); + } else if (name == "__proto__") { + target.setAttribute("proto", ""); + target.setAttribute("pseudo-item", ""); + } + + if (!Object.keys(descriptor).length) { + target.setAttribute("pseudo-item", ""); + } + }, + + /** + * Adds the necessary event listeners for this variable. + */ + _addEventListeners() { + this._name.addEventListener("dblclick", this._activateNameInput); + this._valueLabel.addEventListener("mousedown", this._activateValueInput); + this._title.addEventListener("mousedown", this._onClick); + }, + + /** + * Makes this variable's name editable. + */ + _activateNameInput(e) { + if (!this._variablesView.alignedValues) { + this._separatorLabel.hidden = true; + this._valueLabel.hidden = true; + } + + EditableName.create( + this, + { + onSave: aKey => { + if (!this._variablesView.preventDisableOnChange) { + this._disable(); + } + this.ownerView.switch(this, aKey); + }, + onCleanup: () => { + if (!this._variablesView.alignedValues) { + this._separatorLabel.hidden = false; + this._valueLabel.hidden = false; + } + }, + }, + e + ); + }, + + /** + * Makes this variable's value editable. + */ + _activateValueInput(e) { + EditableValue.create( + this, + { + onSave: aString => { + if (this._linkedToInspector) { + this.unhighlightDomNode(); + } + if (!this._variablesView.preventDisableOnChange) { + this._disable(); + } + this.ownerView.eval(this, aString); + }, + }, + e + ); + }, + + /** + * Disables this variable prior to a new name switch or value evaluation. + */ + _disable() { + // Prevent the variable from being collapsed or expanded. + this.hideArrow(); + + // Hide any nodes that may offer information about the variable. + for (const node of this._title.childNodes) { + node.hidden = node != this._arrow && node != this._name; + } + this._enum.hidden = true; + this._nonenum.hidden = true; + }, + + /** + * The current macro used to generate the string evaluated when performing + * a variable or property value change. + */ + evaluationMacro: VariablesView.simpleValueEvalMacro, + + /** + * The click listener for the edit button. + */ + _onEdit(e) { + if (e.button != 0) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + this._activateValueInput(); + }, + + /** + * The click listener for the delete button. + */ + _onDelete(e) { + if ("button" in e && e.button != 0) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + if (this.ownerView.delete) { + if (!this.ownerView.delete(this)) { + this.hide(); + } + } + }, + + /** + * The click listener for the add property button. + */ + _onAddProperty(e) { + if ("button" in e && e.button != 0) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + this.expanded = true; + + const item = this.addItem( + " ", + { + value: undefined, + configurable: true, + enumerable: true, + writable: true, + }, + { relaxed: true } + ); + + // Force showing the separator. + item._separatorLabel.hidden = false; + + EditableNameAndValue.create( + item, + { + onSave: ([aKey, aValue]) => { + if (!this._variablesView.preventDisableOnChange) { + this._disable(); + } + this.ownerView.new(this, aKey, aValue); + }, + }, + e + ); + }, + + _symbolicName: null, + _symbolicPath: null, + _absoluteName: null, + _initialDescriptor: null, + _separatorLabel: null, + _valueLabel: null, + _spacer: null, + _editNode: null, + _deleteNode: null, + _addPropertyNode: null, + _tooltip: null, + _valueGrip: null, + _valueString: "", + _valueClassName: "", + _prevExpandable: false, + _prevExpanded: false, +}); + +/** + * A Property is a Variable holding additional child Property instances. + * Iterable via "for (let [name, property] of instance) { }". + * + * @param Variable aVar + * The variable to contain this property. + * @param string aName + * The property's name. + * @param object aDescriptor + * The property's descriptor. + * @param object aOptions + * Options of the form accepted by Scope.addItem + */ +function Property(aVar, aName, aDescriptor, aOptions) { + Variable.call(this, aVar, aName, aDescriptor, aOptions); +} + +Property.prototype = extend(Variable.prototype, { + /** + * The class name applied to this property's target element. + */ + targetClassName: "variables-view-property variable-or-property", + + /** + * @see Variable.symbolicName + * @return string + */ + get symbolicName() { + if (this._symbolicName) { + return this._symbolicName; + } + + this._symbolicName = + this.ownerView.symbolicName + "[" + escapeString(this._nameString) + "]"; + return this._symbolicName; + }, + + /** + * @see Variable.absoluteName + * @return string + */ + get absoluteName() { + if (this._absoluteName) { + return this._absoluteName; + } + + this._absoluteName = + this.ownerView.absoluteName + "[" + escapeString(this._nameString) + "]"; + return this._absoluteName; + }, +}); + +/** + * A generator-iterator over the VariablesView, Scopes, Variables and Properties. + */ +VariablesView.prototype[Symbol.iterator] = + Scope.prototype[Symbol.iterator] = + Variable.prototype[Symbol.iterator] = + Property.prototype[Symbol.iterator] = + function* () { + yield* this._store; + }; + +/** + * Forget everything recorded about added scopes, variables or properties. + * @see VariablesView.commitHierarchy + */ +VariablesView.prototype.clearHierarchy = function () { + this._prevHierarchy.clear(); + this._currHierarchy.clear(); +}; + +/** + * Perform operations on all the VariablesView Scopes, Variables and Properties + * after you've added all the items you wanted. + * + * Calling this method is optional, and does the following: + * - styles the items overridden by other items in parent scopes + * - reopens the items which were previously expanded + * - flashes the items whose values changed + */ +VariablesView.prototype.commitHierarchy = function () { + for (const [, currItem] of this._currHierarchy) { + // Avoid performing expensive operations. + if (this.commitHierarchyIgnoredItems[currItem._nameString]) { + continue; + } + const overridden = this.isOverridden(currItem); + if (overridden) { + currItem.setOverridden(true); + } + const expanded = !currItem._committed && this.wasExpanded(currItem); + if (expanded) { + currItem.expand(); + } + const changed = !currItem._committed && this.hasChanged(currItem); + if (changed) { + currItem.flash(); + } + currItem._committed = true; + } + if (this.oncommit) { + this.oncommit(this); + } +}; + +// Some variables are likely to contain a very large number of properties. +// It would be a bad idea to re-expand them or perform expensive operations. +VariablesView.prototype.commitHierarchyIgnoredItems = extend(null, { + window: true, + this: true, +}); + +/** + * Checks if the an item was previously expanded, if it existed in a + * previous hierarchy. + * + * @param Scope | Variable | Property aItem + * The item to verify. + * @return boolean + * Whether the item was expanded. + */ +VariablesView.prototype.wasExpanded = function (aItem) { + if (!(aItem instanceof Scope)) { + return false; + } + const prevItem = this._prevHierarchy.get( + aItem.absoluteName || aItem._nameString + ); + return prevItem ? prevItem._isExpanded : false; +}; + +/** + * Checks if the an item's displayed value (a representation of the grip) + * has changed, if it existed in a previous hierarchy. + * + * @param Variable | Property aItem + * The item to verify. + * @return boolean + * Whether the item has changed. + */ +VariablesView.prototype.hasChanged = function (aItem) { + // Only analyze Variables and Properties for displayed value changes. + // Scopes are just collections of Variables and Properties and + // don't have a "value", so they can't change. + if (!(aItem instanceof Variable)) { + return false; + } + const prevItem = this._prevHierarchy.get(aItem.absoluteName); + return prevItem ? prevItem._valueString != aItem._valueString : false; +}; + +/** + * Checks if the an item was previously expanded, if it existed in a + * previous hierarchy. + * + * @param Scope | Variable | Property aItem + * The item to verify. + * @return boolean + * Whether the item was expanded. + */ +VariablesView.prototype.isOverridden = function (aItem) { + // Only analyze Variables for being overridden in different Scopes. + if (!(aItem instanceof Variable) || aItem instanceof Property) { + return false; + } + const currVariableName = aItem._nameString; + const parentScopes = this.getParentScopesForVariableOrProperty(aItem); + + for (const otherScope of parentScopes) { + for (const [otherVariableName] of otherScope) { + if (otherVariableName == currVariableName) { + return true; + } + } + } + return false; +}; + +/** + * Returns true if the descriptor represents an undefined, null or + * primitive value. + * + * @param object aDescriptor + * The variable's descriptor. + */ +VariablesView.isPrimitive = function (aDescriptor) { + // For accessor property descriptors, the getter and setter need to be + // contained in 'get' and 'set' properties. + const getter = aDescriptor.get; + const setter = aDescriptor.set; + if (getter || setter) { + return false; + } + + // As described in the remote debugger protocol, the value grip + // must be contained in a 'value' property. + const grip = aDescriptor.value; + if (typeof grip != "object") { + return true; + } + + // For convenience, undefined, null, Infinity, -Infinity, NaN, -0, and long + // strings are considered types. + const type = grip.type; + if ( + type == "undefined" || + type == "null" || + type == "Infinity" || + type == "-Infinity" || + type == "NaN" || + type == "-0" || + type == "symbol" || + type == "longString" + ) { + return true; + } + + return false; +}; + +/** + * Returns true if the descriptor represents an undefined value. + * + * @param object aDescriptor + * The variable's descriptor. + */ +VariablesView.isUndefined = function (aDescriptor) { + // For accessor property descriptors, the getter and setter need to be + // contained in 'get' and 'set' properties. + const getter = aDescriptor.get; + const setter = aDescriptor.set; + if ( + typeof getter == "object" && + getter.type == "undefined" && + typeof setter == "object" && + setter.type == "undefined" + ) { + return true; + } + + // As described in the remote debugger protocol, the value grip + // must be contained in a 'value' property. + const grip = aDescriptor.value; + if (typeof grip == "object" && grip.type == "undefined") { + return true; + } + + return false; +}; + +/** + * Returns true if the descriptor represents a falsy value. + * + * @param object aDescriptor + * The variable's descriptor. + */ +VariablesView.isFalsy = function (aDescriptor) { + // As described in the remote debugger protocol, the value grip + // must be contained in a 'value' property. + const grip = aDescriptor.value; + if (typeof grip != "object") { + return !grip; + } + + // For convenience, undefined, null, NaN, and -0 are all considered types. + const type = grip.type; + if (type == "undefined" || type == "null" || type == "NaN" || type == "-0") { + return true; + } + + return false; +}; + +/** + * Returns true if the value is an instance of Variable or Property. + * + * @param any aValue + * The value to test. + */ +VariablesView.isVariable = function (aValue) { + return aValue instanceof Variable; +}; + +/** + * Returns a standard grip for a value. + * + * @param any aValue + * The raw value to get a grip for. + * @return any + * The value's grip. + */ +VariablesView.getGrip = function (aValue) { + switch (typeof aValue) { + case "boolean": + case "string": + return aValue; + case "number": + if (aValue === Infinity) { + return { type: "Infinity" }; + } else if (aValue === -Infinity) { + return { type: "-Infinity" }; + } else if (Number.isNaN(aValue)) { + return { type: "NaN" }; + } else if (1 / aValue === -Infinity) { + return { type: "-0" }; + } + return aValue; + case "undefined": + // document.all is also "undefined" + if (aValue === undefined) { + return { type: "undefined" }; + } + // fall through + case "object": + if (aValue === null) { + return { type: "null" }; + } + // fall through + case "function": + return { type: "object", class: getObjectClassName(aValue) }; + default: + console.error( + "Failed to provide a grip for value of " + typeof value + ": " + aValue + ); + return null; + } +}; + +// Match the function name from the result of toString() or toSource(). +// +// Examples: +// (function foobar(a, b) { ... +// function foobar2(a) { ... +// function() { ... +const REGEX_MATCH_FUNCTION_NAME = /^\(?function\s+([^(\s]+)\s*\(/; + +/** + * Helper function to deduce the name of the provided function. + * + * @param function function + * The function whose name will be returned. + * @return string + * Function name. + */ +function getFunctionName(func) { + let name = null; + if (func.name) { + name = func.name; + } else { + let desc; + try { + desc = func.getOwnPropertyDescriptor("displayName"); + } catch (ex) { + // Ignore. + } + if (desc && typeof desc.value == "string") { + name = desc.value; + } + } + if (!name) { + try { + const str = (func.toString() || func.toSource()) + ""; + name = (str.match(REGEX_MATCH_FUNCTION_NAME) || [])[1]; + } catch (ex) { + // Ignore. + } + } + return name; +} + +/** + * Get the object class name. For example, the |window| object has the Window + * class name (based on [object Window]). + * + * @param object object + * The object you want to get the class name for. + * @return string + * The object class name. + */ +function getObjectClassName(object) { + if (object === null) { + return "null"; + } + if (object === undefined) { + return "undefined"; + } + + const type = typeof object; + if (type != "object") { + // Grip class names should start with an uppercase letter. + return type.charAt(0).toUpperCase() + type.substr(1); + } + + let className; + + try { + className = ((object + "").match(/^\[object (\S+)\]$/) || [])[1]; + if (!className) { + className = ((object.constructor + "").match(/^\[object (\S+)\]$/) || + [])[1]; + } + if (!className && typeof object.constructor == "function") { + className = getFunctionName(object.constructor); + } + } catch (ex) { + // Ignore. + } + + return className; +} + +/** + * Returns a custom formatted property string for a grip. + * + * @param any aGrip + * @see Variable.setGrip + * @param object aOptions + * Options: + * - concise: boolean that tells you want a concisely formatted string. + * - noStringQuotes: boolean that tells to not quote strings. + * - noEllipsis: boolean that tells to not add an ellipsis after the + * initial text of a longString. + * @return string + * The formatted property string. + */ +VariablesView.getString = function (aGrip, aOptions = {}) { + if (aGrip && typeof aGrip == "object") { + switch (aGrip.type) { + case "undefined": + case "null": + case "NaN": + case "Infinity": + case "-Infinity": + case "-0": + return aGrip.type; + default: + const stringifier = VariablesView.stringifiers.byType[aGrip.type]; + if (stringifier) { + const result = stringifier(aGrip, aOptions); + if (result != null) { + return result; + } + } + + if (aGrip.displayString) { + return VariablesView.getString(aGrip.displayString, aOptions); + } + + if (aGrip.type == "object" && aOptions.concise) { + return aGrip.class; + } + + return "[" + aGrip.type + " " + aGrip.class + "]"; + } + } + + switch (typeof aGrip) { + case "string": + return VariablesView.stringifiers.byType.string(aGrip, aOptions); + case "boolean": + return aGrip ? "true" : "false"; + case "number": + if (!aGrip && 1 / aGrip === -Infinity) { + return "-0"; + } + // fall through + default: + return aGrip + ""; + } +}; + +/** + * The VariablesView stringifiers are used by VariablesView.getString(). These + * are organized by object type, object class and by object actor preview kind. + * Some objects share identical ways for previews, for example Arrays, Sets and + * NodeLists. + * + * Any stringifier function must return a string. If null is returned, * then + * the default stringifier will be used. When invoked, the stringifier is + * given the same two arguments as those given to VariablesView.getString(). + */ +VariablesView.stringifiers = {}; + +VariablesView.stringifiers.byType = { + string(aGrip, { noStringQuotes }) { + if (noStringQuotes) { + return aGrip; + } + return '"' + aGrip + '"'; + }, + + longString({ initial }, { noStringQuotes, noEllipsis }) { + const ellipsis = noEllipsis ? "" : ELLIPSIS; + if (noStringQuotes) { + return initial + ellipsis; + } + const result = '"' + initial + '"'; + if (!ellipsis) { + return result; + } + return result.substr(0, result.length - 1) + ellipsis + '"'; + }, + + object(aGrip, aOptions) { + const { preview } = aGrip; + let stringifier; + if (aGrip.class) { + stringifier = VariablesView.stringifiers.byObjectClass[aGrip.class]; + } + if (!stringifier && preview && preview.kind) { + stringifier = VariablesView.stringifiers.byObjectKind[preview.kind]; + } + if (stringifier) { + return stringifier(aGrip, aOptions); + } + return null; + }, + + symbol(aGrip, aOptions) { + const name = aGrip.name || ""; + return "Symbol(" + name + ")"; + }, + + mapEntry(aGrip, { concise }) { + const { + preview: { key, value }, + } = aGrip; + + const keyString = VariablesView.getString(key, { + concise: true, + noStringQuotes: true, + }); + const valueString = VariablesView.getString(value, { concise: true }); + + return keyString + " \u2192 " + valueString; + }, +}; // VariablesView.stringifiers.byType + +VariablesView.stringifiers.byObjectClass = { + Function(aGrip, { concise }) { + // TODO: Bug 948484 - support arrow functions and ES6 generators + + let name = aGrip.userDisplayName || aGrip.displayName || aGrip.name || ""; + name = VariablesView.getString(name, { noStringQuotes: true }); + + // TODO: Bug 948489 - Support functions with destructured parameters and + // rest parameters + const params = aGrip.parameterNames || ""; + if (!concise) { + return "function " + name + "(" + params + ")"; + } + return (name || "function ") + "(" + params + ")"; + }, + + RegExp({ displayString }) { + return VariablesView.getString(displayString, { noStringQuotes: true }); + }, + + Date({ preview }) { + if (!preview || !("timestamp" in preview)) { + return null; + } + + if (typeof preview.timestamp != "number") { + return new Date(preview.timestamp).toString(); // invalid date + } + + return "Date " + new Date(preview.timestamp).toISOString(); + }, + + Number(aGrip) { + const { preview } = aGrip; + if (preview === undefined) { + return null; + } + return ( + aGrip.class + " { " + VariablesView.getString(preview.wrappedValue) + " }" + ); + }, +}; // VariablesView.stringifiers.byObjectClass + +VariablesView.stringifiers.byObjectClass.Boolean = + VariablesView.stringifiers.byObjectClass.Number; + +VariablesView.stringifiers.byObjectKind = { + ArrayLike(aGrip, { concise }) { + const { preview } = aGrip; + if (concise) { + return aGrip.class + "[" + preview.length + "]"; + } + + if (!preview.items) { + return null; + } + + let shown = 0, + lastHole = null; + const result = []; + for (const item of preview.items) { + if (item === null) { + if (lastHole !== null) { + result[lastHole] += ","; + } else { + result.push(""); + } + lastHole = result.length - 1; + } else { + lastHole = null; + result.push(VariablesView.getString(item, { concise: true })); + } + shown++; + } + + if (shown < preview.length) { + const n = preview.length - shown; + result.push(VariablesView.stringifiers._getNMoreString(n)); + } else if (lastHole !== null) { + // make sure we have the right number of commas... + result[lastHole] += ","; + } + + const prefix = aGrip.class == "Array" ? "" : aGrip.class + " "; + return prefix + "[" + result.join(", ") + "]"; + }, + + MapLike(aGrip, { concise }) { + const { preview } = aGrip; + if (concise || !preview.entries) { + const size = + typeof preview.size == "number" ? "[" + preview.size + "]" : ""; + return aGrip.class + size; + } + + const entries = []; + for (const [key, value] of preview.entries) { + const keyString = VariablesView.getString(key, { + concise: true, + noStringQuotes: true, + }); + const valueString = VariablesView.getString(value, { concise: true }); + entries.push(keyString + ": " + valueString); + } + + if (typeof preview.size == "number" && preview.size > entries.length) { + const n = preview.size - entries.length; + entries.push(VariablesView.stringifiers._getNMoreString(n)); + } + + return aGrip.class + " {" + entries.join(", ") + "}"; + }, + + ObjectWithText(aGrip, { concise }) { + if (concise) { + return aGrip.class; + } + + return aGrip.class + " " + VariablesView.getString(aGrip.preview.text); + }, + + ObjectWithURL(aGrip, { concise }) { + let result = aGrip.class; + const url = aGrip.preview.url; + if (!VariablesView.isFalsy({ value: url })) { + result += ` \u2192 ${getSourceNames(url)[concise ? "short" : "long"]}`; + } + return result; + }, + + // Stringifier for any kind of object. + Object(aGrip, { concise }) { + if (concise) { + return aGrip.class; + } + + const { preview } = aGrip; + const props = []; + + if (aGrip.class == "Promise" && aGrip.promiseState) { + const { state, value, reason } = aGrip.promiseState; + props.push("<state>: " + VariablesView.getString(state)); + if (state == "fulfilled") { + props.push( + "<value>: " + VariablesView.getString(value, { concise: true }) + ); + } else if (state == "rejected") { + props.push( + "<reason>: " + VariablesView.getString(reason, { concise: true }) + ); + } + } + + for (const key of Object.keys(preview.ownProperties || {})) { + const value = preview.ownProperties[key]; + let valueString = ""; + if (value.get) { + valueString = "Getter"; + } else if (value.set) { + valueString = "Setter"; + } else { + valueString = VariablesView.getString(value.value, { concise: true }); + } + props.push(key + ": " + valueString); + } + + for (const key of Object.keys(preview.safeGetterValues || {})) { + const value = preview.safeGetterValues[key]; + const valueString = VariablesView.getString(value.getterValue, { + concise: true, + }); + props.push(key + ": " + valueString); + } + + if (!props.length) { + return null; + } + + if (preview.ownPropertiesLength) { + const previewLength = Object.keys(preview.ownProperties).length; + const diff = preview.ownPropertiesLength - previewLength; + if (diff > 0) { + props.push(VariablesView.stringifiers._getNMoreString(diff)); + } + } + + const prefix = aGrip.class != "Object" ? aGrip.class + " " : ""; + return prefix + "{" + props.join(", ") + "}"; + }, // Object + + Error(aGrip, { concise }) { + const { preview } = aGrip; + const name = VariablesView.getString(preview.name, { + noStringQuotes: true, + }); + if (concise) { + return name || aGrip.class; + } + + let msg = + name + + ": " + + VariablesView.getString(preview.message, { noStringQuotes: true }); + + if (!VariablesView.isFalsy({ value: preview.stack })) { + msg += + "\n" + + L10N.getStr("variablesViewErrorStacktrace") + + "\n" + + preview.stack; + } + + return msg; + }, + + DOMException(aGrip, { concise }) { + const { preview } = aGrip; + if (concise) { + return preview.name || aGrip.class; + } + + let msg = + aGrip.class + + " [" + + preview.name + + ": " + + VariablesView.getString(preview.message) + + "\n" + + "code: " + + preview.code + + "\n" + + "nsresult: 0x" + + (+preview.result).toString(16); + + if (preview.filename) { + msg += "\nlocation: " + preview.filename; + if (preview.lineNumber) { + msg += ":" + preview.lineNumber; + } + } + + return msg + "]"; + }, + + DOMEvent(aGrip, { concise }) { + const { preview } = aGrip; + if (!preview.type) { + return null; + } + + if (concise) { + return aGrip.class + " " + preview.type; + } + + let result = preview.type; + + if ( + preview.eventKind == "key" && + preview.modifiers && + preview.modifiers.length + ) { + result += " " + preview.modifiers.join("-"); + } + + const props = []; + if (preview.target) { + const target = VariablesView.getString(preview.target, { concise: true }); + props.push("target: " + target); + } + + for (const prop in preview.properties) { + const value = preview.properties[prop]; + props.push( + prop + ": " + VariablesView.getString(value, { concise: true }) + ); + } + + return result + " {" + props.join(", ") + "}"; + }, // DOMEvent + + DOMNode(aGrip, { concise }) { + const { preview } = aGrip; + + switch (preview.nodeType) { + case nodeConstants.DOCUMENT_NODE: { + let result = aGrip.class; + if (preview.location) { + result += ` \u2192 ${ + getSourceNames(preview.location)[concise ? "short" : "long"] + }`; + } + + return result; + } + + case nodeConstants.ATTRIBUTE_NODE: { + const value = VariablesView.getString(preview.value, { + noStringQuotes: true, + }); + return preview.nodeName + '="' + escapeHTML(value) + '"'; + } + + case nodeConstants.TEXT_NODE: + return ( + preview.nodeName + " " + VariablesView.getString(preview.textContent) + ); + + case nodeConstants.COMMENT_NODE: { + const comment = VariablesView.getString(preview.textContent, { + noStringQuotes: true, + }); + return "<!--" + comment + "-->"; + } + + case nodeConstants.DOCUMENT_FRAGMENT_NODE: { + if (concise || !preview.childNodes) { + return aGrip.class + "[" + preview.childNodesLength + "]"; + } + const nodes = []; + for (const node of preview.childNodes) { + nodes.push(VariablesView.getString(node)); + } + if (nodes.length < preview.childNodesLength) { + const n = preview.childNodesLength - nodes.length; + nodes.push(VariablesView.stringifiers._getNMoreString(n)); + } + return aGrip.class + " [" + nodes.join(", ") + "]"; + } + + case nodeConstants.ELEMENT_NODE: { + const attrs = preview.attributes; + if (!concise) { + let n = 0, + result = "<" + preview.nodeName; + for (const name in attrs) { + const value = VariablesView.getString(attrs[name], { + noStringQuotes: true, + }); + result += " " + name + '="' + escapeHTML(value) + '"'; + n++; + } + if (preview.attributesLength > n) { + result += " " + ELLIPSIS; + } + return result + ">"; + } + + let result = "<" + preview.nodeName; + if (attrs.id) { + result += "#" + attrs.id; + } + + if (attrs.class) { + result += "." + attrs.class.trim().replace(/\s+/, "."); + } + return result + ">"; + } + + default: + return null; + } + }, // DOMNode +}; // VariablesView.stringifiers.byObjectKind + +/** + * Get the "N more…" formatted string, given an N. This is used for displaying + * how many elements are not displayed in an object preview (eg. an array). + * + * @private + * @param number aNumber + * @return string + */ +VariablesView.stringifiers._getNMoreString = function (aNumber) { + const str = L10N.getStr("variablesViewMoreObjects"); + return PluralForm.get(aNumber, str).replace("#1", aNumber); +}; + +/** + * Returns a custom class style for a grip. + * + * @param any aGrip + * @see Variable.setGrip + * @return string + * The custom class style. + */ +VariablesView.getClass = function (aGrip) { + if (aGrip && typeof aGrip == "object") { + if (aGrip.preview) { + switch (aGrip.preview.kind) { + case "DOMNode": + return "token-domnode"; + } + } + + switch (aGrip.type) { + case "undefined": + return "token-undefined"; + case "null": + return "token-null"; + case "Infinity": + case "-Infinity": + case "NaN": + case "-0": + return "token-number"; + case "longString": + return "token-string"; + } + } + switch (typeof aGrip) { + case "string": + return "token-string"; + case "boolean": + return "token-boolean"; + case "number": + return "token-number"; + default: + return "token-other"; + } +}; + +/** + * A monotonically-increasing counter, that guarantees the uniqueness of scope, + * variables and properties ids. + * + * @param string aName + * An optional string to prefix the id with. + * @return number + * A unique id. + */ +var generateId = (function () { + let count = 0; + return function (aName = "") { + return aName.toLowerCase().trim().replace(/\s+/g, "-") + ++count; + }; +})(); + +/** + * Quote and escape a string. The result will be another string containing an + * ECMAScript StringLiteral which will produce the original one when evaluated + * by `eval` or similar. + * + * @param string aString + * An optional string to be escaped. If no string is passed, the function + * returns an empty string. + * @return string + */ +function escapeString(aString) { + if (typeof aString !== "string") { + return ""; + } + // U+2028 and U+2029 are allowed in JSON but not in ECMAScript string literals. + return JSON.stringify(aString) + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} + +/** + * Escape some HTML special characters. We do not need full HTML serialization + * here, we just want to make strings safe to display in HTML attributes, for + * the stringifiers. + * + * @param string aString + * @return string + */ +export function escapeHTML(aString) { + return aString + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +/** + * An Editable encapsulates the UI of an edit box that overlays a label, + * allowing the user to edit the value. + * + * @param Variable aVariable + * The Variable or Property to make editable. + * @param object aOptions + * - onSave + * The callback to call with the value when editing is complete. + * - onCleanup + * The callback to call when the editable is removed for any reason. + */ +function Editable(aVariable, aOptions) { + this._variable = aVariable; + this._onSave = aOptions.onSave; + this._onCleanup = aOptions.onCleanup; +} + +Editable.create = function (aVariable, aOptions, aEvent) { + const editable = new this(aVariable, aOptions); + editable.activate(aEvent); + return editable; +}; + +Editable.prototype = { + /** + * The class name for targeting this Editable type's label element. Overridden + * by inheriting classes. + */ + className: null, + + /** + * Boolean indicating whether this Editable should activate. Overridden by + * inheriting classes. + */ + shouldActivate: null, + + /** + * The label element for this Editable. Overridden by inheriting classes. + */ + label: null, + + /** + * Activate this editable by replacing the input box it overlays and + * initialize the handlers. + * + * @param Event e [optional] + * Optionally, the Event object that was used to activate the Editable. + */ + activate(e) { + if (!this.shouldActivate) { + this._onCleanup && this._onCleanup(); + return; + } + + const { label } = this; + const initialString = label.getAttribute("value"); + + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + // Create a texbox input element which will be shown in the current + // element's specified label location. + const input = (this._input = this._variable.document.createElementNS( + HTML_NS, + "input" + )); + input.className = this.className; + input.setAttribute("value", initialString); + + // Replace the specified label with a textbox input element. + label.parentNode.replaceChild(input, label); + input.scrollIntoView({ block: "nearest" }); + input.select(); + + // When the value is a string (displayed as "value"), then we probably want + // to change it to another string in the textbox, so to avoid typing the "" + // again, tackle with the selection bounds just a bit. + if (initialString.match(/^".+"$/)) { + input.selectionEnd--; + input.selectionStart++; + } + + this._onKeydown = this._onKeydown.bind(this); + this._onBlur = this._onBlur.bind(this); + input.addEventListener("keydown", this._onKeydown); + input.addEventListener("blur", this._onBlur); + + this._prevExpandable = this._variable.twisty; + this._prevExpanded = this._variable.expanded; + this._variable.collapse(); + this._variable.hideArrow(); + this._variable.locked = true; + this._variable.editing = true; + }, + + /** + * Remove the input box and restore the Variable or Property to its previous + * state. + */ + deactivate() { + this._input.removeEventListener("keydown", this._onKeydown); + this._input.removeEventListener("blur", this.deactivate); + this._input.parentNode.replaceChild(this.label, this._input); + this._input = null; + + const scrollbox = this._variable._variablesView._list; + scrollbox.scrollBy(-this._variable._target, 0); + this._variable.locked = false; + this._variable.twisty = this._prevExpandable; + this._variable.expanded = this._prevExpanded; + this._variable.editing = false; + this._onCleanup && this._onCleanup(); + }, + + /** + * Save the current value and deactivate the Editable. + */ + _save() { + const initial = this.label.getAttribute("value"); + const current = this._input.value.trim(); + this.deactivate(); + if (initial != current) { + this._onSave(current); + } + }, + + /** + * Called when tab is pressed, allowing subclasses to link different + * behavior to tabbing if desired. + */ + _next() { + this._save(); + }, + + /** + * Called when escape is pressed, indicating a cancelling of editing without + * saving. + */ + _reset() { + this.deactivate(); + this._variable.focus(); + }, + + /** + * Event handler for when the input loses focus. + */ + _onBlur() { + this.deactivate(); + }, + + /** + * Event handler for when the input receives a key press. + */ + _onKeydown(e) { + e.stopPropagation(); + + switch (e.keyCode) { + case KeyCodes.DOM_VK_TAB: + this._next(); + break; + case KeyCodes.DOM_VK_RETURN: + this._save(); + break; + case KeyCodes.DOM_VK_ESCAPE: + this._reset(); + break; + } + }, +}; + +/** + * An Editable specific to editing the name of a Variable or Property. + */ +function EditableName(aVariable, aOptions) { + Editable.call(this, aVariable, aOptions); +} + +EditableName.create = Editable.create; + +EditableName.prototype = extend(Editable.prototype, { + className: "element-name-input", + + get label() { + return this._variable._name; + }, + + get shouldActivate() { + return !!this._variable.ownerView.switch; + }, +}); + +/** + * An Editable specific to editing the value of a Variable or Property. + */ +function EditableValue(aVariable, aOptions) { + Editable.call(this, aVariable, aOptions); +} + +EditableValue.create = Editable.create; + +EditableValue.prototype = extend(Editable.prototype, { + className: "element-value-input", + + get label() { + return this._variable._valueLabel; + }, + + get shouldActivate() { + return !!this._variable.ownerView.eval; + }, +}); + +/** + * An Editable specific to editing the key and value of a new property. + */ +function EditableNameAndValue(aVariable, aOptions) { + EditableName.call(this, aVariable, aOptions); +} + +EditableNameAndValue.create = Editable.create; + +EditableNameAndValue.prototype = extend(EditableName.prototype, { + _reset(e) { + // Hide the Variable or Property if the user presses escape. + this._variable.remove(); + this.deactivate(); + }, + + _next(e) { + // Override _next so as to set both key and value at the same time. + const key = this._input.value; + this.label.setAttribute("value", key); + + const valueEditable = EditableValue.create(this._variable, { + onSave: aValue => { + this._onSave([key, aValue]); + }, + }); + valueEditable._reset = () => { + this._variable.remove(); + valueEditable.deactivate(); + }; + }, + + _save(e) { + // Both _save and _next activate the value edit box. + this._next(e); + }, +}); diff --git a/devtools/client/storage/index.xhtml b/devtools/client/storage/index.xhtml new file mode 100644 index 0000000000..bad75aa15b --- /dev/null +++ b/devtools/client/storage/index.xhtml @@ -0,0 +1,113 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!DOCTYPE window> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" +> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://devtools/content/shared/widgets/widgets.css" + /> + <html:link rel="stylesheet" href="chrome://devtools/skin/widgets.css" /> + <html:link rel="stylesheet" href="chrome://devtools/skin/storage.css" /> + <html:link + rel="stylesheet" + href="chrome://devtools/content/shared/components/SidebarToggle.css" + /> + + <html:link rel="localization" href="devtools/client/storage.ftl" /> + + <script src="chrome://devtools/content/shared/theme-switching.js" /> + <script src="chrome://global/content/globalOverlay.js" /> + + <popupset id="storagePopupSet"> + <menupopup id="storage-tree-popup"> + <menuitem + id="storage-tree-popup-delete-all" + data-l10n-id="storage-context-menu-delete-all" + /> + <menuitem + id="storage-tree-popup-delete-all-session-cookies" + data-l10n-id="storage-context-menu-delete-all-session-cookies" + /> + <menuitem + id="storage-tree-popup-delete" + data-l10n-id="storage-context-menu-delete" + data-l10n-args='{"itemName": ""}' + /> + </menupopup> + <menupopup id="variable-view-popup"> + <menuitem + id="variable-view-popup-copy" + data-l10n-id="storage-context-menu-copy" + /> + </menupopup> + <menupopup id="storage-table-popup"> + <menuitem + id="storage-table-popup-add" + data-l10n-id="storage-context-menu-add-item" + /> + <menuitem + id="storage-table-popup-delete" + data-l10n-id="storage-context-menu-delete" + data-l10n-args='{"itemName": ""}' + /> + <menuitem + id="storage-table-popup-delete-all-from" + data-l10n-id="storage-context-menu-delete-all-from" + data-l10n-args='{"host": ""}' + /> + <menuitem + id="storage-table-popup-delete-all" + data-l10n-id="storage-context-menu-delete-all" + /> + <menuitem + id="storage-table-popup-delete-all-session-cookies" + data-l10n-id="storage-context-menu-delete-all-session-cookies" + /> + </menupopup> + </popupset> + + <box flex="1" class="devtools-responsive-container theme-body"> + <vbox id="storage-tree" /> + <splitter class="devtools-side-splitter" /> + <vbox flex="1"> + <hbox + id="storage-toolbar" + class="devtools-toolbar devtools-input-toolbar" + > + <html:input + id="storage-searchbox" + class="devtools-filterinput" + data-l10n-id="storage-search-box" + /> + <hbox class="devtools-separator" /> + <html:button + id="add-button" + class="devtools-button add-button" + data-l10n-id="storage-add-button" + ></html:button> + <html:button + id="refresh-button" + class="devtools-button refresh-button" + data-l10n-id="storage-refresh-button" + ></html:button> + <html:button + class="devtools-button sidebar-toggle" + hidden="" + ></html:button> + </hbox> + <vbox id="storage-table" class="theme-sidebar" flex="1" /> + </vbox> + <splitter class="devtools-side-splitter" /> + <vbox id="storage-sidebar" class="devtools-sidebar-tabs" hidden="true"> + <vbox flex="1" /> + </vbox> + </box> +</window> diff --git a/devtools/client/storage/moz.build b/devtools/client/storage/moz.build new file mode 100644 index 0000000000..e0e107ab41 --- /dev/null +++ b/devtools/client/storage/moz.build @@ -0,0 +1,20 @@ +# -*- 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/. + +BROWSER_CHROME_MANIFESTS += ["test/browser.toml"] + +DIRS += [ + "utils", +] + +DevToolsModules( + "panel.js", + "ui.js", + "VariablesView.sys.mjs", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Storage Inspector") diff --git a/devtools/client/storage/panel.js b/devtools/client/storage/panel.js new file mode 100644 index 0000000000..79b579b2bc --- /dev/null +++ b/devtools/client/storage/panel.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"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +loader.lazyRequireGetter( + this, + "StorageUI", + "resource://devtools/client/storage/ui.js", + true +); + +class StoragePanel { + constructor(panelWin, toolbox, commands) { + EventEmitter.decorate(this); + + this._toolbox = toolbox; + this._commands = commands; + this._panelWin = panelWin; + } + + get panelWindow() { + return this._panelWin; + } + + /** + * open is effectively an asynchronous constructor + */ + async open() { + this.UI = new StorageUI(this._panelWin, this._toolbox, this._commands); + + await this.UI.init(); + + return this; + } + + /** + * Destroy the storage inspector. + */ + destroy() { + if (this._destroyed) { + return; + } + this._destroyed = true; + + this.UI.destroy(); + this.UI = null; + + this._toolbox = null; + this._panelWin = null; + } +} + +exports.StoragePanel = StoragePanel; diff --git a/devtools/client/storage/test/browser.toml b/devtools/client/storage/test/browser.toml new file mode 100644 index 0000000000..28b4b04258 --- /dev/null +++ b/devtools/client/storage/test/browser.toml @@ -0,0 +1,194 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +skip-if = [ + "http3", # Many tests relying on test1/test2.example.org + "http2", +] +support-files = [ + "storage-blank.html", + "storage-cache-basic-iframe.html", + "storage-cache-basic.html", + "storage-cache-error.html", + "storage-cache-overflow.html", + "storage-complex-keys.html", + "storage-complex-values.html", + "storage-cookies.html", + "storage-cookies-samesite.html", + "storage-cookies-sort.html", + "storage-dfpi.html", + "storage-empty-objectstores.html", + "storage-file-url.html", + "storage-idb-delete-blocked.html", + "storage-indexeddb-duplicate-names.html", + "storage-indexeddb-iframe.html", + "storage-indexeddb-simple.html", + "storage-indexeddb-simple-alt.html", + "storage-listings.html", + "storage-listings-usercontextid.html", + "storage-listings-with-fragment.html", + "storage-localstorage.html", + "storage-overflow-indexeddb.html", + "storage-overflow.html", + "storage-search.html", + "storage-secured-iframe.html", + "storage-secured-iframe-usercontextid.html", + "storage-sessionstorage.html", + "storage-sidebar-parsetree.html", + "storage-unsecured-iframe.html", + "storage-unsecured-iframe-usercontextid.html", + "storage-updates.html", + "head.js", + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", +] + +["browser_storage_basic.js"] + +["browser_storage_basic_usercontextid_1.js"] + +["browser_storage_basic_usercontextid_2.js"] +tags = "usercontextid" + +["browser_storage_basic_with_fragment.js"] + +["browser_storage_cache_delete.js"] + +["browser_storage_cache_error.js"] + +["browser_storage_cache_navigation.js"] +skip-if = ["win11_2009"] # Bug 1797751 + +["browser_storage_cache_overflow.js"] + +["browser_storage_cookies_add.js"] + +["browser_storage_cookies_delete_all.js"] + +["browser_storage_cookies_domain.js"] + +["browser_storage_cookies_domain_port.js"] + +["browser_storage_cookies_edit.js"] + +["browser_storage_cookies_edit_keyboard.js"] + +["browser_storage_cookies_hostOnly.js"] + +["browser_storage_cookies_navigation.js"] +skip-if = [ + "os == 'linux' && debug && fission && socketprocess_networking", # high frequency intermittent +] + +["browser_storage_cookies_samesite.js"] +skip-if = ["true"] # Bug 1448484 - sameSite1 is "Unset" - Got undefined, expected Unset + +["browser_storage_cookies_sort.js"] +skip-if = ["a11y_checks"] # Bug 1858037 to investigate intermittent a11y_checks results (fails on Autoland, passes on Try) + +["browser_storage_cookies_tab_navigation.js"] + +["browser_storage_delete.js"] + +["browser_storage_delete_all.js"] + +["browser_storage_delete_tree.js"] + +["browser_storage_delete_usercontextid.js"] +tags = "usercontextid" + +["browser_storage_dfpi.js"] + +["browser_storage_dfpi_always_partition_storage.js"] + +["browser_storage_dynamic_updates_cookies.js"] + +["browser_storage_dynamic_updates_localStorage.js"] + +["browser_storage_dynamic_updates_sessionStorage.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_storage_empty_objectstores.js"] + +["browser_storage_file_url.js"] + +["browser_storage_fission_cache.js"] + +["browser_storage_fission_cookies.js"] + +["browser_storage_fission_hide_aboutblank.js"] + +["browser_storage_fission_indexeddb.js"] + +["browser_storage_fission_local_storage.js"] + +["browser_storage_fission_session_storage.js"] + +["browser_storage_indexeddb_add_button_hidden.js"] + +["browser_storage_indexeddb_delete.js"] + +["browser_storage_indexeddb_delete_blocked.js"] + +["browser_storage_indexeddb_duplicate_names.js"] +skip-if = [ + "win11_2009", # Bug 1797751 +] + +["browser_storage_indexeddb_hide_internal_dbs.js"] +skip-if = ["asan"] # Bug 1591064 + +["browser_storage_indexeddb_navigation.js"] +skip-if = [ + "win10_2009 && bits == 64", # Bug 1694274 + "os == 'linux' && bits == 64", # Bug 1694274 +] + +["browser_storage_indexeddb_overflow.js"] + +["browser_storage_keys.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_storage_localstorage_add.js"] + +["browser_storage_localstorage_edit.js"] + +["browser_storage_localstorage_error.js"] + +["browser_storage_localstorage_navigation.js"] + +["browser_storage_localstorage_rapid_add_remove.js"] + +["browser_storage_overflow.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_storage_search.js"] + +["browser_storage_search_keyboard_trap.js"] + +["browser_storage_sessionstorage_add.js"] + +["browser_storage_sessionstorage_edit.js"] + +["browser_storage_sessionstorage_navigation.js"] + +["browser_storage_sidebar.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_storage_sidebar_filter.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_storage_sidebar_parsetree.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_storage_sidebar_toggle.js"] + +["browser_storage_sidebar_update.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_storage_type_descriptions.js"] + +["browser_storage_values.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_storage_webext_storage_local.js"] diff --git a/devtools/client/storage/test/browser_storage_basic.js b/devtools/client/storage/test/browser_storage_basic.js new file mode 100644 index 0000000000..4a160f641b --- /dev/null +++ b/devtools/client/storage/test/browser_storage_basic.js @@ -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/. */ + +// Basic test to assert that the storage tree and table corresponding to each +// item in the storage tree is correctly displayed + +// Entries that should be present in the tree for this test +// Format for each entry in the array : +// [ +// ["path", "to", "tree", "item"], - The path to the tree item to click formed +// by id of each item +// ["key_value1", "key_value2", ...] - The value of the first (unique) column +// for each row in the table corresponding +// to the tree item selected. +// ] +// These entries are formed by the cookies, local storage, session storage and +// indexedDB entries created in storage-listings.html, +// storage-secured-iframe.html and storage-unsecured-iframe.html + +"use strict"; + +const testCases = [ + [ + ["cookies", "http://test1.example.org"], + [ + getCookieId("c1", "test1.example.org", "/browser"), + getCookieId("cs2", ".example.org", "/"), + getCookieId("c3", "test1.example.org", "/"), + getCookieId("c4", ".example.org", "/"), + getCookieId("uc1", ".example.org", "/"), + getCookieId("uc2", ".example.org", "/"), + ], + ], + [ + ["cookies", "https://sectest1.example.org"], + [ + getCookieId("uc1", ".example.org", "/"), + getCookieId("uc2", ".example.org", "/"), + getCookieId("cs2", ".example.org", "/"), + getCookieId("c4", ".example.org", "/"), + getCookieId( + "sc1", + "sectest1.example.org", + "/browser/devtools/client/storage/test" + ), + getCookieId( + "sc2", + "sectest1.example.org", + "/browser/devtools/client/storage/test" + ), + ], + ], + [ + ["localStorage", "http://test1.example.org"], + ["key", "ls1", "ls2"], + ], + [["localStorage", "http://sectest1.example.org"], ["iframe-u-ls1"]], + [["localStorage", "https://sectest1.example.org"], ["iframe-s-ls1"]], + [ + ["sessionStorage", "http://test1.example.org"], + ["key", "ss1"], + ], + [ + ["sessionStorage", "http://sectest1.example.org"], + ["iframe-u-ss1", "iframe-u-ss2"], + ], + [["sessionStorage", "https://sectest1.example.org"], ["iframe-s-ss1"]], + [ + ["indexedDB", "http://test1.example.org"], + ["idb1 (default)", "idb2 (default)"], + ], + [ + ["indexedDB", "http://test1.example.org", "idb1 (default)"], + ["obj1", "obj2"], + ], + [["indexedDB", "http://test1.example.org", "idb2 (default)"], ["obj3"]], + [ + ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"], + [1, 2, 3], + ], + [["indexedDB", "http://test1.example.org", "idb1 (default)", "obj2"], [1]], + [["indexedDB", "http://test1.example.org", "idb2 (default)", "obj3"], []], + [["indexedDB", "http://sectest1.example.org"], []], + [ + ["indexedDB", "https://sectest1.example.org"], + ["idb-s1 (default)", "idb-s2 (default)"], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)"], + ["obj-s1"], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)"], + ["obj-s2"], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)", "obj-s1"], + [6, 7], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)", "obj-s2"], + [16], + ], + [ + ["Cache", "http://test1.example.org", "plop"], + [ + MAIN_DOMAIN + "404_cached_file.js", + MAIN_DOMAIN + "browser_storage_basic.js", + ], + ], +]; + +/** + * Test that the desired number of tree items are present + */ +function testTree() { + const doc = gPanelWindow.document; + for (const [item] of testCases) { + ok( + doc.querySelector("[data-id='" + JSON.stringify(item) + "']"), + `Tree item ${item.toSource()} should be present in the storage tree` + ); + } +} + +/** + * Test that correct table entries are shown for each of the tree item + */ +async function testTables() { + const doc = gPanelWindow.document; + // Expand all nodes so that the synthesized click event actually works + gUI.tree.expandAll(); + + // First tree item is already selected so no clicking and waiting for update + for (const id of testCases[0][1]) { + ok( + doc.querySelector(".table-widget-cell[data-id='" + id + "']"), + "Table item " + id + " should be present" + ); + } + + // Click rest of the tree items and wait for the table to be updated + for (const [treeItem, items] of testCases.slice(1)) { + await selectTreeItem(treeItem); + + // Check whether correct number of items are present in the table + is( + doc.querySelectorAll( + ".table-widget-column:first-of-type .table-widget-cell" + ).length, + items.length, + "Number of items in table is correct" + ); + + // Check if all the desired items are present in the table + for (const id of items) { + ok( + doc.querySelector(".table-widget-cell[data-id='" + id + "']"), + "Table item " + id + " should be present" + ); + } + } +} + +add_task(async function () { + await pushPref("dom.security.https_first", false); + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html"); + + testTree(); + await testTables(); +}); diff --git a/devtools/client/storage/test/browser_storage_basic_usercontextid_1.js b/devtools/client/storage/test/browser_storage_basic_usercontextid_1.js new file mode 100644 index 0000000000..fbd477b61d --- /dev/null +++ b/devtools/client/storage/test/browser_storage_basic_usercontextid_1.js @@ -0,0 +1,162 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// A test to check that the storage inspector is working correctly without +// userContextId. + +"use strict"; + +const testCases = [ + [ + ["cookies", "http://test1.example.org"], + [ + getCookieId("c1", "test1.example.org", "/browser"), + getCookieId("cs2", ".example.org", "/"), + getCookieId("c3", "test1.example.org", "/"), + getCookieId("c4", ".example.org", "/"), + getCookieId("uc1", ".example.org", "/"), + getCookieId("uc2", ".example.org", "/"), + ], + ], + [ + ["cookies", "https://sectest1.example.org"], + [ + getCookieId("uc1", ".example.org", "/"), + getCookieId("uc2", ".example.org", "/"), + getCookieId("cs2", ".example.org", "/"), + getCookieId("c4", ".example.org", "/"), + getCookieId( + "sc1", + "sectest1.example.org", + "/browser/devtools/client/storage/test" + ), + getCookieId( + "sc2", + "sectest1.example.org", + "/browser/devtools/client/storage/test" + ), + ], + ], + [ + ["localStorage", "http://test1.example.org"], + ["key", "ls1", "ls2"], + ], + [["localStorage", "http://sectest1.example.org"], ["iframe-u-ls1"]], + [["localStorage", "https://sectest1.example.org"], ["iframe-s-ls1"]], + [ + ["sessionStorage", "http://test1.example.org"], + ["key", "ss1"], + ], + [ + ["sessionStorage", "http://sectest1.example.org"], + ["iframe-u-ss1", "iframe-u-ss2"], + ], + [["sessionStorage", "https://sectest1.example.org"], ["iframe-s-ss1"]], + [ + ["indexedDB", "http://test1.example.org"], + ["idb1 (default)", "idb2 (default)"], + ], + [ + ["indexedDB", "http://test1.example.org", "idb1 (default)"], + ["obj1", "obj2"], + ], + [["indexedDB", "http://test1.example.org", "idb2 (default)"], ["obj3"]], + [ + ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"], + [1, 2, 3], + ], + [["indexedDB", "http://test1.example.org", "idb1 (default)", "obj2"], [1]], + [["indexedDB", "http://test1.example.org", "idb2 (default)", "obj3"], []], + [["indexedDB", "http://sectest1.example.org"], []], + [ + ["indexedDB", "https://sectest1.example.org"], + ["idb-s1 (default)", "idb-s2 (default)"], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)"], + ["obj-s1"], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)"], + ["obj-s2"], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)", "obj-s1"], + [6, 7], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)", "obj-s2"], + [16], + ], + [ + ["Cache", "http://test1.example.org", "plop"], + [ + MAIN_DOMAIN + "404_cached_file.js", + MAIN_DOMAIN + "browser_storage_basic.js", + ], + ], +]; + +/** + * Test that the desired number of tree items are present + */ +function testTree(tests) { + const doc = gPanelWindow.document; + for (const [item] of tests) { + ok( + doc.querySelector("[data-id='" + JSON.stringify(item) + "']"), + `Tree item ${item.toSource()} should be present in the storage tree` + ); + } +} + +/** + * Test that correct table entries are shown for each of the tree item + */ +async function testTables(tests) { + const doc = gPanelWindow.document; + // Expand all nodes so that the synthesized click event actually works + gUI.tree.expandAll(); + + // First tree item is already selected so no clicking and waiting for update + for (const id of tests[0][1]) { + ok( + doc.querySelector(".table-widget-cell[data-id='" + id + "']"), + "Table item " + id + " should be present" + ); + } + + // Click rest of the tree items and wait for the table to be updated + for (const [treeItem, items] of tests.slice(1)) { + await selectTreeItem(treeItem); + + // Check whether correct number of items are present in the table + is( + doc.querySelectorAll( + ".table-widget-column:first-of-type .table-widget-cell" + ).length, + items.length, + "Number of items in table is correct" + ); + + // Check if all the desired items are present in the table + for (const id of items) { + ok( + doc.querySelector(".table-widget-cell[data-id='" + id + "']"), + "Table item " + id + " should be present" + ); + } + } +} + +add_task(async function () { + // storage-listings.html explicitly mixes secure and insecure frames. + // We should not enforce https for tests using this page. + await pushPref("dom.security.https_first", false); + + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html"); + + testTree(testCases); + await testTables(testCases); +}); diff --git a/devtools/client/storage/test/browser_storage_basic_usercontextid_2.js b/devtools/client/storage/test/browser_storage_basic_usercontextid_2.js new file mode 100644 index 0000000000..e6c3151cd3 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_basic_usercontextid_2.js @@ -0,0 +1,169 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// A test to check that the storage inspector is working correctly with +// userContextId. + +"use strict"; + +const testCasesUserContextId = [ + [ + ["cookies", "http://test1.example.org"], + [ + getCookieId("c1uc1", "test1.example.org", "/browser"), + getCookieId("cs2uc1", ".example.org", "/"), + getCookieId("c3uc1", "test1.example.org", "/"), + getCookieId("uc1uc1", ".example.org", "/"), + ], + ], + [ + ["cookies", "https://sectest1.example.org"], + [ + getCookieId("uc1uc1", ".example.org", "/"), + getCookieId("cs2uc1", ".example.org", "/"), + getCookieId( + "sc1uc1", + "sectest1.example.org", + "/browser/devtools/client/storage/test" + ), + ], + ], + [ + ["localStorage", "http://test1.example.org"], + ["ls1uc1", "ls2uc1"], + ], + [["localStorage", "http://sectest1.example.org"], ["iframe-u-ls1uc1"]], + [["localStorage", "https://sectest1.example.org"], ["iframe-s-ls1uc1"]], + [["sessionStorage", "http://test1.example.org"], ["ss1uc1"]], + [ + ["sessionStorage", "http://sectest1.example.org"], + ["iframe-u-ss1uc1", "iframe-u-ss2uc1"], + ], + [["sessionStorage", "https://sectest1.example.org"], ["iframe-s-ss1uc1"]], + [ + ["indexedDB", "http://test1.example.org"], + ["idb1uc1 (default)", "idb2uc1 (default)"], + ], + [ + ["indexedDB", "http://test1.example.org", "idb1uc1 (default)"], + ["obj1uc1", "obj2uc1"], + ], + [["indexedDB", "http://test1.example.org", "idb2uc1 (default)"], ["obj3uc1"]], + [ + ["indexedDB", "http://test1.example.org", "idb1uc1 (default)", "obj1uc1"], + [1, 2, 3], + ], + [ + ["indexedDB", "http://test1.example.org", "idb1uc1 (default)", "obj2uc1"], + [1], + ], + [ + ["indexedDB", "http://test1.example.org", "idb2uc1 (default)", "obj3uc1"], + [], + ], + [["indexedDB", "http://sectest1.example.org"], []], + [ + ["indexedDB", "https://sectest1.example.org"], + ["idb-s1uc1 (default)", "idb-s2uc1 (default)"], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s1uc1 (default)"], + ["obj-s1uc1"], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s2uc1 (default)"], + ["obj-s2uc1"], + ], + [ + [ + "indexedDB", + "https://sectest1.example.org", + "idb-s1uc1 (default)", + "obj-s1uc1", + ], + [6, 7], + ], + [ + [ + "indexedDB", + "https://sectest1.example.org", + "idb-s2uc1 (default)", + "obj-s2uc1", + ], + [16], + ], + [ + ["Cache", "http://test1.example.org", "plopuc1"], + [ + MAIN_DOMAIN + "404_cached_file.js", + MAIN_DOMAIN + "browser_storage_basic.js", + ], + ], +]; + +/** + * Test that the desired number of tree items are present + */ +function testTree(tests) { + const doc = gPanelWindow.document; + for (const [item] of tests) { + ok( + doc.querySelector("[data-id='" + JSON.stringify(item) + "']"), + `Tree item ${item.toSource()} should be present in the storage tree` + ); + } +} + +/** + * Test that correct table entries are shown for each of the tree item + */ +async function testTables(tests) { + const doc = gPanelWindow.document; + // Expand all nodes so that the synthesized click event actually works + gUI.tree.expandAll(); + + // First tree item is already selected so no clicking and waiting for update + for (const id of tests[0][1]) { + ok( + doc.querySelector(".table-widget-cell[data-id='" + id + "']"), + "Table item " + id + " should be present" + ); + } + + // Click rest of the tree items and wait for the table to be updated + for (const [treeItem, items] of tests.slice(1)) { + await selectTreeItem(treeItem); + + // Check whether correct number of items are present in the table + is( + doc.querySelectorAll( + ".table-widget-column:first-of-type .table-widget-cell" + ).length, + items.length, + "Number of items in table is correct" + ); + + // Check if all the desired items are present in the table + for (const id of items) { + ok( + doc.querySelector(".table-widget-cell[data-id='" + id + "']"), + "Table item " + id + " should be present" + ); + } + } +} + +add_task(async function () { + // storage-listings.html explicitly mixes secure and insecure frames. + // We should not enforce https for tests using this page. + await pushPref("dom.security.https_first", false); + + await openTabAndSetupStorage( + MAIN_DOMAIN + "storage-listings-usercontextid.html", + { userContextId: 1 } + ); + + testTree(testCasesUserContextId); + await testTables(testCasesUserContextId); +}); diff --git a/devtools/client/storage/test/browser_storage_basic_with_fragment.js b/devtools/client/storage/test/browser_storage_basic_with_fragment.js new file mode 100644 index 0000000000..dcb18c7752 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_basic_with_fragment.js @@ -0,0 +1,177 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// A second basic test to assert that the storage tree and table corresponding +// to each item in the storage tree is correctly displayed. + +// This test differs from browser_storage_basic.js because the URLs we load +// include fragments e.g. http://example.com/test.js#abcdefg +// ^^^^^^^^ +// fragment + +// Entries that should be present in the tree for this test +// Format for each entry in the array : +// [ +// ["path", "to", "tree", "item"], - The path to the tree item to click formed +// by id of each item +// ["key_value1", "key_value2", ...] - The value of the first (unique) column +// for each row in the table corresponding +// to the tree item selected. +// ] +// These entries are formed by the cookies, local storage, session storage and +// indexedDB entries created in storage-listings.html, +// storage-secured-iframe.html and storage-unsecured-iframe.html + +"use strict"; + +const testCases = [ + [ + ["cookies", "http://test1.example.org"], + [ + getCookieId("c1", "test1.example.org", "/browser"), + getCookieId("cs2", ".example.org", "/"), + getCookieId("c3", "test1.example.org", "/"), + getCookieId("uc1", ".example.org", "/"), + getCookieId("uc2", ".example.org", "/"), + ], + ], + [ + ["cookies", "https://sectest1.example.org"], + [ + getCookieId("uc1", ".example.org", "/"), + getCookieId("uc2", ".example.org", "/"), + getCookieId("cs2", ".example.org", "/"), + getCookieId( + "sc1", + "sectest1.example.org", + "/browser/devtools/client/storage/test" + ), + getCookieId( + "sc2", + "sectest1.example.org", + "/browser/devtools/client/storage/test" + ), + ], + ], + [ + ["localStorage", "http://test1.example.org"], + ["ls1", "ls2"], + ], + [["localStorage", "http://sectest1.example.org"], ["iframe-u-ls1"]], + [["localStorage", "https://sectest1.example.org"], ["iframe-s-ls1"]], + [["sessionStorage", "http://test1.example.org"], ["ss1"]], + [ + ["sessionStorage", "http://sectest1.example.org"], + ["iframe-u-ss1", "iframe-u-ss2"], + ], + [["sessionStorage", "https://sectest1.example.org"], ["iframe-s-ss1"]], + [ + ["indexedDB", "http://test1.example.org"], + ["idb1 (default)", "idb2 (default)"], + ], + [ + ["indexedDB", "http://test1.example.org", "idb1 (default)"], + ["obj1", "obj2"], + ], + [["indexedDB", "http://test1.example.org", "idb2 (default)"], ["obj3"]], + [ + ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"], + [1, 2, 3], + ], + [["indexedDB", "http://test1.example.org", "idb1 (default)", "obj2"], [1]], + [["indexedDB", "http://test1.example.org", "idb2 (default)", "obj3"], []], + [["indexedDB", "http://sectest1.example.org"], []], + [ + ["indexedDB", "https://sectest1.example.org"], + ["idb-s1 (default)", "idb-s2 (default)"], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)"], + ["obj-s1"], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)"], + ["obj-s2"], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)", "obj-s1"], + [6, 7], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)", "obj-s2"], + [16], + ], + [ + ["Cache", "http://test1.example.org", "plop"], + [ + MAIN_DOMAIN + "404_cached_file.js", + MAIN_DOMAIN + "browser_storage_basic.js", + ], + ], +]; + +/** + * Test that the desired number of tree items are present + */ +function testTree() { + const doc = gPanelWindow.document; + for (const [item] of testCases) { + ok( + doc.querySelector("[data-id='" + JSON.stringify(item) + "']"), + `Tree item ${item.toSource()} should be present in the storage tree` + ); + } +} + +/** + * Test that correct table entries are shown for each of the tree item + */ +async function testTables() { + const doc = gPanelWindow.document; + // Expand all nodes so that the synthesized click event actually works + gUI.tree.expandAll(); + + // First tree item is already selected so no clicking and waiting for update + for (const id of testCases[0][1]) { + ok( + doc.querySelector(".table-widget-cell[data-id='" + id + "']"), + "Table item " + id + " should be present" + ); + } + + // Click rest of the tree items and wait for the table to be updated + for (const [treeItem, items] of testCases.slice(1)) { + await selectTreeItem(treeItem); + + // Check whether correct number of items are present in the table + is( + doc.querySelectorAll( + ".table-widget-column:first-of-type .table-widget-cell" + ).length, + items.length, + "Number of items in table is correct" + ); + + // Check if all the desired items are present in the table + for (const id of items) { + ok( + doc.querySelector(".table-widget-cell[data-id='" + id + "']"), + "Table item " + id + " should be present" + ); + } + } +} + +add_task(async function () { + // storage-listings.html explicitly mixes secure and insecure frames. + // We should not enforce https for tests using this page. + await pushPref("dom.security.https_first", false); + + await openTabAndSetupStorage( + MAIN_DOMAIN + "storage-listings-with-fragment.html#abc" + ); + + testTree(); + await testTables(); +}); diff --git a/devtools/client/storage/test/browser_storage_cache_delete.js b/devtools/client/storage/test/browser_storage_cache_delete.js new file mode 100644 index 0000000000..991ce22891 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_cache_delete.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/. */ + +"use strict"; + +// Test deleting a Cache object from the tree using context menu + +add_task(async function () { + await pushPref("dom.security.https_first", false); + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html"); + + const contextMenu = + gPanelWindow.document.getElementById("storage-tree-popup"); + const menuDeleteItem = contextMenu.querySelector( + "#storage-tree-popup-delete" + ); + + const cacheToDelete = ["Cache", "http://test1.example.org", "plop"]; + + info("test state before delete"); + await selectTreeItem(cacheToDelete); + ok(gUI.tree.isSelected(cacheToDelete), "Cache item is present in the tree"); + + info("do the delete"); + const eventWait = gUI.once("store-objects-updated"); + + const selector = `[data-id='${JSON.stringify( + cacheToDelete + )}'] > .tree-widget-item`; + const target = gPanelWindow.document.querySelector(selector); + ok(target, "Cache item's tree element is present"); + + await waitForContextMenu(contextMenu, target, () => { + info("Opened tree context menu"); + menuDeleteItem.click(); + + const cacheName = cacheToDelete[2]; + ok( + menuDeleteItem.getAttribute("label").includes(cacheName), + `Context menu item label contains '${cacheName}')` + ); + }); + + await eventWait; + + info("test state after delete"); + await selectTreeItem(cacheToDelete); + ok( + !gUI.tree.isSelected(cacheToDelete), + "Cache item is no longer present in the tree" + ); +}); diff --git a/devtools/client/storage/test/browser_storage_cache_error.js b/devtools/client/storage/test/browser_storage_cache_error.js new file mode 100644 index 0000000000..4aef96d0a8 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_cache_error.js @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +// Test handling errors in CacheStorage + +add_task(async function () { + // Open the URL in a private browsing window. + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + const tab = win.gBrowser.selectedBrowser; + const triggeringPrincipal = + Services.scriptSecurityManager.getSystemPrincipal(); + tab.loadURI( + Services.io.newURI(ALT_DOMAIN_SECURED + "storage-cache-error.html"), + { triggeringPrincipal } + ); + await BrowserTestUtils.browserLoaded(tab); + + // On enumerating cache storages, CacheStorage::Keys would throw a + // DOM security exception. We'd like to verify storage panel still work in + // this case. + await openStoragePanel({ tab: win.gBrowser.selectedTab }); + + const cacheItemId = ["Cache", "https://test2.example.org"]; + + await selectTreeItem(cacheItemId); + ok( + gUI.tree.isSelected(cacheItemId), + `The item ${cacheItemId.join(" > ")} is present in the tree` + ); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/devtools/client/storage/test/browser_storage_cache_navigation.js b/devtools/client/storage/test/browser_storage_cache_navigation.js new file mode 100644 index 0000000000..5421b85028 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_cache_navigation.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"; + +add_task(async function () { + const URL1 = buildURLWithContent( + "example.com", + `<h1>example.com</h1>` + + `<script> + caches.open("lorem").then(cache => { + cache.add("${URL_ROOT_COM_SSL}storage-blank.html"); + }); + function clear() { + caches.delete("lorem"); + } + </script>` + ); + const URL2 = buildURLWithContent( + "example.net", + `<h1>example.net</h1>` + + `<script> + caches.open("foo").then(cache => { + cache.add("${URL_ROOT_NET_SSL}storage-blank.html"); + }); + function clear() { + caches.delete("foo"); + } + </script>` + ); + + // open tab + await openTabAndSetupStorage(URL1); + const doc = gPanelWindow.document; + + // Check first domain + // check that host appears in the storage tree + checkTree(doc, ["Cache", "https://example.com", "lorem"]); + // check the table for values + await selectTreeItem(["Cache", "https://example.com", "lorem"]); + checkCacheData(URL_ROOT_COM_SSL + "storage-blank.html", "OK"); + + // clear up the cache before navigating + info("Cleaning up cache…"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const win = content.wrappedJSObject; + await win.clear(); + }); + + // Check second domain + await navigateTo(URL2); + + // Select the Cache view in order to force updating it + await selectTreeItem(["Cache", "https://example.net"]); + + // wait for storage tree refresh, and check host + info("Waiting for storage tree to update…"); + await waitUntil(() => isInTree(doc, ["Cache", "https://example.net", "foo"])); + + ok( + !isInTree(doc, ["Cache", "https://example.com"]), + "example.com item is not in the tree anymore" + ); + + // check the table for values + await selectTreeItem(["Cache", "https://example.net", "foo"]); + checkCacheData(URL_ROOT_NET_SSL + "storage-blank.html", "OK"); + + info("Check that the Cache node still has the expected label"); + is( + getTreeNodeLabel(doc, ["Cache"]), + "Cache Storage", + "Cache item is properly displayed" + ); +}); + +function checkCacheData(url, status) { + is( + gUI.table.items.get(url)?.status, + status, + `Table row has an entry for: ${url} with status: ${status}` + ); +} diff --git a/devtools/client/storage/test/browser_storage_cache_overflow.js b/devtools/client/storage/test/browser_storage_cache_overflow.js new file mode 100644 index 0000000000..86e761b77b --- /dev/null +++ b/devtools/client/storage/test/browser_storage_cache_overflow.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/. */ + +// Test endless scrolling when a lot of items are present in the storage +// inspector table for Cache storage. +"use strict"; + +const ITEMS_PER_PAGE = 50; + +add_task(async function () { + await openTabAndSetupStorage( + URL_ROOT_COM_SSL + "storage-cache-overflow.html" + ); + + gUI.tree.expandAll(); + + await selectTreeItem(["Cache", "https://example.com", "lorem"]); + await waitFor( + () => getCellLength() == ITEMS_PER_PAGE, + "Wait until the first 50 messages have been rendered" + ); + + await scroll(); + await waitFor( + () => getCellLength() == ITEMS_PER_PAGE * 2, + "Wait until 100 messages have been rendered" + ); + + info("Close Toolbox"); + await gDevTools.closeToolboxForTab(gBrowser.selectedTab); +}); diff --git a/devtools/client/storage/test/browser_storage_cookies_add.js b/devtools/client/storage/test/browser_storage_cookies_add.js new file mode 100644 index 0000000000..a4eda3cd0e --- /dev/null +++ b/devtools/client/storage/test/browser_storage_cookies_add.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/. */ + +// Basic test to check the adding of cookies. + +"use strict"; + +add_task(async function () { + const TEST_URL = MAIN_DOMAIN + "storage-cookies.html"; + await openTabAndSetupStorage(TEST_URL); + showAllColumns(true); + + const rowId = await performAdd(["cookies", "http://test1.example.org"]); + checkCookieData(rowId); + + await performAdd(["cookies", "http://test1.example.org"]); + await performAdd(["cookies", "http://test1.example.org"]); + await performAdd(["cookies", "http://test1.example.org"]); + await performAdd(["cookies", "http://test1.example.org"]); + + info("Check it does work in private tabs too"); + const privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + ok(PrivateBrowsingUtils.isWindowPrivate(privateWindow), "window is private"); + const privateTab = await addTab(TEST_URL, { window: privateWindow }); + await openStoragePanel({ tab: privateTab }); + const privateTabRowId = await performAdd([ + "cookies", + "http://test1.example.org", + ]); + checkCookieData(privateTabRowId); + + await performAdd(["cookies", "http://test1.example.org"]); + privateWindow.close(); +}); + +function checkCookieData(rowId) { + is(getCellValue(rowId, "value"), "value", "value is correct"); + is(getCellValue(rowId, "host"), "test1.example.org", "host is correct"); + is(getCellValue(rowId, "path"), "/", "path is correct"); + const actualExpiry = Math.floor( + new Date(getCellValue(rowId, "expires")) / 1000 + ); + const ONE_DAY_IN_SECONDS = 60 * 60 * 24; + const time = Math.floor(new Date(getCellValue(rowId, "creationTime")) / 1000); + const expectedExpiry = time + ONE_DAY_IN_SECONDS; + Assert.lessOrEqual( + actualExpiry - expectedExpiry, + 2, + "expiry is in expected range" + ); + is(getCellValue(rowId, "size"), "43", "size is correct"); + is(getCellValue(rowId, "isHttpOnly"), "false", "httpOnly is not set"); + is(getCellValue(rowId, "isSecure"), "false", "secure is not set"); + is(getCellValue(rowId, "sameSite"), "Lax", "sameSite is Lax"); + is(getCellValue(rowId, "hostOnly"), "true", "hostOnly is not set"); +} diff --git a/devtools/client/storage/test/browser_storage_cookies_delete_all.js b/devtools/client/storage/test/browser_storage_cookies_delete_all.js new file mode 100644 index 0000000000..a637b8a4ab --- /dev/null +++ b/devtools/client/storage/test/browser_storage_cookies_delete_all.js @@ -0,0 +1,185 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +// Test deleting all cookies + +async function performDelete(store, rowName, action) { + const contextMenu = gPanelWindow.document.getElementById( + "storage-table-popup" + ); + const menuDeleteAllItem = contextMenu.querySelector( + "#storage-table-popup-delete-all" + ); + const menuDeleteAllSessionCookiesItem = contextMenu.querySelector( + "#storage-table-popup-delete-all-session-cookies" + ); + const menuDeleteAllFromItem = contextMenu.querySelector( + "#storage-table-popup-delete-all-from" + ); + + const storeName = store.join(" > "); + + await selectTreeItem(store); + + const eventWait = gUI.once("store-objects-edit"); + const cells = getRowCells(rowName, true); + + await waitForContextMenu(contextMenu, cells.name, () => { + info(`Opened context menu in ${storeName}, row '${rowName}'`); + switch (action) { + case "deleteAll": + menuDeleteAllItem.click(); + break; + case "deleteAllSessionCookies": + menuDeleteAllSessionCookiesItem.click(); + break; + case "deleteAllFrom": + menuDeleteAllFromItem.click(); + const hostName = cells.host.value; + ok( + menuDeleteAllFromItem.getAttribute("label").includes(hostName), + `Context menu item label contains '${hostName}'` + ); + break; + } + }); + + await eventWait; +} + +add_task(async function () { + // storage-listings.html explicitly mixes secure and insecure frames. + // We should not enforce https for tests using this page. + await pushPref("dom.security.https_first", false); + + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html"); + + info("test state before delete"); + await checkState([ + [ + ["cookies", "http://test1.example.org"], + [ + getCookieId("c1", "test1.example.org", "/browser"), + getCookieId("c3", "test1.example.org", "/"), + getCookieId("cs2", ".example.org", "/"), + getCookieId("c4", ".example.org", "/"), + getCookieId("uc1", ".example.org", "/"), + getCookieId("uc2", ".example.org", "/"), + ], + ], + [ + ["cookies", "https://sectest1.example.org"], + [ + getCookieId("cs2", ".example.org", "/"), + getCookieId("c4", ".example.org", "/"), + getCookieId( + "sc1", + "sectest1.example.org", + "/browser/devtools/client/storage/test" + ), + getCookieId( + "sc2", + "sectest1.example.org", + "/browser/devtools/client/storage/test" + ), + getCookieId("uc1", ".example.org", "/"), + getCookieId("uc2", ".example.org", "/"), + ], + ], + ]); + + info("delete all from domain"); + // delete only cookies that match the host exactly + let id = getCookieId("c1", "test1.example.org", "/browser"); + await performDelete( + ["cookies", "http://test1.example.org"], + id, + "deleteAllFrom" + ); + + info("test state after delete all from domain"); + await checkState([ + // Domain cookies (.example.org) must not be deleted. + [ + ["cookies", "http://test1.example.org"], + [ + getCookieId("cs2", ".example.org", "/"), + getCookieId("c4", ".example.org", "/"), + getCookieId("uc1", ".example.org", "/"), + getCookieId("uc2", ".example.org", "/"), + ], + ], + [ + ["cookies", "https://sectest1.example.org"], + [ + getCookieId("cs2", ".example.org", "/"), + getCookieId("c4", ".example.org", "/"), + getCookieId("uc1", ".example.org", "/"), + getCookieId("uc2", ".example.org", "/"), + getCookieId( + "sc1", + "sectest1.example.org", + "/browser/devtools/client/storage/test" + ), + getCookieId( + "sc2", + "sectest1.example.org", + "/browser/devtools/client/storage/test" + ), + ], + ], + ]); + + info("delete all session cookies"); + // delete only session cookies + id = getCookieId("cs2", ".example.org", "/"); + await performDelete( + ["cookies", "http://sectest1.example.org"], + id, + "deleteAllSessionCookies" + ); + + info("test state after delete all session cookies"); + await checkState([ + // Cookies with expiry date must not be deleted. + [ + ["cookies", "http://test1.example.org"], + [ + getCookieId("c4", ".example.org", "/"), + getCookieId("uc2", ".example.org", "/"), + ], + ], + [ + ["cookies", "https://sectest1.example.org"], + [ + getCookieId("c4", ".example.org", "/"), + getCookieId("uc2", ".example.org", "/"), + getCookieId( + "sc2", + "sectest1.example.org", + "/browser/devtools/client/storage/test" + ), + ], + ], + ]); + + info("delete all"); + // delete all cookies for host, including domain cookies + id = getCookieId("uc2", ".example.org", "/"); + await performDelete( + ["cookies", "http://sectest1.example.org"], + id, + "deleteAll" + ); + + info("test state after delete all"); + await checkState([ + // Domain cookies (.example.org) are deleted too, so deleting in sectest1 + // also removes stuff from test1. + [["cookies", "http://test1.example.org"], []], + [["cookies", "https://sectest1.example.org"], []], + ]); +}); diff --git a/devtools/client/storage/test/browser_storage_cookies_domain.js b/devtools/client/storage/test/browser_storage_cookies_domain.js new file mode 100644 index 0000000000..5e4510196a --- /dev/null +++ b/devtools/client/storage/test/browser_storage_cookies_domain.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Test that cookies with domain equal to full host name are listed. +// E.g., ".example.org" vs. example.org). Bug 1149497. + +add_task(async function () { + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-cookies.html"); + + await checkState([ + [ + ["cookies", "http://test1.example.org"], + [ + getCookieId("test1", ".test1.example.org", "/browser"), + getCookieId("test2", "test1.example.org", "/browser"), + getCookieId("test3", ".test1.example.org", "/browser"), + getCookieId("test4", "test1.example.org", "/browser"), + getCookieId("test5", ".test1.example.org", "/browser"), + ], + ], + ]); +}); diff --git a/devtools/client/storage/test/browser_storage_cookies_domain_port.js b/devtools/client/storage/test/browser_storage_cookies_domain_port.js new file mode 100644 index 0000000000..8dd8f25d0a --- /dev/null +++ b/devtools/client/storage/test/browser_storage_cookies_domain_port.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Test that cookies with domain equal to full host name and port are listed. +// E.g., ".example.org:8000" vs. example.org:8000). + +add_task(async function () { + await openTabAndSetupStorage(MAIN_DOMAIN_WITH_PORT + "storage-cookies.html"); + + await checkState([ + [ + ["cookies", "http://test1.example.org:8000"], + [ + getCookieId("test1", ".test1.example.org", "/browser"), + getCookieId("test2", "test1.example.org", "/browser"), + getCookieId("test3", ".test1.example.org", "/browser"), + getCookieId("test4", "test1.example.org", "/browser"), + getCookieId("test5", ".test1.example.org", "/browser"), + ], + ], + ]); +}); diff --git a/devtools/client/storage/test/browser_storage_cookies_edit.js b/devtools/client/storage/test/browser_storage_cookies_edit.js new file mode 100644 index 0000000000..f49635750f --- /dev/null +++ b/devtools/client/storage/test/browser_storage_cookies_edit.js @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Basic test to check the editing of cookies. + +"use strict"; + +add_task(async function () { + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-cookies.html"); + showAllColumns(true); + + let id = getCookieId("test3", ".test1.example.org", "/browser"); + await editCell(id, "name", "newTest3"); + + id = getCookieId("newTest3", ".test1.example.org", "/browser"); + await editCell(id, "host", "test1.example.org"); + + id = getCookieId("newTest3", "test1.example.org", "/browser"); + await editCell(id, "path", "/"); + + id = getCookieId("newTest3", "test1.example.org", "/"); + await editCell(id, "expires", "Tue, 14 Feb 2040 17:41:14 GMT"); + await editCell(id, "value", "newValue3"); + await editCell(id, "isSecure", "true"); + await editCell(id, "isHttpOnly", "true"); +}); diff --git a/devtools/client/storage/test/browser_storage_cookies_edit_keyboard.js b/devtools/client/storage/test/browser_storage_cookies_edit_keyboard.js new file mode 100644 index 0000000000..c7f7857cf5 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_cookies_edit_keyboard.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Basic test to check the editing of cookies with the keyboard. + +"use strict"; + +add_task(async function () { + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-cookies.html"); + showAllColumns(true); + showColumn("uniqueKey", false); + + const id = getCookieId("test4", "test1.example.org", "/browser"); + await startCellEdit(id, "name"); + await typeWithTerminator("test6", "KEY_Tab"); + await typeWithTerminator("test6value", "KEY_Tab"); + await typeWithTerminator(".example.org", "KEY_Tab"); + await typeWithTerminator("/", "KEY_Tab"); + await typeWithTerminator("Tue, 25 Dec 2040 12:00:00 GMT", "KEY_Tab"); + await typeWithTerminator("false", "KEY_Tab"); + await typeWithTerminator("false", "KEY_Tab"); +}); diff --git a/devtools/client/storage/test/browser_storage_cookies_hostOnly.js b/devtools/client/storage/test/browser_storage_cookies_hostOnly.js new file mode 100644 index 0000000000..99280cff9f --- /dev/null +++ b/devtools/client/storage/test/browser_storage_cookies_hostOnly.js @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Test that the HostOnly values displayed in the table are correct. + +SpecialPowers.pushPrefEnv({ + set: [["security.allow_eval_with_system_principal", true]], +}); + +add_task(async function () { + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-complex-values.html"); + + gUI.tree.expandAll(); + + showColumn("hostOnly", true); + + const c1id = getCookieId("c1", "test1.example.org", "/browser"); + await selectTableItem(c1id); + checkCell(c1id, "hostOnly", "true"); + + const c2id = getCookieId("cs2", ".example.org", "/"); + await selectTableItem(c2id); + checkCell(c2id, "hostOnly", "false"); +}); diff --git a/devtools/client/storage/test/browser_storage_cookies_navigation.js b/devtools/client/storage/test/browser_storage_cookies_navigation.js new file mode 100644 index 0000000000..3dc1406451 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_cookies_navigation.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"; + +add_task(async function () { + // Bug 1617611: Fix all the tests broken by "cookies SameSite=lax by default" + await SpecialPowers.pushPrefEnv({ + set: [["network.cookie.sameSite.laxByDefault", false]], + }); + + const URL1 = buildURLWithContent( + "example.com", + `<h1>example.com</h1>` + `<script>document.cookie = "lorem=ipsum";</script>` + ); + const URL2 = buildURLWithContent( + "example.net", + `<h1>example.net</h1>` + + `<iframe></iframe>` + + `<script>document.cookie = "foo=bar";</script>` + ); + const URL_IFRAME = buildURLWithContent( + "example.org", + `<h1>example.org</h1>` + `<script>document.cookie = "hello=world";</script>` + ); + + // open tab + await openTabAndSetupStorage(URL1); + const doc = gPanelWindow.document; + + // Check first domain + // check that both host appear in the storage tree + checkTree(doc, ["cookies", "https://example.com"]); + // check the table for values + await selectTreeItem(["cookies", "https://example.com"]); + checkCookieData("lorem", "ipsum"); + + // NOTE: No need to clean up cookies since Services.cookies.removeAll() from + // the registered clean up function will remove all of them. + + // Check second domain + await navigateTo(URL2); + // wait for storage tree refresh, and check host + info("Waiting for storage tree to refresh and show correct host…"); + await waitUntil( + () => + isInTree(doc, ["cookies", "https://example.net"]) && + !isInTree(doc, ["cookies", "https://example.com"]) + ); + + ok( + !isInTree(doc, ["cookies", "https://example.com"]), + "example.com item is not in the tree anymore" + ); + + // check the table for values + // NOTE: there's an issue with the TreeWidget in which `selectedItem` is set + // but we have nothing selected in the UI. See Bug 1712706. + // Here we are forcing selecting a different item first. + await selectTreeItem(["cookies"]); + await selectTreeItem(["cookies", "https://example.net"]); + info("Waiting for table data to update and show correct values"); + await waitUntil(() => hasCookieData("foo", "bar")); + + // reload the current page, and check again + await reloadBrowser(); + // wait for storage tree refresh, and check host + info("Waiting for storage tree to refresh and show correct host…"); + await waitUntil(() => isInTree(doc, ["cookies", "https://example.net"])); + // check the table for values + // NOTE: there's an issue with the TreeWidget in which `selectedItem` is set + // but we have nothing selected in the UI. See Bug 1712706. + // Here we are forcing selecting a different item first. + await selectTreeItem(["cookies"]); + await selectTreeItem(["cookies", "https://example.net"]); + info("Waiting for table data to update and show correct values"); + await waitUntil(() => hasCookieData("foo", "bar")); + + // make the iframe navigate to a different domain + const onStorageTreeUpdated = gUI.once("store-objects-edit"); + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [URL_IFRAME], + async function (url) { + const iframe = content.document.querySelector("iframe"); + const onIframeLoaded = new Promise(loaded => + iframe.addEventListener("load", loaded, { once: true }) + ); + iframe.src = url; + await onIframeLoaded; + } + ); + info("Waiting for storage tree to update"); + await onStorageTreeUpdated; + + info("Waiting for storage tree to refresh and show correct host…"); + await waitUntil(() => isInTree(doc, ["cookies", "https://example.org"])); + info("Checking cookie data"); + await selectTreeItem(["cookies", "https://example.org"]); + checkCookieData("hello", "world"); + + info( + "Navigate to the first URL to check that the multiple hosts in the current document are all removed" + ); + await navigateTo(URL1); + ok(true, "navigated"); + await waitUntil(() => isInTree(doc, ["cookies", "https://example.com"])); + ok( + !isInTree(doc, ["cookies", "https://example.net"]), + "host of previous document (example.net) is not in the tree anymore" + ); + ok( + !isInTree(doc, ["cookies", "https://example.org"]), + "host of iframe in previous document (example.org) is not in the tree anymore" + ); + + info("Navigate backward to test bfcache navigation"); + gBrowser.goBack(); + await waitUntil( + () => + isInTree(doc, ["cookies", "https://example.net"]) && + isInTree(doc, ["cookies", "https://example.org"]) + ); + + ok( + !isInTree(doc, ["cookies", "https://example.com"]), + "host of previous document (example.com) is not in the tree anymore" + ); + + info("Check that the Cookies node still has the expected label"); + is( + getTreeNodeLabel(doc, ["cookies"]), + "Cookies", + "Cookies item is properly displayed" + ); + + SpecialPowers.clearUserPref("network.cookie.sameSite.laxByDefault"); +}); diff --git a/devtools/client/storage/test/browser_storage_cookies_samesite.js b/devtools/client/storage/test/browser_storage_cookies_samesite.js new file mode 100644 index 0000000000..dc58aaa9f6 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_cookies_samesite.js @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +// Test that the samesite cookie attribute is displayed correctly. + +add_task(async function () { + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-cookies-samesite.html"); + + const id1 = getCookieId( + "test1", + "test1.example.org", + "/browser/devtools/client/storage/test" + ); + const id2 = getCookieId( + "test2", + "test1.example.org", + "/browser/devtools/client/storage/test" + ); + const id3 = getCookieId( + "test3", + "test1.example.org", + "/browser/devtools/client/storage/test" + ); + + await checkState([ + [ + ["cookies", "http://test1.example.org"], + [id1, id2, id3], + ], + ]); + + const sameSite1 = getRowValues(id1).sameSite; + const sameSite2 = getRowValues(id2).sameSite; + const sameSite3 = getRowValues(id3).sameSite; + + is(sameSite1, "None", `sameSite1 is "None"`); + is(sameSite2, "Lax", `sameSite2 is "Lax"`); + is(sameSite3, "Strict", `sameSite3 is "Strict"`); +}); diff --git a/devtools/client/storage/test/browser_storage_cookies_sort.js b/devtools/client/storage/test/browser_storage_cookies_sort.js new file mode 100644 index 0000000000..2b4316af53 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_cookies_sort.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/. */ + +// Test column sorting works and sorts dates correctly (including "session" +// cookies). + +"use strict"; + +add_task(async function () { + const TEST_URL = MAIN_DOMAIN + "storage-cookies-sort.html"; + await openTabAndSetupStorage(TEST_URL); + showAllColumns(true); + + info("Sort on the expires column, ascending order"); + clickColumnHeader("expires"); + + // Note that here we only specify `test_session` for `test_session1` and + // `test_session2`. Since we sort on the "expires" column, there is no point + // in asserting the order between those 2 items. + checkCells([ + "test_session", + "test_session", + "test_hour", + "test_day", + "test_year", + ]); + + info("Sort on the expires column, descending order"); + clickColumnHeader("expires"); + + // Again, only assert `test_session` for `test_session1` and `test_session2`. + checkCells([ + "test_year", + "test_day", + "test_hour", + "test_session", + "test_session", + ]); + + info("Sort on the name column, ascending order"); + clickColumnHeader("name"); + checkCells([ + "test_day", + "test_hour", + "test_session1", + "test_session2", + "test_year", + ]); +}); + +function checkCells(expected) { + const cells = [ + ...gPanelWindow.document.querySelectorAll("#name .table-widget-cell"), + ]; + cells.forEach(function (cell, i, arr) { + // We use startsWith in order to avoid asserting the relative order of + // "session" cookies when sorting on the "expires" column. + ok( + cell.value.startsWith(expected[i]), + `Cell value starts with "${expected[i]}".` + ); + }); +} diff --git a/devtools/client/storage/test/browser_storage_cookies_tab_navigation.js b/devtools/client/storage/test/browser_storage_cookies_tab_navigation.js new file mode 100644 index 0000000000..b7f1aaf159 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_cookies_tab_navigation.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Basic test to check cookie table tab navigation. + +"use strict"; + +add_task(async function () { + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-cookies.html"); + showAllColumns(true); + + const id = getCookieId("test1", ".test1.example.org", "/browser"); + await startCellEdit(id, "name"); + + PressKeyXTimes("VK_TAB", 15); + is(getCurrentEditorValue(), "value3", "We have tabbed to the correct cell."); + + PressKeyXTimes("VK_TAB", 15, { shiftKey: true }); + is( + getCurrentEditorValue(), + "test1", + "We have shift-tabbed to the correct cell." + ); +}); diff --git a/devtools/client/storage/test/browser_storage_delete.js b/devtools/client/storage/test/browser_storage_delete.js new file mode 100644 index 0000000000..96af0ca15d --- /dev/null +++ b/devtools/client/storage/test/browser_storage_delete.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"; + +// Test deleting storage items + +const TEST_CASES = [ + [["localStorage", "http://test1.example.org"], "ls1", "name"], + [["sessionStorage", "http://test1.example.org"], "ss1", "name"], + [ + ["cookies", "http://test1.example.org"], + getCookieId("c1", "test1.example.org", "/browser"), + "name", + ], + [ + ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"], + 1, + "name", + ], + [ + ["Cache", "http://test1.example.org", "plop"], + MAIN_DOMAIN + "404_cached_file.js", + "url", + ], +]; + +add_task(async function () { + // storage-listings.html explicitly mixes secure and insecure frames. + // We should not enforce https for tests using this page. + await pushPref("dom.security.https_first", false); + + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html"); + + const contextMenu = gPanelWindow.document.getElementById( + "storage-table-popup" + ); + const menuDeleteItem = contextMenu.querySelector( + "#storage-table-popup-delete" + ); + + for (const [treeItem, rowName, cellToClick] of TEST_CASES) { + const treeItemName = treeItem.join(" > "); + + info(`Selecting tree item ${treeItemName}`); + await selectTreeItem(treeItem); + + const row = getRowCells(rowName); + ok( + gUI.table.items.has(rowName), + `There is a row '${rowName}' in ${treeItemName}` + ); + + const eventWait = gUI.once("store-objects-edit"); + + await waitForContextMenu(contextMenu, row[cellToClick], () => { + info(`Opened context menu in ${treeItemName}, row '${rowName}'`); + contextMenu.activateItem(menuDeleteItem); + const truncatedRowName = String(rowName) + .replace(SEPARATOR_GUID, "-") + .substr(0, 16); + ok( + JSON.parse( + menuDeleteItem.getAttribute("data-l10n-args") + ).itemName.includes(truncatedRowName), + `Context menu item label contains '${rowName}' (maybe truncated)` + ); + }); + + info("Awaiting for store-objects-edit event"); + await eventWait; + + ok( + !gUI.table.items.has(rowName), + `There is no row '${rowName}' in ${treeItemName} after deletion` + ); + } +}); diff --git a/devtools/client/storage/test/browser_storage_delete_all.js b/devtools/client/storage/test/browser_storage_delete_all.js new file mode 100644 index 0000000000..4585821cee --- /dev/null +++ b/devtools/client/storage/test/browser_storage_delete_all.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"; + +// Test deleting all storage items + +add_task(async function () { + // storage-listings.html explicitly mixes secure and insecure frames. + // We should not enforce https for tests using this page. + await pushPref("dom.security.https_first", false); + + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html"); + + const contextMenu = gPanelWindow.document.getElementById( + "storage-table-popup" + ); + const menuDeleteAllItem = contextMenu.querySelector( + "#storage-table-popup-delete-all" + ); + + info("test state before delete"); + const beforeState = [ + [ + ["localStorage", "http://test1.example.org"], + ["key", "ls1", "ls2"], + ], + [["localStorage", "http://sectest1.example.org"], ["iframe-u-ls1"]], + [["localStorage", "https://sectest1.example.org"], ["iframe-s-ls1"]], + [ + ["sessionStorage", "http://test1.example.org"], + ["key", "ss1"], + ], + [ + ["sessionStorage", "http://sectest1.example.org"], + ["iframe-u-ss1", "iframe-u-ss2"], + ], + [["sessionStorage", "https://sectest1.example.org"], ["iframe-s-ss1"]], + [ + ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"], + [1, 2, 3], + ], + [ + ["Cache", "http://test1.example.org", "plop"], + [ + MAIN_DOMAIN + "404_cached_file.js", + MAIN_DOMAIN + "browser_storage_basic.js", + ], + ], + ]; + + await checkState(beforeState); + + info("do the delete"); + const deleteHosts = [ + [["localStorage", "https://sectest1.example.org"], "iframe-s-ls1", "name"], + [ + ["sessionStorage", "https://sectest1.example.org"], + "iframe-s-ss1", + "name", + ], + [ + ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"], + 1, + "name", + ], + [ + ["Cache", "http://test1.example.org", "plop"], + MAIN_DOMAIN + "404_cached_file.js", + "url", + ], + ]; + + for (const [store, rowName, cellToClick] of deleteHosts) { + const storeName = store.join(" > "); + + await selectTreeItem(store); + + const eventWait = gUI.once("store-objects-cleared"); + + const cell = getRowCells(rowName)[cellToClick]; + await waitForContextMenu(contextMenu, cell, () => { + info(`Opened context menu in ${storeName}, row '${rowName}'`); + contextMenu.activateItem(menuDeleteAllItem); + }); + + await eventWait; + } + + info("test state after delete"); + const afterState = [ + // iframes from the same host, one secure, one unsecure, are independent + // from each other. Delete all in one doesn't touch the other one. + [ + ["localStorage", "http://test1.example.org"], + ["key", "ls1", "ls2"], + ], + [["localStorage", "http://sectest1.example.org"], ["iframe-u-ls1"]], + [["localStorage", "https://sectest1.example.org"], []], + [ + ["sessionStorage", "http://test1.example.org"], + ["key", "ss1"], + ], + [ + ["sessionStorage", "http://sectest1.example.org"], + ["iframe-u-ss1", "iframe-u-ss2"], + ], + [["sessionStorage", "https://sectest1.example.org"], []], + [["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"], []], + [["Cache", "http://test1.example.org", "plop"], []], + ]; + + await checkState(afterState); +}); diff --git a/devtools/client/storage/test/browser_storage_delete_tree.js b/devtools/client/storage/test/browser_storage_delete_tree.js new file mode 100644 index 0000000000..047536acd7 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_delete_tree.js @@ -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/. */ + +"use strict"; + +// Test deleting all storage items from the tree. + +add_task(async function () { + // storage-listings.html explicitly mixes secure and insecure frames. + // We should not enforce https for tests using this page. + await pushPref("dom.security.https_first", false); + + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html"); + + const contextMenu = + gPanelWindow.document.getElementById("storage-tree-popup"); + const menuDeleteAllItem = contextMenu.querySelector( + "#storage-tree-popup-delete-all" + ); + + info("test state before delete"); + await checkState([ + [ + ["cookies", "http://test1.example.org"], + [ + getCookieId("c1", "test1.example.org", "/browser"), + getCookieId("cs2", ".example.org", "/"), + getCookieId("c3", "test1.example.org", "/"), + getCookieId("c4", ".example.org", "/"), + getCookieId("uc1", ".example.org", "/"), + getCookieId("uc2", ".example.org", "/"), + ], + ], + [ + ["localStorage", "http://test1.example.org"], + ["key", "ls1", "ls2"], + ], + [ + ["sessionStorage", "http://test1.example.org"], + ["key", "ss1"], + ], + [ + ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"], + [1, 2, 3], + ], + [ + ["Cache", "http://test1.example.org", "plop"], + [ + MAIN_DOMAIN + "404_cached_file.js", + MAIN_DOMAIN + "browser_storage_basic.js", + ], + ], + ]); + + info("do the delete"); + const deleteHosts = [ + ["cookies", "http://test1.example.org"], + ["localStorage", "http://test1.example.org"], + ["sessionStorage", "http://test1.example.org"], + ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"], + ["Cache", "http://test1.example.org", "plop"], + ]; + + for (const store of deleteHosts) { + const storeName = store.join(" > "); + + await selectTreeItem(store); + + const eventName = + "store-objects-" + (store[0] == "cookies" ? "edit" : "cleared"); + const eventWait = gUI.once(eventName); + + const selector = `[data-id='${JSON.stringify(store)}'] > .tree-widget-item`; + const target = gPanelWindow.document.querySelector(selector); + ok(target, `tree item found in ${storeName}`); + await waitForContextMenu(contextMenu, target, () => { + info(`Opened tree context menu in ${storeName}`); + contextMenu.activateItem(menuDeleteAllItem); + }); + + await eventWait; + } + + info("test state after delete"); + await checkState([ + [["cookies", "http://test1.example.org"], []], + [["localStorage", "http://test1.example.org"], []], + [["sessionStorage", "http://test1.example.org"], []], + [["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"], []], + [["Cache", "http://test1.example.org", "plop"], []], + ]); +}); diff --git a/devtools/client/storage/test/browser_storage_delete_usercontextid.js b/devtools/client/storage/test/browser_storage_delete_usercontextid.js new file mode 100644 index 0000000000..5e89028f9d --- /dev/null +++ b/devtools/client/storage/test/browser_storage_delete_usercontextid.js @@ -0,0 +1,238 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +// Test deleting storage items with userContextId. + +// The items that will be deleted. +const TEST_CASES = [ + [["localStorage", "http://test1.example.org"], "ls1", "name"], + [["sessionStorage", "http://test1.example.org"], "ss1", "name"], + [ + ["cookies", "http://test1.example.org"], + getCookieId("c1", "test1.example.org", "/browser"), + "name", + ], + [ + ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"], + 1, + "name", + ], + [ + ["Cache", "http://test1.example.org", "plop"], + MAIN_DOMAIN + "404_cached_file.js", + "url", + ], +]; + +// The storage items that should exist for default userContextId +const storageItemsForDefault = [ + [ + ["cookies", "http://test1.example.org"], + [ + getCookieId("c1", "test1.example.org", "/browser"), + getCookieId("cs2", ".example.org", "/"), + getCookieId("c3", "test1.example.org", "/"), + getCookieId("c4", ".example.org", "/"), + getCookieId("uc1", ".example.org", "/"), + getCookieId("uc2", ".example.org", "/"), + ], + ], + [ + ["cookies", "https://sectest1.example.org"], + [ + getCookieId("uc1", ".example.org", "/"), + getCookieId("uc2", ".example.org", "/"), + getCookieId("cs2", ".example.org", "/"), + getCookieId("c4", ".example.org", "/"), + getCookieId( + "sc1", + "sectest1.example.org", + "/browser/devtools/client/storage/test" + ), + getCookieId( + "sc2", + "sectest1.example.org", + "/browser/devtools/client/storage/test" + ), + ], + ], + [ + ["localStorage", "http://test1.example.org"], + ["key", "ls1", "ls2"], + ], + [["localStorage", "http://sectest1.example.org"], ["iframe-u-ls1"]], + [["localStorage", "https://sectest1.example.org"], ["iframe-s-ls1"]], + [ + ["sessionStorage", "http://test1.example.org"], + ["key", "ss1"], + ], + [ + ["sessionStorage", "http://sectest1.example.org"], + ["iframe-u-ss1", "iframe-u-ss2"], + ], + [["sessionStorage", "https://sectest1.example.org"], ["iframe-s-ss1"]], + [ + ["indexedDB", "http://test1.example.org"], + ["idb1 (default)", "idb2 (default)"], + ], + [ + ["indexedDB", "http://test1.example.org", "idb1 (default)"], + ["obj1", "obj2"], + ], + [["indexedDB", "http://test1.example.org", "idb2 (default)"], ["obj3"]], + [ + ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"], + [1, 2, 3], + ], + [["indexedDB", "http://test1.example.org", "idb1 (default)", "obj2"], [1]], + [["indexedDB", "http://test1.example.org", "idb2 (default)", "obj3"], []], + [["indexedDB", "http://sectest1.example.org"], []], + [ + ["indexedDB", "https://sectest1.example.org"], + ["idb-s1 (default)", "idb-s2 (default)"], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)"], + ["obj-s1"], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)"], + ["obj-s2"], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)", "obj-s1"], + [6, 7], + ], + [ + ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)", "obj-s2"], + [16], + ], + [ + ["Cache", "http://test1.example.org", "plop"], + [ + MAIN_DOMAIN + "404_cached_file.js", + MAIN_DOMAIN + "browser_storage_basic.js", + ], + ], +]; + +/** + * Test that the desired number of tree items are present + */ +function testTree(tests) { + const doc = gPanelWindow.document; + for (const [item] of tests) { + ok( + doc.querySelector("[data-id='" + JSON.stringify(item) + "']"), + `Tree item ${item.toSource()} should be present in the storage tree` + ); + } +} + +/** + * Test that correct table entries are shown for each of the tree item + */ +async function testTables(tests) { + const doc = gPanelWindow.document; + // Expand all nodes so that the synthesized click event actually works + gUI.tree.expandAll(); + + // First tree item is already selected so no clicking and waiting for update + for (const id of tests[0][1]) { + ok( + doc.querySelector(".table-widget-cell[data-id='" + id + "']"), + "Table item " + id + " should be present" + ); + } + + // Click rest of the tree items and wait for the table to be updated + for (const [treeItem, items] of tests.slice(1)) { + await selectTreeItem(treeItem); + + // Check whether correct number of items are present in the table + is( + doc.querySelectorAll( + ".table-widget-column:first-of-type .table-widget-cell" + ).length, + items.length, + "Number of items in table is correct" + ); + + // Check if all the desired items are present in the table + for (const id of items) { + ok( + doc.querySelector(".table-widget-cell[data-id='" + id + "']"), + "Table item " + id + " should be present" + ); + } + } +} + +add_task(async function () { + // storage-listings.html explicitly mixes secure and insecure frames. + // We should not enforce https for tests using this page. + await pushPref("dom.security.https_first", false); + + // First, open a tab with the default userContextId and setup its storages. + const tabDefault = await openTab(MAIN_DOMAIN + "storage-listings.html"); + + // Second, start testing for userContextId 1. + // We use the same item name as the default page has to see deleting items + // from userContextId 1 will affect default one or not. + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html", { + userContextId: 1, + }); + + const contextMenu = gPanelWindow.document.getElementById( + "storage-table-popup" + ); + const menuDeleteItem = contextMenu.querySelector( + "#storage-table-popup-delete" + ); + + for (const [treeItem, rowName, cellToClick] of TEST_CASES) { + const treeItemName = treeItem.join(" > "); + + info(`Selecting tree item ${treeItemName}`); + await selectTreeItem(treeItem); + + const row = getRowCells(rowName); + ok( + gUI.table.items.has(rowName), + `There is a row '${rowName}' in ${treeItemName}` + ); + + const eventWait = gUI.once("store-objects-edit"); + + await waitForContextMenu(contextMenu, row[cellToClick], () => { + info(`Opened context menu in ${treeItemName}, row '${rowName}'`); + contextMenu.activateItem(menuDeleteItem); + const truncatedRowName = String(rowName) + .replace(SEPARATOR_GUID, "-") + .substr(0, 16); + ok( + JSON.parse( + menuDeleteItem.getAttribute("data-l10n-args") + ).itemName.includes(truncatedRowName), + `Context menu item label contains '${rowName}' (maybe truncated)` + ); + }); + + await eventWait; + + ok( + !gUI.table.items.has(rowName), + `There is no row '${rowName}' in ${treeItemName} after deletion` + ); + } + + // Final, we see that the default tab is intact or not. + await BrowserTestUtils.switchTab(gBrowser, tabDefault); + await openStoragePanel(); + + testTree(storageItemsForDefault); + await testTables(storageItemsForDefault); +}); diff --git a/devtools/client/storage/test/browser_storage_dfpi.js b/devtools/client/storage/test/browser_storage_dfpi.js new file mode 100644 index 0000000000..14d625910e --- /dev/null +++ b/devtools/client/storage/test/browser_storage_dfpi.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/. */ + +// Basic test to assert that the storage tree and table corresponding to each +// item in the storage tree is correctly displayed + +"use strict"; + +const { SiteDataTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SiteDataTestUtils.sys.mjs" +); + +// Ensure iframe.src in storage-dfpi.html starts with PREFIX. +const PREFIX = "https://sub1.test1.example"; +const ORIGIN = `${PREFIX}.org`; +const ORIGIN_THIRD_PARTY = `${PREFIX}.com`; +const TEST_URL = `${ORIGIN}/${PATH}storage-dfpi.html`; + +function listOrigins() { + return new Promise(resolve => { + SpecialPowers.Services.qms.listOrigins().callback = req => { + resolve(req.result); + }; + }); +} + +add_task(async function () { + await pushPref( + "network.cookie.cookieBehavior", + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN + ); + + await pushPref( + "privacy.partition.always_partition_third_party_non_cookie_storage", + false + ); + + registerCleanupFunction(SiteDataTestUtils.clear); + + // `Services.qms.listOrigins()` may or contain results created by other tests. + // And it's unsafe to clear existing origins by `Services.qms.clear()`. + // In order to obtain correct results, we need to compare the results before + // and after `openTabAndSetupStorage` is called. + // To ensure more accurate results, try choosing a uncommon origin for PREFIX. + const EXISTING_ORIGINS = await listOrigins(); + ok(!EXISTING_ORIGINS.includes(ORIGIN), `${ORIGIN} doesn't exist`); + + await openTabAndSetupStorage(TEST_URL); + + const origins = await listOrigins(); + for (const origin of origins) { + ok( + EXISTING_ORIGINS.includes(origin) || origin === ORIGIN, + `check origin: ${origin}` + ); + } + ok(origins.includes(ORIGIN), `${ORIGIN} is added`); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +async function setPartitionedStorage(browser, type, key) { + const handler = async (storageType, storageKey, storageValue) => { + if (storageType == "cookie") { + content.document.cookie = `${storageKey}=${storageValue}`; + return; + } + content.localStorage.setItem(storageKey, storageValue); + }; + + // Set first party storage. + await SpecialPowers.spawn(browser, [type, key, "first"], handler); + // Set third-party (partitioned) storage in the iframe. + await SpecialPowers.spawn( + browser.browsingContext.children[0], + [type, key, "third"], + handler + ); +} + +async function checkData(storageType, key, value) { + if (storageType == "cookie") { + checkCookieData(key, value); + return; + } + await waitForStorageData(key, value); +} + +async function testPartitionedStorage( + storageType, + treeItemLabel = storageType +) { + await pushPref( + "network.cookie.cookieBehavior", + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN + ); + // Bug 1617611: Fix all the tests broken by "cookies SameSite=lax by default" + await pushPref("network.cookie.sameSite.laxByDefault", false); + + info( + "Open the test url in a new tab and add storage entries *before* opening the storage panel." + ); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await setPartitionedStorage(browser, storageType, "contextA"); + }); + + await openTabAndSetupStorage(TEST_URL); + + const doc = gPanelWindow.document; + + info("check that both hosts appear in the storage tree"); + checkTree(doc, [treeItemLabel, ORIGIN]); + checkTree(doc, [treeItemLabel, ORIGIN_THIRD_PARTY]); + + info( + "check that items for both first and third party host have the initial storage entries" + ); + + await selectTreeItem([treeItemLabel, ORIGIN]); + await checkData(storageType, "contextA", "first"); + + await selectTreeItem([treeItemLabel, ORIGIN_THIRD_PARTY]); + await checkData(storageType, "contextA", "third"); + + info("Add more entries while the storage panel is open"); + const onUpdated = gUI.once("store-objects-edit"); + await setPartitionedStorage( + gBrowser.selectedBrowser, + storageType, + "contextB" + ); + await onUpdated; + + info("check that both hosts appear in the storage tree"); + checkTree(doc, [treeItemLabel, ORIGIN]); + checkTree(doc, [treeItemLabel, ORIGIN_THIRD_PARTY]); + + info( + "check that items for both first and third party host have the updated storage entries" + ); + + await selectTreeItem([treeItemLabel, ORIGIN]); + await checkData(storageType, "contextA", "first"); + await checkData(storageType, "contextB", "first"); + + await selectTreeItem([treeItemLabel, ORIGIN_THIRD_PARTY]); + await checkData(storageType, "contextA", "third"); + await checkData(storageType, "contextB", "third"); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); +} + +// Tests that partitioned storage is shown in the storage panel. + +add_task(async function test_partitioned_cookies() { + registerCleanupFunction(SiteDataTestUtils.clear); + await testPartitionedStorage("cookie", "cookies"); +}); + +add_task(async function test_partitioned_localStorage() { + registerCleanupFunction(SiteDataTestUtils.clear); + await testPartitionedStorage("localStorage"); +}); diff --git a/devtools/client/storage/test/browser_storage_dfpi_always_partition_storage.js b/devtools/client/storage/test/browser_storage_dfpi_always_partition_storage.js new file mode 100644 index 0000000000..e2615fb951 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_dfpi_always_partition_storage.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/. */ + +// Basic test to assert that the storage tree and table corresponding to each +// item in the storage tree is correctly displayed, bearing in mind the origin +// is partitioned when always_partition_third_party_non_cookie_storage is true. + +"use strict"; + +const { SiteDataTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SiteDataTestUtils.sys.mjs" +); + +// Ensure iframe.src in storage-dfpi.html starts with PREFIX. +const PREFIX = "https://sub1.test1.example"; +const ORIGIN = `${PREFIX}.org`; +const ORIGIN_PARTITIONED = `${PREFIX}.com^partitionKey=%28https%2Cexample.org%29`; +const TEST_URL = `${ORIGIN}/document-builder.sjs?html= + <iframe src="${PREFIX}.com/browser/devtools/client/storage/test/storage-blank.html"></iframe> +`; + +function listOrigins() { + return new Promise(resolve => { + SpecialPowers.Services.qms.listOrigins().callback = req => { + resolve(req.result); + }; + }); +} + +add_task(async function () { + await pushPref( + "network.cookie.cookieBehavior", + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN + ); + + await pushPref( + "privacy.partition.always_partition_third_party_non_cookie_storage", + true + ); + + registerCleanupFunction(SiteDataTestUtils.clear); + + const expectedOrigins = [ORIGIN, ORIGIN_PARTITIONED]; + + // `Services.qms.listOrigins()` may or contain results created by other tests. + // And it's unsafe to clear existing origins by `Services.qms.clear()`. + // In order to obtain correct results, we need to compare the results before + // and after `openTabAndSetupStorage` is called. + // To ensure more accurate results, try choosing a uncommon origin for PREFIX. + const EXISTING_ORIGINS = await listOrigins(); + expectedOrigins.forEach(expected => { + ok(!EXISTING_ORIGINS.includes(expected), `${expected} doesn't exist`); + }); + + await openTabAndSetupStorage(TEST_URL); + + const origins = await listOrigins(); + for (const origin of origins) { + ok( + EXISTING_ORIGINS.includes(origin) || expectedOrigins.includes(origin), + `check origin: ${origin}` + ); + } + expectedOrigins.forEach(expected => { + ok(origins.includes(expected), `${expected} is added`); + }); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); diff --git a/devtools/client/storage/test/browser_storage_dynamic_updates_cookies.js b/devtools/client/storage/test/browser_storage_dynamic_updates_cookies.js new file mode 100644 index 0000000000..77c8047fc3 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_dynamic_updates_cookies.js @@ -0,0 +1,239 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true); + +registerCleanupFunction(() => { + Services.prefs.clearUserPref("security.allow_eval_with_system_principal"); +}); + +// Test dynamic updates in the storage inspector for cookies. + +add_task(async function () { + await openTabAndSetupStorage(MAIN_DOMAIN_SECURED + "storage-updates.html"); + + gUI.tree.expandAll(); + + ok(gUI.sidebar.hidden, "Sidebar is initially hidden"); + const c1id = getCookieId("c1", "test1.example.org", "/browser"); + await selectTableItem(c1id); + + // test that value is something initially + const initialValue = [ + [ + { name: "c1", value: "1.2.3.4.5.6.7" }, + { name: "c1.Path", value: "/browser" }, + ], + [ + { name: "c1", value: "Array" }, + { name: "c1.0", value: "1" }, + { name: "c1.6", value: "7" }, + ], + ]; + + // test that value is something initially + const finalValue = [ + [ + { name: "c1", value: '{"foo": 4,"bar":6}' }, + { name: "c1.Path", value: "/browser" }, + ], + [ + { name: "c1", value: "Object" }, + { name: "c1.foo", value: "4" }, + { name: "c1.bar", value: "6" }, + ], + ]; + + // Check that sidebar shows correct initial value + await findVariableViewProperties(initialValue[0], false); + + await findVariableViewProperties(initialValue[1], true); + + // Check if table shows correct initial value + await checkState([ + [ + ["cookies", "https://test1.example.org"], + [ + getCookieId("c1", "test1.example.org", "/browser"), + getCookieId("c2", "test1.example.org", "/browser"), + ], + ], + ]); + checkCell(c1id, "value", "1.2.3.4.5.6.7"); + + await addCookie("c1", '{"foo": 4,"bar":6}', "/browser"); + await gUI.once("store-objects-edit"); + + await findVariableViewProperties(finalValue[0], false); + await findVariableViewProperties(finalValue[1], true); + + await checkState([ + [ + ["cookies", "https://test1.example.org"], + [ + getCookieId("c1", "test1.example.org", "/browser"), + getCookieId("c2", "test1.example.org", "/browser"), + ], + ], + ]); + checkCell(c1id, "value", '{"foo": 4,"bar":6}'); + + // Add a new entry + await addCookie("c3", "booyeah"); + + await gUI.once("store-objects-edit"); + + await checkState([ + [ + ["cookies", "https://test1.example.org"], + [ + getCookieId("c1", "test1.example.org", "/browser"), + getCookieId("c2", "test1.example.org", "/browser"), + getCookieId( + "c3", + "test1.example.org", + "/browser/devtools/client/storage/test" + ), + ], + ], + ]); + const c3id = getCookieId( + "c3", + "test1.example.org", + "/browser/devtools/client/storage/test" + ); + checkCell(c3id, "value", "booyeah"); + + // Add another + await addCookie("c4", "booyeah"); + + await gUI.once("store-objects-edit"); + + await checkState([ + [ + ["cookies", "https://test1.example.org"], + [ + getCookieId("c1", "test1.example.org", "/browser"), + getCookieId("c2", "test1.example.org", "/browser"), + getCookieId( + "c3", + "test1.example.org", + "/browser/devtools/client/storage/test" + ), + getCookieId( + "c4", + "test1.example.org", + "/browser/devtools/client/storage/test" + ), + ], + ], + ]); + const c4id = getCookieId( + "c4", + "test1.example.org", + "/browser/devtools/client/storage/test" + ); + checkCell(c4id, "value", "booyeah"); + + // Removing cookies + await removeCookie("c1", "/browser"); + + await gUI.once("store-objects-edit"); + + await checkState([ + [ + ["cookies", "https://test1.example.org"], + [ + getCookieId("c2", "test1.example.org", "/browser"), + getCookieId( + "c3", + "test1.example.org", + "/browser/devtools/client/storage/test" + ), + getCookieId( + "c4", + "test1.example.org", + "/browser/devtools/client/storage/test" + ), + ], + ], + ]); + + ok(!gUI.sidebar.hidden, "Sidebar still visible for next row"); + + // Check if next element's value is visible in sidebar + await findVariableViewProperties([{ name: "c2", value: "foobar" }]); + + // Keep deleting till no rows + await removeCookie("c3"); + + await gUI.once("store-objects-edit"); + + await checkState([ + [ + ["cookies", "https://test1.example.org"], + [ + getCookieId("c2", "test1.example.org", "/browser"), + getCookieId( + "c4", + "test1.example.org", + "/browser/devtools/client/storage/test" + ), + ], + ], + ]); + + // Check if next element's value is visible in sidebar + await findVariableViewProperties([{ name: "c2", value: "foobar" }]); + + await removeCookie("c2", "/browser"); + + await gUI.once("store-objects-edit"); + + await checkState([ + [ + ["cookies", "https://test1.example.org"], + [ + getCookieId( + "c4", + "test1.example.org", + "/browser/devtools/client/storage/test" + ), + ], + ], + ]); + + // Check if next element's value is visible in sidebar + await findVariableViewProperties([{ name: "c4", value: "booyeah" }]); + + await removeCookie("c4"); + + await gUI.once("store-objects-edit"); + + await checkState([[["cookies", "https://test1.example.org"], []]]); + + ok(gUI.sidebar.hidden, "Sidebar is hidden when no rows"); +}); + +async function addCookie(name, value, path) { + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[name, value, path]], + ([nam, valu, pat]) => { + content.wrappedJSObject.addCookie(nam, valu, pat); + } + ); +} + +async function removeCookie(name, path) { + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[name, path]], + ([nam, pat]) => { + content.wrappedJSObject.removeCookie(nam, pat); + } + ); +} diff --git a/devtools/client/storage/test/browser_storage_dynamic_updates_localStorage.js b/devtools/client/storage/test/browser_storage_dynamic_updates_localStorage.js new file mode 100644 index 0000000000..f220d16b0c --- /dev/null +++ b/devtools/client/storage/test/browser_storage_dynamic_updates_localStorage.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"; + +// Test dynamic updates in the storage inspector for localStorage. + +add_task(async function () { + const TEST_HOST = "https://test1.example.org"; + + await openTabAndSetupStorage(MAIN_DOMAIN_SECURED + "storage-updates.html"); + + gUI.tree.expandAll(); + + ok(gUI.sidebar.hidden, "Sidebar is initially hidden"); + + const expectedKeys = ["1", "2", "3", "4", "5", "null", "non-json-parsable"]; + + // Test on string keys that JSON.parse can parse without throwing + // (to verify the issue fixed by Bug 1578447 doesn't regress). + await testRemoveAndChange("null", expectedKeys, TEST_HOST); + await testRemoveAndChange("4", expectedKeys, TEST_HOST); + // Test on a string that makes JSON.parse to throw. + await testRemoveAndChange("non-json-parsable", expectedKeys, TEST_HOST); + + // Clearing items. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + content.wrappedJSObject.clear(); + }); + + await gUI.once("store-objects-cleared"); + + await checkState([[["localStorage", TEST_HOST], []]]); +}); + +async function testRemoveAndChange(targetKey, expectedKeys, host) { + await checkState([[["localStorage", host], expectedKeys]]); + + await removeLocalStorageItem(targetKey); + await gUI.once("store-objects-edit"); + await checkState([ + [["localStorage", host], expectedKeys.filter(key => key !== targetKey)], + ]); + + await setLocalStorageItem(targetKey, "again"); + await gUI.once("store-objects-edit"); + await checkState([[["localStorage", host], expectedKeys]]); + + // Updating a row set to the string "null" + await setLocalStorageItem(targetKey, `key-${targetKey}-changed`); + await gUI.once("store-objects-edit"); + checkCell(targetKey, "value", `key-${targetKey}-changed`); +} + +async function setLocalStorageItem(key, value) { + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[key, value]], + ([innerKey, innerValue]) => { + content.wrappedJSObject.localStorage.setItem(innerKey, innerValue); + } + ); +} + +async function removeLocalStorageItem(key) { + await SpecialPowers.spawn(gBrowser.selectedBrowser, [key], innerKey => { + content.wrappedJSObject.localStorage.removeItem(innerKey); + }); +} diff --git a/devtools/client/storage/test/browser_storage_dynamic_updates_sessionStorage.js b/devtools/client/storage/test/browser_storage_dynamic_updates_sessionStorage.js new file mode 100644 index 0000000000..a6aa6890d5 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_dynamic_updates_sessionStorage.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/. */ + +"use strict"; + +// Test dynamic updates in the storage inspector for sessionStorage. + +add_task(async function () { + await openTabAndSetupStorage(MAIN_DOMAIN_SECURED + "storage-updates.html"); + + gUI.tree.expandAll(); + + ok(gUI.sidebar.hidden, "Sidebar is initially hidden"); + + await checkState([ + [ + ["sessionStorage", "https://test1.example.org"], + ["ss1", "ss2", "ss3"], + ], + ]); + + await setSessionStorageItem("ss4", "new-item"); + + await gUI.once("store-objects-edit"); + + await checkState([ + [ + ["sessionStorage", "https://test1.example.org"], + ["ss1", "ss2", "ss3", "ss4"], + ], + ]); + + // deleting item + + await removeSessionStorageItem("ss3"); + + await gUI.once("store-objects-edit"); + + await removeSessionStorageItem("ss1"); + + await gUI.once("store-objects-edit"); + + await checkState([ + [ + ["sessionStorage", "https://test1.example.org"], + ["ss2", "ss4"], + ], + ]); + + await selectTableItem("ss2"); + + ok(!gUI.sidebar.hidden, "sidebar is visible"); + + // Checking for correct value in sidebar before update + await findVariableViewProperties([{ name: "ss2", value: "foobar" }]); + + await setSessionStorageItem("ss2", "changed=ss2"); + + await gUI.once("sidebar-updated"); + + checkCell("ss2", "value", "changed=ss2"); + + await findVariableViewProperties([{ name: "ss2", value: "changed=ss2" }]); + + // Clearing items. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + content.wrappedJSObject.clear(); + }); + + await gUI.once("store-objects-cleared"); + + await checkState([[["sessionStorage", "https://test1.example.org"], []]]); +}); + +async function setSessionStorageItem(key, value) { + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[key, value]], + ([innerKey, innerValue]) => { + content.wrappedJSObject.sessionStorage.setItem(innerKey, innerValue); + } + ); +} + +async function removeSessionStorageItem(key) { + await SpecialPowers.spawn(gBrowser.selectedBrowser, [key], innerKey => { + content.wrappedJSObject.sessionStorage.removeItem(innerKey); + }); +} diff --git a/devtools/client/storage/test/browser_storage_empty_objectstores.js b/devtools/client/storage/test/browser_storage_empty_objectstores.js new file mode 100644 index 0000000000..647e1b362a --- /dev/null +++ b/devtools/client/storage/test/browser_storage_empty_objectstores.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/. */ + +// Basic test to assert that the storage tree and table corresponding to each +// item in the storage tree is correctly displayed. + +"use strict"; + +// Entries that should be present in the tree for this test +// Format for each entry in the array: +// [ +// ["path", "to", "tree", "item"], +// - The path to the tree item to click formed by id of each item +// ["key_value1", "key_value2", ...] +// - The value of the first (unique) column for each row in the table +// corresponding to the tree item selected. +// ] +// These entries are formed by the cookies, local storage, session storage and +// indexedDB entries created in storage-listings.html, +// storage-secured-iframe.html and storage-unsecured-iframe.html +const storeItems = [ + [ + ["indexedDB", "https://test1.example.org"], + ["idb1 (default)", "idb2 (default)"], + ], + [ + ["indexedDB", "https://test1.example.org", "idb1 (default)"], + ["obj1", "obj2"], + ], + [["indexedDB", "https://test1.example.org", "idb2 (default)"], []], + [ + ["indexedDB", "https://test1.example.org", "idb1 (default)", "obj1"], + [1, 2, 3], + ], + [["indexedDB", "https://test1.example.org", "idb1 (default)", "obj2"], [1]], +]; + +/** + * Test that the desired number of tree items are present + */ +function testTree() { + const doc = gPanelWindow.document; + for (const [item] of storeItems) { + ok( + doc.querySelector(`[data-id='${JSON.stringify(item)}']`), + `Tree item ${item} should be present in the storage tree` + ); + } +} + +/** + * Test that correct table entries are shown for each of the tree item + */ +const testTables = async function () { + const doc = gPanelWindow.document; + // Expand all nodes so that the synthesized click event actually works + gUI.tree.expandAll(); + + // Click the tree items and wait for the table to be updated + for (const [item, ids] of storeItems) { + await selectTreeItem(item); + + // Check whether correct number of items are present in the table + is( + doc.querySelectorAll( + ".table-widget-column:first-of-type .table-widget-cell" + ).length, + ids.length, + "Number of items in table is correct" + ); + + // Check if all the desired items are present in the table + for (const id of ids) { + ok( + doc.querySelector(".table-widget-cell[data-id='" + id + "']"), + `Table item ${id} should be present` + ); + } + } +}; + +add_task(async function () { + await openTabAndSetupStorage( + MAIN_DOMAIN_SECURED + "storage-empty-objectstores.html" + ); + + testTree(); + await testTables(); +}); diff --git a/devtools/client/storage/test/browser_storage_file_url.js b/devtools/client/storage/test/browser_storage_file_url.js new file mode 100644 index 0000000000..7e0d3c0283 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_file_url.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/. */ + +// Test to verify that various storage types work when using file:// URLs. + +"use strict"; + +add_task(async function () { + const TESTPAGE = "storage-file-url.html"; + + // We need to load TESTPAGE using a file:// path so we need to get that from + // the current test path. + const testPath = getResolvedURI(gTestPath); + const dir = getChromeDir(testPath); + + // Then append TESTPAGE to the test path. + dir.append(TESTPAGE); + + // Then generate a FileURI to ensure the path is valid. + const uriString = Services.io.newFileURI(dir).spec; + + // Now we have a valid file:// URL pointing to TESTPAGE. + await openTabAndSetupStorage(uriString); + + // uriString points to the test inside objdir e.g. + // `/path/to/fx/objDir/_tests/testing/mochitest/browser/devtools/client/ + // storage/test/storage-file-url.html`. + // + // When opened in the browser this may resolve to a different path e.g. + // `path/to/fx/repo/devtools/client/storage/test/storage-file-url.html`. + // + // The easiest way to get the actual path is to request it from the content + // process. + const browser = gBrowser.selectedBrowser; + const actualPath = await SpecialPowers.spawn(browser, [], () => { + return content.document.location.href; + }); + + const cookiePath = actualPath + .substr(0, actualPath.lastIndexOf("/")) + .replace(/file:\/\//g, ""); + await checkState([ + [ + ["cookies", actualPath], + [ + getCookieId("test1", "", cookiePath), + getCookieId("test2", "", cookiePath), + ], + ], + [ + ["indexedDB", actualPath, "MyDatabase (default)", "MyObjectStore"], + [12345, 54321, 67890, 98765], + ], + [ + ["localStorage", actualPath], + ["test3", "test4"], + ], + [ + ["sessionStorage", actualPath], + ["test5", "test6"], + ], + ]); +}); diff --git a/devtools/client/storage/test/browser_storage_fission_cache.js b/devtools/client/storage/test/browser_storage_fission_cache.js new file mode 100644 index 0000000000..4b9223198c --- /dev/null +++ b/devtools/client/storage/test/browser_storage_fission_cache.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"; + +// Bug 1746646: Make mochitests work with TCP enabled (cookieBehavior = 5) +// All instances of addPermission and removePermission set up 3rd-party storage +// access in a way that allows the test to proceed with TCP enabled. + +add_task(async function () { + // open tab + const URL = URL_ROOT_COM_SSL + "storage-cache-basic.html"; + await SpecialPowers.addPermission( + "3rdPartyStorage^https://example.net", + true, + URL + ); + await openTabAndSetupStorage(URL); + const doc = gPanelWindow.document; + + // check that host appears in the storage tree + checkTree(doc, ["Cache", "https://example.com", "lorem"]); + checkTree(doc, ["Cache", "https://example.net", "foo"]); + // Check top level page + await selectTreeItem(["Cache", "https://example.com", "lorem"]); + checkCacheData(URL_ROOT_COM_SSL + "storage-blank.html", "OK"); + // Check iframe + await selectTreeItem(["Cache", "https://example.net", "foo"]); + checkCacheData(URL_ROOT_NET_SSL + "storage-blank.html", "OK"); + + await SpecialPowers.removePermission( + "3rdPartyStorage^http://example.net", + URL + ); +}); + +function checkCacheData(url, status) { + is( + gUI.table.items.get(url)?.status, + status, + `Table row has an entry for: ${url} with status: ${status}` + ); +} diff --git a/devtools/client/storage/test/browser_storage_fission_cookies.js b/devtools/client/storage/test/browser_storage_fission_cookies.js new file mode 100644 index 0000000000..4628643188 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_fission_cookies.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"; + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + // Bug 1617611: Fix all the tests broken by "cookies SameSite=lax by default" + set: [ + ["network.cookie.sameSite.laxByDefault", false], + [ + "privacy.partition.always_partition_third_party_non_cookie_storage", + false, + ], + ], + }); + + const URL_IFRAME = buildURLWithContent( + "example.net", + `<h1>iframe</h1>` + `<script>document.cookie = "lorem=ipsum";</script>` + ); + + const URL_MAIN = buildURLWithContent( + "example.com", + `<h1>Main</h1>` + + `<script>document.cookie="foo=bar";</script>` + + `<iframe src="${URL_IFRAME}">` + ); + + // open tab + await openTabAndSetupStorage(URL_MAIN); + const doc = gPanelWindow.document; + + // check that both hosts appear in the storage tree + checkTree(doc, ["cookies", "https://example.com"]); + checkTree(doc, ["cookies", "https://example.net"]); + // check the table for values + await selectTreeItem(["cookies", "https://example.com"]); + checkCookieData("foo", "bar"); + await selectTreeItem(["cookies", "https://example.net"]); + checkCookieData("lorem", "ipsum"); + + info("Add more cookies"); + const onUpdated = gUI.once("store-objects-edit"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + content.window.document.cookie = "foo2=bar2"; + + const iframe = content.document.querySelector("iframe"); + return SpecialPowers.spawn(iframe, [], () => { + content.document.cookie = "lorem2=ipsum2"; + }); + }); + await onUpdated; + + // check that the new data is shown in the table for the iframe document + checkCookieData("lorem2", "ipsum2"); + + // check that the new data is shown in the table for the top-level document + await selectTreeItem(["cookies", "https://example.com"]); + checkCookieData("foo2", "bar2"); + + SpecialPowers.clearUserPref("network.cookie.sameSite.laxByDefault"); +}); diff --git a/devtools/client/storage/test/browser_storage_fission_hide_aboutblank.js b/devtools/client/storage/test/browser_storage_fission_hide_aboutblank.js new file mode 100644 index 0000000000..4dab607cc3 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_fission_hide_aboutblank.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + const html = `<h1>about:blank iframe</h1><iframe src="about:blank"></iframe>`; + const url = `https://example.com/document-builder.sjs?html=${encodeURI( + html + )}`; + // open tab + await openTabAndSetupStorage(url); + const doc = gPanelWindow.document; + + checkTree(doc, ["localStorage", "https://example.com"], true); + checkTree(doc, ["localStorage", "about:blank"], false); +}); + +add_task(async function () { + // open tab with about:blank as top-level page + await openTabAndSetupStorage("about:blank"); + const doc = gPanelWindow.document; + + checkTree(doc, ["localStorage"], true); +}); diff --git a/devtools/client/storage/test/browser_storage_fission_indexeddb.js b/devtools/client/storage/test/browser_storage_fission_indexeddb.js new file mode 100644 index 0000000000..ae43a77249 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_fission_indexeddb.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"; + +add_task(async function () { + const URL = URL_ROOT_COM_SSL + "storage-indexeddb-iframe.html"; + + // open tab + await openTabAndSetupStorage(URL); + const doc = gPanelWindow.document; + + // check that host appears in the storage tree + checkTree(doc, ["indexedDB", "https://example.com"]); + // check the table for values + await selectTreeItem([ + "indexedDB", + "https://example.com", + "db (default)", + "store", + ]); + checkStorageData("foo", JSON.stringify({ key: "foo", value: "bar" })); + + // check that host appears in the storage tree + checkTree(doc, ["indexedDB", "https://example.net"]); + // check the table for values + await selectTreeItem([ + "indexedDB", + "https://example.net", + "db (default)", + "store", + ]); + checkStorageData("lorem", JSON.stringify({ key: "lorem", value: "ipsum" })); + + info("Add new data to the iframe DB"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const iframe = content.document.querySelector("iframe"); + return SpecialPowers.spawn(iframe, [], async function () { + return new Promise(resolve => { + const request = content.window.indexedDB.open("db", 1); + request.onsuccess = event => { + const db = event.target.result; + const transaction = db.transaction(["store"], "readwrite"); + const addRequest = transaction + .objectStore("store") + .add({ key: "hello", value: "world" }); + addRequest.onsuccess = () => resolve(); + }; + }); + }); + }); + + info("Refreshing table"); + doc.querySelector("#refresh-button").click(); + + info("Check that table has new row"); + await waitUntil(() => + hasStorageData("hello", JSON.stringify({ key: "hello", value: "world" })) + ); + ok(true, "Table has the new data"); +}); diff --git a/devtools/client/storage/test/browser_storage_fission_local_storage.js b/devtools/client/storage/test/browser_storage_fission_local_storage.js new file mode 100644 index 0000000000..ae49516031 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_fission_local_storage.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"; + +add_task(async function () { + const URL_IFRAME = buildURLWithContent( + "example.net", + `<h1>iframe</h1>` + + `<script>localStorage.setItem("lorem", "ipsum");</script>` + ); + const URL_MAIN = buildURLWithContent( + "example.com", + `<h1>Main</h1>` + + `<script>localStorage.setItem("foo", "bar");</script>` + + `<iframe src="${URL_IFRAME}">` + ); + + // open tab + await openTabAndSetupStorage(URL_MAIN); + const doc = gPanelWindow.document; + + // check that both hosts appear in the storage tree + checkTree(doc, ["localStorage", "https://example.com"]); + // check the table for values + await selectTreeItem(["localStorage", "https://example.com"]); + await waitForStorageData("foo", "bar"); + await selectTreeItem(["localStorage", "https://example.net"]); + await waitForStorageData("lorem", "ipsum"); + + // add more storage data to the main wrapper + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + content.window.localStorage.setItem("foo2", "bar2"); + const iframe = content.document.querySelector("iframe"); + return SpecialPowers.spawn(iframe, [], () => { + content.window.localStorage.setItem("lorem2", "ipsum2"); + }); + }); + // check that the new data is shown in the table + await selectTreeItem(["localStorage", "https://example.com"]); + await waitForStorageData("foo2", "bar2"); + await selectTreeItem(["localStorage", "https://example.net"]); + await waitForStorageData("lorem2", "ipsum2"); +}); diff --git a/devtools/client/storage/test/browser_storage_fission_session_storage.js b/devtools/client/storage/test/browser_storage_fission_session_storage.js new file mode 100644 index 0000000000..a3aa475868 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_fission_session_storage.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"; + +add_task(async function () { + const URL_IFRAME = buildURLWithContent( + "example.net", + `<h1>iframe</h1>` + + `<script>sessionStorage.setItem("lorem", "ipsum");</script>` + ); + const URL_MAIN = buildURLWithContent( + "example.com", + `<h1>Main</h1>` + + `<script>sessionStorage.setItem("foo", "bar");</script>` + + `<iframe src="${URL_IFRAME}">` + ); + + // open tab + await openTabAndSetupStorage(URL_MAIN); + const doc = gPanelWindow.document; + + // check that both hosts appear in the storage tree + checkTree(doc, ["sessionStorage", "https://example.com"]); + // check the table for values + await selectTreeItem(["sessionStorage", "https://example.com"]); + await waitForStorageData("foo", "bar"); + await selectTreeItem(["sessionStorage", "https://example.net"]); + await waitForStorageData("lorem", "ipsum"); + + // add more storage data to the main wrapper + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + content.window.sessionStorage.setItem("foo2", "bar2"); + const iframe = content.document.querySelector("iframe"); + return SpecialPowers.spawn(iframe, [], () => { + content.window.sessionStorage.setItem("lorem2", "ipsum2"); + }); + }); + // check that the new data is shown in the table + await selectTreeItem(["sessionStorage", "https://example.com"]); + await waitForStorageData("foo2", "bar2"); + await selectTreeItem(["sessionStorage", "https://example.net"]); + await waitForStorageData("lorem2", "ipsum2"); +}); diff --git a/devtools/client/storage/test/browser_storage_indexeddb_add_button_hidden.js b/devtools/client/storage/test/browser_storage_indexeddb_add_button_hidden.js new file mode 100644 index 0000000000..6cee0dc493 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_indexeddb_add_button_hidden.js @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +// Test that the add button is hidden for the indexedDB storage type. +add_task(async function () { + await openTabAndSetupStorage( + MAIN_DOMAIN_SECURED + "storage-empty-objectstores.html" + ); + + info("Select an indexedDB item"); + const idbItem = ["indexedDB", "https://test1.example.org", "idb1 (default)"]; + await selectTreeItem(idbItem); + checkAddButtonState({ expectHidden: true }); + + // Note: test only one of the other stoage types to check that the logic to + // find the add button is not outdated. Other storage types have more detailed + // tests focused on the add feature. + info("Select a cookie item"); + const cookieItem = ["cookies", "https://test1.example.org"]; + await selectTreeItem(cookieItem); + checkAddButtonState({ expectHidden: false }); +}); + +function checkAddButtonState({ expectHidden }) { + const toolbar = gPanelWindow.document.getElementById("storage-toolbar"); + const addButton = toolbar.querySelector("#add-button"); + is( + addButton.hidden, + expectHidden, + `The add button is ${expectHidden ? "hidden" : "displayed"}` + ); +} diff --git a/devtools/client/storage/test/browser_storage_indexeddb_delete.js b/devtools/client/storage/test/browser_storage_indexeddb_delete.js new file mode 100644 index 0000000000..8c2f6bbf71 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_indexeddb_delete.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/. */ + +"use strict"; + +// Test deleting indexedDB database from the tree using context menu + +add_task(async function () { + await openTabAndSetupStorage( + MAIN_DOMAIN_SECURED + "storage-empty-objectstores.html" + ); + + const contextMenu = + gPanelWindow.document.getElementById("storage-tree-popup"); + const menuDeleteDb = contextMenu.querySelector("#storage-tree-popup-delete"); + + info("test state before delete"); + await checkState([ + [ + ["indexedDB", "https://test1.example.org"], + ["idb1 (default)", "idb2 (default)"], + ], + ]); + + info("do the delete"); + const deletedDb = [ + "indexedDB", + "https://test1.example.org", + "idb1 (default)", + ]; + + await selectTreeItem(deletedDb); + + // Wait once for update and another time for value fetching + const eventWait = gUI.once("store-objects-updated"); + + const selector = `[data-id='${JSON.stringify( + deletedDb + )}'] > .tree-widget-item`; + const target = gPanelWindow.document.querySelector(selector); + ok(target, `tree item found in ${deletedDb.join(" > ")}`); + await waitForContextMenu(contextMenu, target, () => { + info(`Opened tree context menu in ${deletedDb.join(" > ")}`); + menuDeleteDb.click(); + }); + + await eventWait; + + info("test state after delete"); + await checkState([ + [["indexedDB", "https://test1.example.org"], ["idb2 (default)"]], + ]); +}); diff --git a/devtools/client/storage/test/browser_storage_indexeddb_delete_blocked.js b/devtools/client/storage/test/browser_storage_indexeddb_delete_blocked.js new file mode 100644 index 0000000000..5b27816426 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_indexeddb_delete_blocked.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"; + +// Test what happens when deleting indexedDB database is blocked + +add_task(async function () { + await openTabAndSetupStorage( + MAIN_DOMAIN_SECURED + "storage-idb-delete-blocked.html" + ); + + info("test state before delete"); + await checkState([ + [["indexedDB", "https://test1.example.org"], ["idb (default)"]], + ]); + + info("do the delete"); + await selectTreeItem(["indexedDB", "https://test1.example.org"]); + const front = gUI.getCurrentFront(); + let result = await front.removeDatabase( + "https://test1.example.org", + "idb (default)" + ); + + ok(result.blocked, "removeDatabase attempt is blocked"); + + info("test state after blocked delete"); + await checkState([ + [["indexedDB", "https://test1.example.org"], ["idb (default)"]], + ]); + + const eventWait = gUI.once("store-objects-edit"); + + info("telling content to close the db"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const win = content.wrappedJSObject; + await win.closeDb(); + }); + + info("waiting for store edit events"); + await eventWait; + + info("test state after real delete"); + await checkState([[["indexedDB", "https://test1.example.org"], []]]); + + info("try to delete database from nonexistent host"); + let errorThrown = false; + try { + result = await front.removeDatabase( + "https://test2.example.org", + "idb (default)" + ); + } catch (ex) { + errorThrown = true; + } + + ok(errorThrown, "error was reported when trying to delete"); +}); diff --git a/devtools/client/storage/test/browser_storage_indexeddb_duplicate_names.js b/devtools/client/storage/test/browser_storage_indexeddb_duplicate_names.js new file mode 100644 index 0000000000..cc6ac951fc --- /dev/null +++ b/devtools/client/storage/test/browser_storage_indexeddb_duplicate_names.js @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Test to verify that indexedDBs with duplicate names (different types / paths) +// work as expected. + +"use strict"; + +add_task(async function () { + const TESTPAGE = + MAIN_DOMAIN_SECURED + "storage-indexeddb-duplicate-names.html"; + + setPermission(TESTPAGE, "indexedDB"); + + await openTabAndSetupStorage(TESTPAGE); + + await checkState([ + [ + ["indexedDB", "https://test1.example.org"], + ["idb1 (default)", "idb2 (default)"], + ], + ]); +}); diff --git a/devtools/client/storage/test/browser_storage_indexeddb_hide_internal_dbs.js b/devtools/client/storage/test/browser_storage_indexeddb_hide_internal_dbs.js new file mode 100644 index 0000000000..35906256b1 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_indexeddb_hide_internal_dbs.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"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/framework/browser-toolbox/test/helpers-browser-toolbox.js", + this +); + +// Test that internal DBs are hidden in the regular toolbox,but visible in the +// Browser Toolbox +add_task(async function () { + await openTabAndSetupStorage( + MAIN_DOMAIN_SECURED + "storage-empty-objectstores.html" + ); + const doc = gPanelWindow.document; + + // check regular toolbox + info("Check indexedDB tree in toolbox"); + const hosts = getDBHostsInTree(doc); + is(hosts.length, 1, "There is only one host for indexedDB storage"); + is(hosts[0], "https://test1.example.org", "Host is test1.example.org"); + + // check browser toolbox + info("awaiting to open browser toolbox"); + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ getDBHostsInTree }); + + await ToolboxTask.spawn(null, async () => { + info("Selecting storage panel"); + await gToolbox.selectTool("storage"); + info("Check indexedDB tree in browser toolbox"); + const browserToolboxDoc = gToolbox.getCurrentPanel().panelWindow.document; + + const browserToolboxHosts = getDBHostsInTree(browserToolboxDoc); + ok(browserToolboxHosts.length > 1, "There are more than 1 indexedDB hosts"); + ok( + browserToolboxHosts.includes("about:devtools-toolbox"), + "about:devtools-toolbox host is present" + ); + ok(browserToolboxHosts.includes("chrome"), "chrome host is present"); + ok( + browserToolboxHosts.includes("indexeddb+++fx-devtools"), + "fx-devtools host is present" + ); + }); + + info("Destroying browser toolbox"); + await ToolboxTask.destroy(); +}); + +function getDBHostsInTree(doc) { + const treeId = JSON.stringify(["indexedDB"]); + const items = doc.querySelectorAll( + `[data-id='${treeId}'] > .tree-widget-children > *` + ); + + // the host is located at the 2nd element of the array in data-id + return [...items].map(x => JSON.parse(x.dataset.id)[1]); +} diff --git a/devtools/client/storage/test/browser_storage_indexeddb_navigation.js b/devtools/client/storage/test/browser_storage_indexeddb_navigation.js new file mode 100644 index 0000000000..3235f10121 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_indexeddb_navigation.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/. */ + +"use strict"; + +requestLongerTimeout(3); + +add_task(async function () { + const URL1 = URL_ROOT_COM_SSL + "storage-indexeddb-simple.html"; + const URL2 = URL_ROOT_NET_SSL + "storage-indexeddb-simple-alt.html"; + + // open tab + await openTabAndSetupStorage(URL1); + const doc = gPanelWindow.document; + + // Check first domain + // check that host appears in the storage tree + checkTree(doc, ["indexedDB", "https://example.com"]); + // check the table for values + await selectTreeItem([ + "indexedDB", + "https://example.com", + "db (default)", + "store", + ]); + checkStorageData("lorem", JSON.stringify({ key: "lorem", value: "ipsum" })); + + // clear db before navigating to a new domain + info("Removing database…"); + await clearStorage(); + + // Check second domain + await navigateTo(URL2); + info("Creating database in the second domain…"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const win = content.wrappedJSObject; + await win.setup(); + }); + // wait for storage tree refresh, and check host + info("Checking storage tree…"); + await waitUntil(() => isInTree(doc, ["indexedDB", "https://example.net"])); + + ok( + !isInTree(doc, ["indexedDB", "https://example.com"]), + "example.com item is not in the tree anymore" + ); + + // TODO: select tree and check on storage data. + // We cannot do it yet since we do not detect newly created indexed db's when + // navigating. See Bug 1273802 + + // reload the current tab, and check again + await reloadBrowser(); + // wait for storage tree refresh, and check host + info("Checking storage tree…"); + await waitUntil(() => isInTree(doc, ["indexedDB", "https://example.net"])); + + info("Check that the indexedDB node still has the expected label"); + is( + getTreeNodeLabel(doc, ["indexedDB"]), + "Indexed DB", + "indexedDB item is properly displayed" + ); +}); + +async function clearStorage() { + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const win = content.wrappedJSObject; + await win.clear(); + }); +} diff --git a/devtools/client/storage/test/browser_storage_indexeddb_overflow.js b/devtools/client/storage/test/browser_storage_indexeddb_overflow.js new file mode 100644 index 0000000000..7ec65ea010 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_indexeddb_overflow.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/. */ + +// Test endless scrolling when a lot of items are present in the storage +// inspector table for IndexedDB. +"use strict"; + +const ITEMS_PER_PAGE = 50; + +add_task(async function () { + await openTabAndSetupStorage( + MAIN_DOMAIN_SECURED + "storage-overflow-indexeddb.html" + ); + + info("Run the tests with short DevTools"); + await runTests(); + + info("Close Toolbox"); + await gDevTools.closeToolboxForTab(gBrowser.selectedTab); +}); + +async function runTests() { + gUI.tree.expandAll(); + + await selectTreeItem([ + "indexedDB", + "https://test1.example.org", + "database (default)", + "store", + ]); + checkCellLength(ITEMS_PER_PAGE); + + await scroll(); + checkCellLength(ITEMS_PER_PAGE * 2); +} diff --git a/devtools/client/storage/test/browser_storage_keys.js b/devtools/client/storage/test/browser_storage_keys.js new file mode 100644 index 0000000000..d0c0bcaad0 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_keys.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/. */ + +// Test to verify that the keys shown in sidebar are correct + +// Format of the test cases: { +// action: Either "selectTreeItem" to select a tree item or +// "assertTableItem" to select a table item, +// ids: ID array for tree item to select if `action` is "selectTreeItem", +// id: ID of the table item if `action` is "assertTableItem", +// keyValuePairs: Array of key value pair objects which will be asserted +// to exist in the storage sidebar (optional) +// } + +"use strict"; + +const LONG_WORD = "a".repeat(1000); + +add_task(async function () { + await openTabAndSetupStorage( + MAIN_DOMAIN_SECURED + "storage-complex-keys.html" + ); + + gUI.tree.expandAll(); + + await testLocalStorage(); + await testSessionStorage(); + await testIndexedDB(); +}); + +async function testLocalStorage() { + const tests = [ + { + action: "selectTreeItem", + ids: ["localStorage", "https://test1.example.org"], + }, + { + action: "assertTableItem", + id: "", + value: "1", + }, + { + action: "assertTableItem", + id: "键", + value: "2", + }, + ]; + + await makeTests(tests); +} + +async function testSessionStorage() { + const tests = [ + { + action: "selectTreeItem", + ids: ["sessionStorage", "https://test1.example.org"], + }, + { + action: "assertTableItem", + id: "Key with spaces", + value: "3", + }, + { + action: "assertTableItem", + id: "Key#with~special$characters", + value: "4", + }, + { + action: "assertTableItem", + id: LONG_WORD, + value: "5", + }, + ]; + + await makeTests(tests); +} + +async function testIndexedDB() { + const tests = [ + { + action: "selectTreeItem", + ids: ["indexedDB", "https://test1.example.org", "idb (default)", "obj"], + }, + { + action: "assertTableItem", + id: "", + value: JSON.stringify({ id: "", name: "foo" }), + keyValuePairs: [ + { name: ".id", value: "" }, + { name: ".name", value: "foo" }, + ], + }, + { + action: "assertTableItem", + id: "键", + value: JSON.stringify({ id: "键", name: "foo2" }), + keyValuePairs: [ + { name: "键.id", value: "键" }, + { name: "键.name", value: "foo2" }, + ], + }, + { + action: "assertTableItem", + id: "Key with spaces", + value: JSON.stringify({ id: "Key with spaces", name: "foo3" }), + keyValuePairs: [ + { name: "Key with spaces.id", value: "Key with spaces" }, + { name: "Key with spaces.name", value: "foo3" }, + ], + }, + { + action: "assertTableItem", + id: "Key#with~special$characters", + value: JSON.stringify({ + id: "Key#with~special$characters", + name: "foo4", + }), + keyValuePairs: [ + { + name: "Key#with~special$characters.id", + value: "Key#with~special$characters", + }, + { name: "Key#with~special$characters.name", value: "foo4" }, + ], + }, + { + action: "assertTableItem", + id: LONG_WORD, + value: JSON.stringify({ id: LONG_WORD, name: "foo5" }), + keyValuePairs: [ + { name: `${LONG_WORD}.id`, value: LONG_WORD }, + { name: `${LONG_WORD}.name`, value: "foo5" }, + ], + }, + ]; + + await makeTests(tests); +} + +async function makeTests(tests) { + for (const item of tests) { + info(`Selecting item ${JSON.stringify(item)}`); + + switch (item.action) { + case "selectTreeItem": + await selectTreeItem(item.ids); + break; + + case "assertTableItem": + await selectTableItem(item.id); + // Check the ID and value in the data section + await findVariableViewProperties([ + { name: item.id, value: item.value }, + ]); + // If there are key value pairs defined, check those in the + // parsed value section + if (item.keyValuePairs) { + await findVariableViewProperties(item.keyValuePairs, true); + } + break; + } + } +} diff --git a/devtools/client/storage/test/browser_storage_localstorage_add.js b/devtools/client/storage/test/browser_storage_localstorage_add.js new file mode 100644 index 0000000000..a8c6d23585 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_localstorage_add.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/. */ + +// Basic test to check the adding of localStorage entries. + +"use strict"; + +add_task(async function () { + await openTabAndSetupStorage( + MAIN_DOMAIN_SECURED + "storage-localstorage.html" + ); + showAllColumns(true); + + await performAdd(["localStorage", "https://test1.example.org"]); + await performAdd(["localStorage", "https://test1.example.org"]); + await performAdd(["localStorage", "https://test1.example.org"]); + await performAdd(["localStorage", "https://test1.example.org"]); + await performAdd(["localStorage", "https://test1.example.org"]); +}); diff --git a/devtools/client/storage/test/browser_storage_localstorage_edit.js b/devtools/client/storage/test/browser_storage_localstorage_edit.js new file mode 100644 index 0000000000..0fc6cb4d7d --- /dev/null +++ b/devtools/client/storage/test/browser_storage_localstorage_edit.js @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Basic test to check the editing of localStorage. + +"use strict"; + +add_task(async function () { + await openTabAndSetupStorage( + MAIN_DOMAIN_SECURED + "storage-localstorage.html" + ); + + await selectTreeItem(["localStorage", "https://test1.example.org"]); + + await editCell("TestLS1", "name", "newTestLS1"); + await editCell("newTestLS1", "value", "newValueLS1"); + + await editCell("TestLS3", "name", "newTestLS3"); + await editCell("newTestLS3", "value", "newValueLS3"); + + await editCell("TestLS5", "name", "newTestLS5"); + await editCell("newTestLS5", "value", "newValueLS5"); +}); diff --git a/devtools/client/storage/test/browser_storage_localstorage_error.js b/devtools/client/storage/test/browser_storage_localstorage_error.js new file mode 100644 index 0000000000..2faa53c213 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_localstorage_error.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Test that for pages where local/sessionStorage is not available (like about:home), +// the host still appears in the storage tree and no unhandled exception is thrown. + +add_task(async function () { + await openTabAndSetupStorage("about:home"); + + const itemsToOpen = [ + ["localStorage", "about:home"], + ["sessionStorage", "about:home"], + ]; + + for (const item of itemsToOpen) { + await selectTreeItem(item); + ok( + gUI.tree.isSelected(item), + `Item ${item.join(" > ")} is present in the tree` + ); + } +}); diff --git a/devtools/client/storage/test/browser_storage_localstorage_navigation.js b/devtools/client/storage/test/browser_storage_localstorage_navigation.js new file mode 100644 index 0000000000..92a8eab210 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_localstorage_navigation.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"; + +add_task(async function () { + const URL1 = buildURLWithContent( + "example.com", + `<h1>example.com</h1>` + + `<script>localStorage.setItem("lorem", "ipsum");</script>` + ); + const URL2 = buildURLWithContent( + "example.net", + `<h1>example.net</h1>` + + `<script>localStorage.setItem("foo", "bar");</script>` + ); + + // open tab + await openTabAndSetupStorage(URL1); + const doc = gPanelWindow.document; + + // Check first domain + // check that both host appear in the storage tree + checkTree(doc, ["localStorage", "https://example.com"]); + // check the table for values + await selectTreeItem(["localStorage", "https://example.com"]); + checkStorageData("lorem", "ipsum"); + + // clear up local storage data before navigating + info("Cleaning up localStorage…"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const win = content.wrappedJSObject; + await win.localStorage.clear(); + }); + + // Check second domain + await navigateTo(URL2); + // wait for storage tree refresh, and check host + info("Waiting for storage tree to refresh and show correct host…"); + await waitUntil(() => isInTree(doc, ["localStorage", "https://example.net"])); + ok( + !isInTree(doc, ["localStorage", "https://example.com"]), + "example.com item is not in the tree anymore" + ); + + // reload the current tab and check data + await reloadBrowser(); + // wait for storage tree refresh, and check host + info("Waiting for storage tree to refresh and show correct host…"); + await waitUntil(() => isInTree(doc, ["localStorage", "https://example.net"])); + + // check the table for values + await selectTreeItem(["localStorage", "https://example.net"]); + checkStorageData("foo", "bar"); + + info("Check that the localStorage node still has the expected label"); + is( + getTreeNodeLabel(doc, ["localStorage"]), + "Local Storage", + "localStorage item is properly displayed" + ); +}); diff --git a/devtools/client/storage/test/browser_storage_localstorage_rapid_add_remove.js b/devtools/client/storage/test/browser_storage_localstorage_rapid_add_remove.js new file mode 100644 index 0000000000..56636903bb --- /dev/null +++ b/devtools/client/storage/test/browser_storage_localstorage_rapid_add_remove.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Basic test to check the rapid adding and removing of localStorage entries. + +"use strict"; + +add_task(async function () { + await openTabAndSetupStorage(MAIN_DOMAIN_SECURED + "storage-blank.html"); + await selectTreeItem(["localStorage", "https://test1.example.org"]); + + ok(isTableEmpty(), "Table empty on init"); + + for (let i = 0; i < 10; i++) { + await addRemove(`test${i}`); + } +}); + +async function addRemove(name) { + await SpecialPowers.spawn(gBrowser.selectedBrowser, [name], innerName => { + content.localStorage.setItem(innerName, "true"); + content.localStorage.removeItem(innerName); + }); + + info("Waiting for store objects to be changed"); + await gUI.once("store-objects-edit"); + + ok(isTableEmpty(), `Table empty after rapid add/remove of "${name}"`); +} diff --git a/devtools/client/storage/test/browser_storage_overflow.js b/devtools/client/storage/test/browser_storage_overflow.js new file mode 100644 index 0000000000..b5f3989fa8 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_overflow.js @@ -0,0 +1,104 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Test endless scrolling when a lot of items are present in the storage +// inspector table. +"use strict"; + +const ITEMS_PER_PAGE = 50; + +add_task(async function () { + await openTabAndSetupStorage(MAIN_DOMAIN_SECURED + "storage-overflow.html"); + + info("Run the tests with short DevTools"); + await runTests(); + + info("Close Toolbox"); + await gDevTools.closeToolboxForTab(gBrowser.selectedTab); + + info("Set a toolbox height of 1000px"); + await pushPref("devtools.toolbox.footer.height", 1000); + + info("Open storage panel again"); + await openStoragePanel(); + + info("Run the tests with tall DevTools"); + await runTests(true); +}); + +async function runTests(tall) { + if (tall) { + // We need to zoom out and a tall storage panel in order to fit more than 50 + // items in the table. We do this to ensure that we load enough content to + // show a scrollbar so that we can still use infinite scrolling. + zoom(0.5); + } + + gUI.tree.expandAll(); + await selectTreeItem(["localStorage", "https://test1.example.org"]); + + if (tall) { + if (getCellLength() === ITEMS_PER_PAGE) { + await scrollToAddItems(); + await waitForStorageData("item-100", "value-100"); + } + + if (getCellLength() === ITEMS_PER_PAGE * 2) { + await scrollToAddItems(); + await waitForStorageData("item-150", "value-150"); + } + + if (getCellLength() === ITEMS_PER_PAGE * 3) { + await scrollToAddItems(); + await waitForStorageData("item-151", "value-151"); + } + } else { + checkCellLength(ITEMS_PER_PAGE); + await scrollToAddItems(); + await waitForStorageData("item-100", "value-100"); + + checkCellLength(ITEMS_PER_PAGE * 2); + await scrollToAddItems(); + await waitForStorageData("item-150", "value-150"); + + checkCellLength(ITEMS_PER_PAGE * 3); + await scrollToAddItems(); + await waitForStorageData("item-151", "value-151"); + } + + is(getCellLength(), 151, "Storage table contains 151 items"); + + // Check that the columns are sorted in a human readable way (ascending). + checkCellValues("ASC"); + + // Sort descending. + clickColumnHeader("name"); + + // Check that the columns are sorted in a human readable way (descending). + checkCellValues("DEC"); + + if (tall) { + zoom(1); + } +} + +function checkCellValues(order) { + const cells = [ + ...gPanelWindow.document.querySelectorAll("#name .table-widget-cell"), + ]; + cells.forEach(function (cell, index, arr) { + const i = order === "ASC" ? index + 1 : arr.length - index; + is(cell.value, `item-${i}`, `Cell value is "item-${i}" (${order}).`); + }); +} + +async function scrollToAddItems() { + info(`Scrolling to add ${ITEMS_PER_PAGE} items`); + await scroll(); +} + +function zoom(zoomValue) { + const bc = BrowsingContext.getFromWindow(gPanelWindow); + bc.fullZoom = zoomValue; +} diff --git a/devtools/client/storage/test/browser_storage_search.js b/devtools/client/storage/test/browser_storage_search.js new file mode 100644 index 0000000000..1f7c8a5c06 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_search.js @@ -0,0 +1,140 @@ +// Tests the filter search box in the storage inspector +"use strict"; + +add_task(async function () { + await openTabAndSetupStorage(MAIN_DOMAIN_SECURED + "storage-search.html"); + + gUI.tree.expandAll(); + await selectTreeItem(["cookies", "https://test1.example.org"]); + + showColumn("expires", false); + showColumn("host", false); + showColumn("isHttpOnly", false); + showColumn("lastAccessed", false); + showColumn("path", false); + + // Results: 0=hidden, 1=visible + const testcases = [ + // Test that search isn't case-sensitive + { + value: "FoO", + results: [0, 0, 1, 1, 0, 1, 0], + }, + { + value: "OR", + results: [0, 1, 0, 0, 0, 1, 0], + }, + { + value: "aNImAl", + results: [0, 1, 0, 0, 0, 0, 0], + }, + // Test numbers + { + value: "01", + results: [1, 0, 0, 0, 0, 0, 1], + }, + { + value: "2016", + results: [0, 0, 0, 0, 0, 0, 1], + }, + { + value: "56789", + results: [1, 0, 0, 0, 0, 0, 0], + }, + // Test filtering by value + { + value: "horse", + results: [0, 1, 0, 0, 0, 0, 0], + }, + { + value: "$$$", + results: [0, 0, 0, 0, 1, 0, 0], + }, + { + value: "bar", + results: [0, 0, 1, 1, 0, 0, 0], + }, + // Test input with whitespace + { + value: "energy b", + results: [0, 0, 1, 0, 0, 0, 0], + }, + // Test no input at all + { + value: "", + results: [1, 1, 1, 1, 1, 1, 1], + }, + // Test input that matches nothing + { + value: "input that matches nothing", + results: [0, 0, 0, 0, 0, 0, 0], + }, + ]; + + const testcasesAfterHiding = [ + // Test that search isn't case-sensitive + { + value: "OR", + results: [0, 0, 0, 0, 0, 1, 0], + }, + { + value: "01", + results: [1, 0, 0, 0, 0, 0, 0], + }, + { + value: "2016", + results: [0, 0, 0, 0, 0, 0, 0], + }, + { + value: "56789", + results: [0, 0, 0, 0, 0, 0, 0], + }, + // Test filtering by value + { + value: "horse", + results: [0, 0, 0, 0, 0, 0, 0], + }, + { + value: "$$$", + results: [0, 0, 0, 0, 0, 0, 0], + }, + { + value: "bar", + results: [0, 0, 0, 0, 0, 0, 0], + }, + // Test input with whitespace + { + value: "energy b", + results: [0, 0, 0, 0, 0, 0, 0], + }, + ]; + + runTests(testcases); + showColumn("value", false); + runTests(testcasesAfterHiding); +}); + +function runTests(testcases) { + const $$ = sel => gPanelWindow.document.querySelectorAll(sel); + const names = $$("#name .table-widget-cell"); + const rows = $$("#value .table-widget-cell"); + for (const testcase of testcases) { + const { value, results } = testcase; + + info(`Testing input: ${value}`); + + gUI.searchBox.value = value; + gUI.filterItems(); + + for (let i = 0; i < rows.length; i++) { + info(`Testing row ${i} for "${value}"`); + info(`key: ${names[i].value}, value: ${rows[i].value}`); + const state = results[i] ? "visible" : "hidden"; + is( + rows[i].hasAttribute("hidden"), + !results[i], + `Row ${i} should be ${state}` + ); + } + } +} diff --git a/devtools/client/storage/test/browser_storage_search_keyboard_trap.js b/devtools/client/storage/test/browser_storage_search_keyboard_trap.js new file mode 100644 index 0000000000..21ccd12980 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_search_keyboard_trap.js @@ -0,0 +1,15 @@ +// Test ability to focus search field by using keyboard +"use strict"; + +add_task(async function () { + await openTabAndSetupStorage(MAIN_DOMAIN_SECURED + "storage-search.html"); + + gUI.tree.expandAll(); + await selectTreeItem(["localStorage", "https://test1.example.org"]); + + await focusSearchBoxUsingShortcut(gPanelWindow); + ok( + containsFocus(gPanelWindow.document, gUI.searchBox), + "Focus is in a searchbox" + ); +}); diff --git a/devtools/client/storage/test/browser_storage_sessionstorage_add.js b/devtools/client/storage/test/browser_storage_sessionstorage_add.js new file mode 100644 index 0000000000..9f5ed9476f --- /dev/null +++ b/devtools/client/storage/test/browser_storage_sessionstorage_add.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/. */ + +// Basic test to check the adding of sessionStorage entries. + +"use strict"; + +add_task(async function () { + await openTabAndSetupStorage( + MAIN_DOMAIN_SECURED + "storage-sessionstorage.html" + ); + showAllColumns(true); + + await performAdd(["sessionStorage", "https://test1.example.org"]); + await performAdd(["sessionStorage", "https://test1.example.org"]); + await performAdd(["sessionStorage", "https://test1.example.org"]); + await performAdd(["sessionStorage", "https://test1.example.org"]); + await performAdd(["sessionStorage", "https://test1.example.org"]); +}); diff --git a/devtools/client/storage/test/browser_storage_sessionstorage_edit.js b/devtools/client/storage/test/browser_storage_sessionstorage_edit.js new file mode 100644 index 0000000000..51e9657585 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_sessionstorage_edit.js @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Basic test to check the editing of localStorage. + +"use strict"; + +add_task(async function () { + await openTabAndSetupStorage( + MAIN_DOMAIN_SECURED + "storage-sessionstorage.html" + ); + + await selectTreeItem(["sessionStorage", "https://test1.example.org"]); + + await editCell("TestSS1", "name", "newTestSS1"); + await editCell("newTestSS1", "value", "newValueSS1"); + + await editCell("TestSS3", "name", "newTestSS3"); + await editCell("newTestSS3", "value", "newValueSS3"); + + await editCell("TestSS5", "name", "newTestSS5"); + await editCell("newTestSS5", "value", "newValueSS5"); +}); diff --git a/devtools/client/storage/test/browser_storage_sessionstorage_navigation.js b/devtools/client/storage/test/browser_storage_sessionstorage_navigation.js new file mode 100644 index 0000000000..22d6d5661f --- /dev/null +++ b/devtools/client/storage/test/browser_storage_sessionstorage_navigation.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"; + +add_task(async function () { + const URL1 = buildURLWithContent( + "example.com", + `<h1>example.com</h1>` + + `<script>sessionStorage.setItem("lorem", "ipsum");</script>` + ); + const URL2 = buildURLWithContent( + "example.net", + `<h1>example.net</h1>` + + `<script>sessionStorage.setItem("foo", "bar");</script>` + ); + + // open tab + await openTabAndSetupStorage(URL1); + const doc = gPanelWindow.document; + + // Check first domain + // check that both host appear in the storage tree + checkTree(doc, ["sessionStorage", "https://example.com"]); + // check the table for values + await selectTreeItem(["sessionStorage", "https://example.com"]); + checkStorageData("lorem", "ipsum"); + + // clear up session storage data before navigating + info("Cleaning up sessionStorage…"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const win = content.wrappedJSObject; + await win.sessionStorage.clear(); + }); + + // Check second domain + await navigateTo(URL2); + // wait for storage tree refresh, and check host + info("Waiting for storage tree to refresh and show correct host…"); + await waitUntil(() => + isInTree(doc, ["sessionStorage", "https://example.net"]) + ); + + ok( + !isInTree(doc, ["sessionStorage", "https://example.com"]), + "example.com item is not in the tree anymore" + ); + + // check the table for values + await selectTreeItem(["sessionStorage", "https://example.net"]); + checkStorageData("foo", "bar"); + + info("Check that the sessionStorage node still has the expected label"); + is( + getTreeNodeLabel(doc, ["sessionStorage"]), + "Session Storage", + "sessionStorage item is properly displayed" + ); +}); diff --git a/devtools/client/storage/test/browser_storage_sidebar.js b/devtools/client/storage/test/browser_storage_sidebar.js new file mode 100644 index 0000000000..e55a0365f1 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_sidebar.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/. */ + +// Test to verify that the sidebar opens, closes and updates +// This test is not testing the values in the sidebar, being tested in _values + +// Format: [ +// <id of the table item to click> or <id array for tree item to select> or +// null to press Escape, +// <do we wait for the async "sidebar-updated" event>, +// <is the sidebar open> +// ] + +"use strict"; + +const testCases = [ + { + location: ["cookies", "https://sectest1.example.org"], + sidebarHidden: true, + }, + { + location: getCookieId("cs2", ".example.org", "/"), + sidebarHidden: false, + }, + { + sendEscape: true, + }, + { + location: getCookieId("cs2", ".example.org", "/"), + sidebarHidden: true, + }, + { + location: getCookieId("uc1", ".example.org", "/"), + sidebarHidden: true, + }, + { + location: getCookieId("uc1", ".example.org", "/"), + sidebarHidden: true, + }, + + { + location: ["localStorage", "http://sectest1.example.org"], + sidebarHidden: true, + }, + { + location: "iframe-u-ls1", + sidebarHidden: false, + }, + { + location: "iframe-u-ls1", + sidebarHidden: false, + }, + { + sendEscape: true, + }, + + { + location: ["sessionStorage", "http://test1.example.org"], + sidebarHidden: true, + }, + { + location: "ss1", + sidebarHidden: false, + }, + { + sendEscape: true, + }, + + { + location: ["indexedDB", "http://test1.example.org"], + sidebarHidden: true, + }, + { + location: "idb2 (default)", + sidebarHidden: false, + }, + + { + location: [ + "indexedDB", + "http://test1.example.org", + "idb2 (default)", + "obj3", + ], + sidebarHidden: true, + }, + + { + location: ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)"], + sidebarHidden: true, + }, + { + location: "obj-s2", + sidebarHidden: false, + }, + { + sendEscape: true, + }, + { + location: "obj-s2", + sidebarHidden: true, + }, +]; + +add_task(async function () { + // storage-listings.html explicitly mixes secure and insecure frames. + // We should not enforce https for tests using this page. + await pushPref("dom.security.https_first", false); + + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html"); + + for (const test of testCases) { + const { location, sidebarHidden, sendEscape } = test; + + info("running " + JSON.stringify(test)); + + if (Array.isArray(location)) { + await selectTreeItem(location); + } else if (location) { + await selectTableItem(location); + } + + if (sendEscape) { + EventUtils.sendKey("ESCAPE", gPanelWindow); + } else { + is( + gUI.sidebar.hidden, + sidebarHidden, + "correct visibility state of sidebar." + ); + } + + info("-".repeat(80)); + } +}); diff --git a/devtools/client/storage/test/browser_storage_sidebar_filter.js b/devtools/client/storage/test/browser_storage_sidebar_filter.js new file mode 100644 index 0000000000..2efeb7ad8a --- /dev/null +++ b/devtools/client/storage/test/browser_storage_sidebar_filter.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/. */ + +// Test to verify that the filter value input works in the sidebar. + +"use strict"; + +add_task(async function () { + await openTabAndSetupStorage( + MAIN_DOMAIN_SECURED + "storage-complex-values.html" + ); + + const updated = gUI.once("sidebar-updated"); + await selectTreeItem(["localStorage", "https://test1.example.org"]); + await selectTableItem("ls1"); + await updated; + + const doc = gPanelWindow.document; + + let properties = doc.querySelectorAll(`.variables-view-property`); + let unmatched = doc.querySelectorAll(`.variables-view-property[unmatched]`); + is(properties.length, 5, "5 properties in total before filtering"); + is(unmatched.length, 0, "No unmatched properties before filtering"); + + info("Focus the filter input and type 'es6' to filter out entries"); + doc.querySelector(".variables-view-searchinput").focus(); + EventUtils.synthesizeKey("es6", {}, gPanelWindow); + + await waitFor( + () => + doc.querySelectorAll(`.variables-view-property[unmatched]`).length == 3 + ); + + info("Updates performed, going to verify result"); + properties = doc.querySelectorAll(`.variables-view-property`); + unmatched = doc.querySelectorAll(`.variables-view-property[unmatched]`); + is(properties.length, 5, "5 properties in total after filtering"); + // Note: even though only one entry matches, since the VariablesView displays + // it as a tree, we also have one matched .variables-view-property for the + // parent. + is(unmatched.length, 3, "Three unmatched properties after filtering"); +}); diff --git a/devtools/client/storage/test/browser_storage_sidebar_parsetree.js b/devtools/client/storage/test/browser_storage_sidebar_parsetree.js new file mode 100644 index 0000000000..e5c169dfbd --- /dev/null +++ b/devtools/client/storage/test/browser_storage_sidebar_parsetree.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/. */ + +// Test to verify that the sidebar parsetree is used for only values it makes sense to +// parse into a tree. + +"use strict"; + +const testCases = [ + { + row: "ampersand", + parseTreeVisible: true, + }, + { + row: "asterisk", + parseTreeVisible: true, + }, + { + row: "base64", + parseTreeVisible: false, + }, + { + row: "boolean", + parseTreeVisible: false, + }, + { + row: "colon", + parseTreeVisible: true, + }, + { + row: "color", + parseTreeVisible: false, + }, + { + row: "comma", + parseTreeVisible: true, + }, + { + row: "dataURI", + parseTreeVisible: false, + }, + { + row: "date", + parseTreeVisible: false, + }, + { + row: "email", + parseTreeVisible: false, + }, + { + row: "equals", + parseTreeVisible: true, + }, + { + row: "FQDN", + parseTreeVisible: false, + }, + { + row: "hash", + parseTreeVisible: true, + }, + { + row: "IP", + parseTreeVisible: false, + }, + { + row: "MacAddress", + parseTreeVisible: false, + }, + { + row: "maths", + parseTreeVisible: false, + }, + { + row: "numbers", + parseTreeVisible: false, + }, + { + row: "period", + parseTreeVisible: true, + }, + { + row: "SemVer", + parseTreeVisible: false, + }, + { + row: "tilde", + parseTreeVisible: true, + }, + { + row: "URL", + parseTreeVisible: false, + }, + { + row: "URL2", + parseTreeVisible: false, + }, +]; + +add_task(async function () { + await openTabAndSetupStorage( + MAIN_DOMAIN_SECURED + "storage-sidebar-parsetree.html" + ); + + await selectTreeItem(["localStorage", "https://test1.example.org"]); + + for (const test of testCases) { + const { parseTreeVisible, row } = test; + + await selectTableItem(row); + + sidebarParseTreeVisible(parseTreeVisible); + } +}); diff --git a/devtools/client/storage/test/browser_storage_sidebar_toggle.js b/devtools/client/storage/test/browser_storage_sidebar_toggle.js new file mode 100644 index 0000000000..3e27e23e88 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_sidebar_toggle.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/. */ + +// Test to verify that the sidebar toggles when the toggle button is clicked. + +"use strict"; + +const testCases = [ + { + location: ["cookies", "https://sectest1.example.org"], + sidebarHidden: true, + toggleButtonVisible: false, + }, + { + location: getCookieId("cs2", ".example.org", "/"), + sidebarHidden: false, + toggleButtonVisible: true, + }, + { + clickToggle: true, + }, + { + location: getCookieId("cs2", ".example.org", "/"), + sidebarHidden: true, + }, +]; + +add_task(async function () { + // storage-listings.html explicitly mixes secure and insecure frames. + // We should not enforce https for tests using this page. + await pushPref("dom.security.https_first", false); + + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html"); + + for (const test of testCases) { + const { location, sidebarHidden, clickToggle, toggleButtonVisible } = test; + + info("running " + JSON.stringify(test)); + + if (Array.isArray(location)) { + await selectTreeItem(location); + } else if (location) { + await selectTableItem(location); + } + + if (clickToggle) { + toggleSidebar(); + } else if (typeof toggleButtonHidden !== "undefined") { + is( + sidebarToggleVisible(), + toggleButtonVisible, + "correct visibility state of toggle button" + ); + } else { + is( + gUI.sidebar.hidden, + sidebarHidden, + "correct visibility state of sidebar." + ); + } + + info("-".repeat(80)); + } +}); diff --git a/devtools/client/storage/test/browser_storage_sidebar_update.js b/devtools/client/storage/test/browser_storage_sidebar_update.js new file mode 100644 index 0000000000..b8f18d0ca7 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_sidebar_update.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/. */ + +// Test to verify that the sidebar is not broken when several updates +// come in quick succession. See bug 1260380 - it could happen that the +// "Parsed Value" section gets duplicated. + +"use strict"; + +add_task(async function () { + const ITEM_NAME = "ls1"; + const UPDATE_COUNT = 3; + + await openTabAndSetupStorage( + MAIN_DOMAIN_SECURED + "storage-complex-values.html" + ); + + const updated = gUI.once("sidebar-updated"); + await selectTreeItem(["localStorage", "https://test1.example.org"]); + await selectTableItem(ITEM_NAME); + await updated; + + is(gUI.sidebar.hidden, false, "sidebar is visible"); + + // do several updates in a row and wait for them to finish + const updates = []; + for (let i = 0; i < UPDATE_COUNT; i++) { + info(`Performing update #${i}`); + updates.push(gUI.once("sidebar-updated")); + gUI.updateObjectSidebar(); + } + await Promise.all(updates); + + info("Updates performed, going to verify result"); + const parsedScope = gUI.view.getScopeAtIndex(1); + const elements = parsedScope.target.querySelectorAll( + `.name[value="${ITEM_NAME}"]` + ); + is( + elements.length, + 1, + `There is only one displayed variable named '${ITEM_NAME}'` + ); +}); diff --git a/devtools/client/storage/test/browser_storage_type_descriptions.js b/devtools/client/storage/test/browser_storage_type_descriptions.js new file mode 100644 index 0000000000..cad28e569b --- /dev/null +++ b/devtools/client/storage/test/browser_storage_type_descriptions.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/. */ + +// Basic test to assert that the descriptions for the different storage types +// are correctly displayed and the links referring to pages with further +// information are set. + +"use strict"; + +const getStorageTypeURL = require("resource://devtools/client/storage/utils/doc-utils.js"); + +const storeItems = [ + "Cache", + "cookies", + "indexedDB", + "localStorage", + "sessionStorage", +]; + +/** + * Test that the desired number of tree items are present + */ +function testTree() { + const doc = gPanelWindow.document; + for (const type of storeItems) { + ok( + doc.querySelector(`[data-id='${JSON.stringify([type])}']`), + `Tree item ${type} should be present in the storage tree` + ); + } +} + +/** + * Test that description is shown for each of the tree items + */ +const testDescriptions = async function () { + const doc = gPanelWindow.document; + const win = doc.defaultView; + // Expand all nodes so that the synthesized click event actually works + gUI.tree.expandAll(); + + // Click the tree items and wait for the content to be updated + for (const type of storeItems) { + await selectTreeItem([type]); + + // Check whether the table is hidden + is( + win.getComputedStyle(doc.querySelector(".table-widget-body")).display, + "none", + "Table must be hidden" + ); + + // Check whether the description shown + is( + win.getComputedStyle(doc.querySelector(".table-widget-empty-text")) + .display, + "block", + "Description for the type must be shown" + ); + + // Check learn more link + const learnMoreLink = doc.querySelector(".table-widget-empty-text > a"); + ok(learnMoreLink, "There is a [Learn more] link"); + const expectedURL = getStorageTypeURL(type); + is( + learnMoreLink.href, + expectedURL, + `Learn more link refers to ${expectedURL}` + ); + } +}; + +add_task(async function () { + await openTabAndSetupStorage(MAIN_DOMAIN + "storage-empty-objectstores.html"); + + testTree(); + await testDescriptions(); +}); diff --git a/devtools/client/storage/test/browser_storage_values.js b/devtools/client/storage/test/browser_storage_values.js new file mode 100644 index 0000000000..e069781546 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_values.js @@ -0,0 +1,265 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Test to verify that the values shown in sidebar are correct + +// Format: [ +// <id of the table item to click> or <id array for tree item to select> or +// null do click nothing, +// null to skip checking value in variables view or a key value pair object +// which will be asserted to exist in the storage sidebar, +// true if the check is to be made in the parsed value section +// ] + +"use strict"; + +const LONG_WORD = "a".repeat(1000); + +const testCases = [ + [ + getCookieId("cs2", ".example.org", "/"), + [ + { name: "cs2", value: "sessionCookie" }, + { name: "cs2.Path", value: "/" }, + { name: "cs2.HostOnly", value: "false" }, + { name: "cs2.HttpOnly", value: "false" }, + { name: "cs2.Domain", value: ".example.org" }, + { name: "cs2.Expires / Max-Age", value: "Session" }, + { name: "cs2.Secure", value: "false" }, + ], + ], + [ + getCookieId("c1", "test1.example.org", "/browser"), + [ + { name: "c1", value: JSON.stringify(["foo", "Bar", { foo: "Bar" }]) }, + { name: "c1.Path", value: "/browser" }, + { name: "c1.HostOnly", value: "true" }, + { name: "c1.HttpOnly", value: "false" }, + { name: "c1.Domain", value: "test1.example.org" }, + { + name: "c1.Expires / Max-Age", + value: new Date(2000000000000).toUTCString(), + }, + { name: "c1.Secure", value: "false" }, + ], + ], + [ + null, + [ + { name: "c1", value: "Array" }, + { name: "c1.0", value: "foo" }, + { name: "c1.1", value: "Bar" }, + { name: "c1.2", value: "Object" }, + { name: "c1.2.foo", value: "Bar" }, + ], + true, + ], + [ + getCookieId( + "c_encoded", + "test1.example.org", + "/browser/devtools/client/storage/test" + ), + [ + { + name: "c_encoded", + value: encodeURIComponent(JSON.stringify({ foo: { foo1: "bar" } })), + }, + ], + ], + [ + null, + [ + { name: "c_encoded", value: "Object" }, + { name: "c_encoded.foo", value: "Object" }, + { name: "c_encoded.foo.foo1", value: "bar" }, + ], + true, + ], + [["localStorage", "https://test1.example.org"]], + ["ls2", [{ name: "ls2", value: "foobar-2" }]], + [ + "ls1", + [ + { + name: "ls1", + value: JSON.stringify({ + es6: "for", + the: "win", + baz: [ + 0, + 2, + 3, + { + deep: "down", + nobody: "cares", + }, + ], + }), + }, + ], + ], + [ + null, + [ + { name: "ls1", value: "Object" }, + { name: "ls1.es6", value: "for" }, + { name: "ls1.the", value: "win" }, + { name: "ls1.baz", value: "Array" }, + { name: "ls1.baz.0", value: "0" }, + { name: "ls1.baz.1", value: "2" }, + { name: "ls1.baz.2", value: "3" }, + { name: "ls1.baz.3", value: "Object" }, + { name: "ls1.baz.3.deep", value: "down" }, + { name: "ls1.baz.3.nobody", value: "cares" }, + ], + true, + ], + ["ls3", [{ name: "ls3", value: "http://foobar.com/baz.php" }]], + [ + null, + [{ name: "ls3", value: "http://foobar.com/baz.php", dontMatch: true }], + true, + ], + ["ls4", [{ name: "ls4", value: "0x1" }], false], + [["sessionStorage", "https://test1.example.org"]], + ["ss1", [{ name: "ss1", value: "This#is#an#array" }]], + [ + null, + [ + { name: "ss1", value: "Array" }, + { name: "ss1.0", value: "This" }, + { name: "ss1.1", value: "is" }, + { name: "ss1.2", value: "an" }, + { name: "ss1.3", value: "array" }, + ], + true, + ], + [ + "ss2", + [ + { name: "ss2", value: "Array" }, + { name: "ss2.0", value: "This" }, + { name: "ss2.1", value: "is" }, + { name: "ss2.2", value: "another" }, + { name: "ss2.3", value: "array" }, + ], + true, + ], + [ + "ss3", + [ + { name: "ss3", value: "Object" }, + { name: "ss3.this", value: "is" }, + { name: "ss3.an", value: "object" }, + { name: "ss3.foo", value: "bar" }, + ], + true, + ], + [ + "ss4", + [ + { name: "ss4", value: "Array" }, + { name: "ss4.0", value: "" }, + { name: "ss4.1", value: "array" }, + { name: "ss4.2", value: "" }, + { name: "ss4.3", value: "with" }, + { name: "ss4.4", value: "empty" }, + { name: "ss4.5", value: "items" }, + ], + true, + ], + [ + "ss5", + [ + { name: "ss5", value: "Array" }, + { name: "ss5.0", value: LONG_WORD }, + { name: "ss5.1", value: LONG_WORD }, + { name: "ss5.2", value: LONG_WORD }, + { name: "ss5.3", value: `${LONG_WORD}&${LONG_WORD}` }, + { name: "ss5.4", value: `${LONG_WORD}&${LONG_WORD}` }, + ], + true, + ], + [["indexedDB", "https://test1.example.org", "idb1 (default)", "obj1"]], + [ + 1, + [ + { + name: 1, + value: JSON.stringify({ id: 1, name: "foo", email: "foo@bar.com" }), + }, + ], + ], + [ + null, + [ + { name: "1.id", value: "1" }, + { name: "1.name", value: "foo" }, + { name: "1.email", value: "foo@bar.com" }, + ], + true, + ], + [["indexedDB", "https://test1.example.org", "idb1 (default)", "obj2"]], + [ + 1, + [ + { + name: 1, + value: JSON.stringify({ + id2: 1, + name: "foo", + email: "foo@bar.com", + extra: "baz".repeat(10000), + }), + }, + ], + ], + [ + null, + [ + { name: "1.id2", value: "1" }, + { name: "1.name", value: "foo" }, + { name: "1.email", value: "foo@bar.com" }, + { name: "1.extra", value: "baz".repeat(10000) }, + ], + true, + ], +]; + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.documentCookies.maxage", 0]], + }); + + await openTabAndSetupStorage( + MAIN_DOMAIN_SECURED + "storage-complex-values.html" + ); + + gUI.tree.expandAll(); + + for (const item of testCases) { + info("clicking for item " + item); + const [path, ruleArray, parsed] = item; + const start = performance.now(); + + if (Array.isArray(path)) { + await selectTreeItem(path); + continue; + } else if (path) { + await selectTableItem(path); + } + + // Parsing "0x1" used to be very slow and memory intensive. + // Check that each test case completes in less than 15000ms. + const time = performance.now() - start; + Assert.less( + time, + 15000, + `item ${item} completed in less than 15000ms ${time}` + ); + + await findVariableViewProperties(ruleArray, parsed); + } +}); diff --git a/devtools/client/storage/test/browser_storage_webext_storage_local.js b/devtools/client/storage/test/browser_storage_webext_storage_local.js new file mode 100644 index 0000000000..3100a16f5b --- /dev/null +++ b/devtools/client/storage/test/browser_storage_webext_storage_local.js @@ -0,0 +1,296 @@ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* globals browser BigInt */ + +"use strict"; + +add_setup(async function () { + // Always on top mode mess up with toolbox focus and openStoragePanelForAddon would timeout + // waiting for toolbox focus. + await pushPref("devtools.toolbox.alwaysOnTop", false); +}); + +/** + * Since storage item values are represented in the client as strings in textboxes, not all + * JavaScript object types supported by the WE storage local API and its IndexedDB backend + * can be successfully stringified for display in the table much less parsed correctly when + * the user tries to edit a value in the panel. This test is expected to change over time + * as more and more value types are supported. + */ +add_task( + async function test_extension_toolbox_only_supported_values_editable() { + async function background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "storage-local-set": + await browser.storage.local.set(args[0]); + break; + case "storage-local-get": { + const items = await browser.storage.local.get(args[0]); + for (const [key, val] of Object.entries(items)) { + browser.test.assertTrue( + val === args[1], + `New value ${val} is set for key ${key}.` + ); + } + break; + } + case "storage-local-fireOnChanged": { + const listener = () => { + browser.storage.onChanged.removeListener(listener); + browser.test.sendMessage("storage-local-onChanged"); + }; + browser.storage.onChanged.addListener(listener); + // Call an API method implemented in the parent process + // to ensure that the listener has been registered + // in the main process before the test proceeds. + await browser.runtime.getPlatformInfo(); + break; + } + default: + browser.test.fail(`Unexpected test message: ${msg}`); + } + + browser.test.sendMessage(`${msg}:done`); + }); + browser.test.sendMessage("extension-origin", window.location.origin); + } + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + + const host = await extension.awaitMessage("extension-origin"); + + const itemsSupported = { + arr: [1, 2], + bool: true, + null: null, + num: 4, + obj: { a: 123 }, + str: "hi", + // Nested objects or arrays at most 2 levels deep should be editable + nestedArr: [ + { + a: "b", + }, + "c", + ], + nestedObj: { + a: [1, 2, "long-".repeat(10000)], + b: 3, + }, + }; + + const itemsUnsupported = { + arrBuffer: new ArrayBuffer(8), + bigint: BigInt(1), + blob: new Blob( + [ + JSON.stringify( + { + hello: "world", + }, + null, + 2 + ), + ], + { + type: "application/json", + } + ), + date: new Date(0), + map: new Map().set("a", "b"), + regexp: /regexp/, + set: new Set().add(1).add("a"), + undef: undefined, + // Arrays and object literals with non-JSONifiable values should not be editable + arrWithMap: [1, new Map().set("a", 1)], + objWithArrayBuffer: { a: new ArrayBuffer(8) }, + // Nested objects or arrays more than 2 levels deep should not be editable + deepNestedArr: [[{ a: "b" }, 3], 4], + deepNestedObj: { + a: { + b: [1, 2], + }, + }, + }; + + info("Add storage items from the extension"); + const allItems = { ...itemsSupported, ...itemsUnsupported }; + extension.sendMessage("storage-local-fireOnChanged"); + await extension.awaitMessage("storage-local-fireOnChanged:done"); + extension.sendMessage("storage-local-set", allItems); + info( + "Wait for the extension to add storage items and receive the 'onChanged' event" + ); + await extension.awaitMessage("storage-local-set:done"); + await extension.awaitMessage("storage-local-onChanged"); + + info("Open the addon toolbox storage panel"); + const { toolbox } = await openStoragePanelForAddon(extension.id); + + await selectTreeItem(["extensionStorage", host]); + await waitForStorageData("str", "hi"); + + info("Verify that values are displayed as expected in the sidebar"); + const expectedRenderedData = { + arr: { + sidebarItems: [ + { name: "arr", value: "Array" }, + { name: "arr.0", value: "1" }, + { name: "arr.1", value: "2" }, + ], + parsed: true, + }, + arrBuffer: { + sidebarItems: [{ name: "arrBuffer", value: "Object" }], + parsed: true, + }, + arrWithMap: { + sidebarItems: [ + { name: "arrWithMap", value: "Array" }, + { name: "arrWithMap.0", value: "1" }, + { name: "arrWithMap.1", value: "Object" }, + ], + parsed: true, + }, + bigint: { sidebarItems: [{ name: "bigint", value: "1n" }] }, + blob: { sidebarItems: [{ name: "blob", value: "Object" }], parsed: true }, + bool: { + sidebarItems: [{ name: "bool", value: "true" }], + }, + date: { + sidebarItems: [{ name: "date", value: "1970-01-01T00:00:00.000Z" }], + }, + deepNestedArr: { + sidebarItems: [ + { name: "deepNestedArr", value: "Array" }, + { name: "deepNestedArr.0", value: "Array" }, + { name: "deepNestedArr.1", value: "4" }, + { name: "deepNestedArr.length", value: "2" }, + ], + parsed: true, + }, + deepNestedObj: { + sidebarItems: [ + { name: "deepNestedObj", value: "Object" }, + { name: "deepNestedObj.a", value: "Object" }, + ], + parsed: true, + }, + map: { sidebarItems: [{ name: "map", value: "Object" }], parsed: true }, + nestedArr: { + sidebarItems: [ + { name: "nestedArr", value: "Array" }, + { name: "nestedArr.0", value: "Object" }, + { name: "nestedArr.0.a", value: "b" }, + { name: "nestedArr.1", value: "c" }, + ], + parsed: true, + }, + nestedObj: { + sidebarItems: [ + { name: "nestedObj", value: "Object" }, + { name: "nestedObj.a", value: "Array" }, + { name: "nestedObj.a.0", value: "1" }, + { name: "nestedObj.a.1", value: "2" }, + { name: "nestedObj.a.2", value: "long-".repeat(10000) }, + { name: "nestedObj.b", value: "3" }, + ], + parsed: true, + }, + null: { + sidebarItems: [{ name: "null", value: "null" }], + }, + num: { + sidebarItems: [{ name: "num", value: itemsSupported.num }], + }, + obj: { + sidebarItems: [ + { name: "obj", value: "Object" }, + { name: "obj.a", value: "123" }, + ], + parsed: true, + }, + objWithArrayBuffer: { + sidebarItems: [ + { name: "objWithArrayBuffer", value: "Object" }, + { name: "objWithArrayBuffer.a", value: "Object" }, + ], + parsed: true, + }, + regexp: { + sidebarItems: [{ name: "regexp", value: "Object" }], + parsed: true, + }, + set: { sidebarItems: [{ name: "set", value: "Object" }], parsed: true }, + str: { + sidebarItems: [{ name: "str", value: itemsSupported.str }], + }, + + undef: { sidebarItems: [{ name: "undef", value: "undefined" }] }, + }; + + for (const [id, { sidebarItems, parsed }] of Object.entries( + expectedRenderedData + )) { + info(`Verify "${id}" entry`); + await selectTableItem(id); + await findVariableViewProperties(sidebarItems, parsed); + } + + info("Verify that value types supported by the storage actor are editable"); + let validate = true; + const newValue = "anotherValue"; + const supportedIds = Object.keys(itemsSupported); + + for (const id of supportedIds) { + startCellEdit(id, "value", newValue); + await editCell(id, "value", newValue, validate); + } + + info("Verify that associated values have been changed in the extension"); + extension.sendMessage( + "storage-local-get", + Object.keys(itemsSupported), + newValue + ); + await extension.awaitMessage("storage-local-get:done"); + + info( + "Verify that value types not supported by the storage actor are uneditable" + ); + const expectedValStrings = { + arrBuffer: "{}", + bigint: "1n", + blob: "{}", + date: "1970-01-01T00:00:00.000Z", + map: "{}", + regexp: "{}", + set: "{}", + undef: "undefined", + arrWithMap: "[1,{}]", + objWithArrayBuffer: '{"a":{}}', + deepNestedArr: '[[{"a":"b"},3],4]', + deepNestedObj: '{"a":{"b":[1,2]}}', + }; + validate = false; + for (const id of Object.keys(itemsUnsupported)) { + startCellEdit(id, "value", validate); + checkCellUneditable(id, "value"); + checkCell(id, "value", expectedValStrings[id]); + } + + info("Shut down the test"); + await toolbox.destroy(); + await extension.unload(); + } +); diff --git a/devtools/client/storage/test/head.js b/devtools/client/storage/test/head.js new file mode 100644 index 0000000000..f50f573342 --- /dev/null +++ b/devtools/client/storage/test/head.js @@ -0,0 +1,1186 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ +/* import-globals-from ../../shared/test/shared-head.js */ + +// Sometimes HTML pages have a `clear` function that cleans up the storage they +// created. To make sure it's always called, we are registering as a cleanup +// function, but since this needs to run before tabs are closed, we need to +// do this registration before importing `shared-head`, since declaration +// order matters. +registerCleanupFunction(async () => { + const browser = gBrowser.selectedBrowser; + const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); + for (const context of contexts) { + await SpecialPowers.spawn(context, [], async () => { + const win = content.wrappedJSObject; + + // Some windows (e.g., about: URLs) don't have storage available + try { + win.localStorage.clear(); + win.sessionStorage.clear(); + } catch (ex) { + // ignore + } + + if (win.clear) { + // Do not get hung into win.clear() forever + await Promise.race([ + new Promise(r => win.setTimeout(r, 10000)), + win.clear(), + ]); + } + }); + } + + Services.cookies.removeAll(); + + // Close tabs and force memory collection to happen + while (gBrowser.tabs.length > 1) { + await closeTabAndToolbox(gBrowser.selectedTab); + } + forceCollections(); +}); + +// shared-head.js handles imports, constants, and utility functions +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +const { + TableWidget, +} = require("resource://devtools/client/shared/widgets/TableWidget.js"); +const { + LocalTabCommandsFactory, +} = require("resource://devtools/client/framework/local-tab-commands-factory.js"); +const STORAGE_PREF = "devtools.storage.enabled"; +const DUMPEMIT_PREF = "devtools.dump.emit"; +const DEBUGGERLOG_PREF = "devtools.debugger.log"; + +// Allows Cache API to be working on usage `http` test page +const CACHES_ON_HTTP_PREF = "dom.caches.testing.enabled"; +const PATH = "browser/devtools/client/storage/test/"; +const MAIN_DOMAIN = "http://test1.example.org/" + PATH; +const MAIN_DOMAIN_SECURED = "https://test1.example.org/" + PATH; +const MAIN_DOMAIN_WITH_PORT = "http://test1.example.org:8000/" + PATH; +const ALT_DOMAIN = "http://sectest1.example.org/" + PATH; +const ALT_DOMAIN_SECURED = "https://sectest1.example.org:443/" + PATH; + +// GUID to be used as a separator in compound keys. This must match the same +// constant in devtools/server/actors/resources/storage/index.js, +// devtools/client/storage/ui.js and devtools/server/tests/browser/head.js +const SEPARATOR_GUID = "{9d414cc5-8319-0a04-0586-c0a6ae01670a}"; + +var gToolbox, gPanelWindow, gUI; + +// Services.prefs.setBoolPref(DUMPEMIT_PREF, true); +// Services.prefs.setBoolPref(DEBUGGERLOG_PREF, true); + +Services.prefs.setBoolPref(STORAGE_PREF, true); +Services.prefs.setBoolPref(CACHES_ON_HTTP_PREF, true); +registerCleanupFunction(() => { + gToolbox = gPanelWindow = gUI = null; + Services.prefs.clearUserPref(CACHES_ON_HTTP_PREF); + Services.prefs.clearUserPref(DEBUGGERLOG_PREF); + Services.prefs.clearUserPref(DUMPEMIT_PREF); + Services.prefs.clearUserPref(STORAGE_PREF); +}); + +/** + * This generator function opens the given url in a new tab, then sets up the + * page by waiting for all cookies, indexedDB items etc. + * + * @param url {String} The url to be opened in the new tab + * @param options {Object} The tab options for the new tab + * + * @return {Promise} A promise that resolves after the tab is ready + */ +async function openTab(url, options = {}) { + const tab = await addTab(url, options); + + const browser = gBrowser.selectedBrowser; + const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); + + for (const context of contexts) { + await SpecialPowers.spawn(context, [], async () => { + const win = content.wrappedJSObject; + const readyState = win.document.readyState; + info(`Found a window: ${readyState}`); + if (readyState != "complete") { + await new Promise(resolve => { + const onLoad = () => { + win.removeEventListener("load", onLoad); + resolve(); + }; + win.addEventListener("load", onLoad); + }); + } + if (win.setup) { + await win.setup(); + } + }); + } + + return tab; +} + +/** + * This generator function opens the given url in a new tab, then sets up the + * page by waiting for all cookies, indexedDB items etc. to be created; Then + * opens the storage inspector and waits for the storage tree and table to be + * populated. + * + * @param url {String} The url to be opened in the new tab + * @param options {Object} The tab options for the new tab + * + * @return {Promise} A promise that resolves after storage inspector is ready + */ +async function openTabAndSetupStorage(url, options = {}) { + // open tab + await openTab(url, options); + + // open storage inspector + return openStoragePanel(); +} + +/** + * Open a toolbox with the storage panel opened by default + * for a given Web Extension. + * + * @param {String} addonId + * The ID of the Web Extension to debug. + */ +var openStoragePanelForAddon = async function (addonId) { + const toolbox = await gDevTools.showToolboxForWebExtension(addonId, { + toolId: "storage", + }); + + info("Making sure that the toolbox's frame is focused"); + await SimpleTest.promiseFocus(toolbox.win); + + const storage = _setupStoragePanelForTest(toolbox); + + return { + toolbox, + storage, + }; +}; + +/** + * Open the toolbox, with the storage tool visible. + * + * @param tab {XULTab} Optional, the tab for the toolbox; defaults to selected tab + * @param commands {Object} Optional, the commands for the toolbox; defaults to a tab commands + * @param hostType {Toolbox.HostType} Optional, type of host that will host the toolbox + * + * @return {Promise} a promise that resolves when the storage inspector is ready + */ +var openStoragePanel = async function ({ tab, hostType } = {}) { + const toolbox = await openToolboxForTab( + tab || gBrowser.selectedTab, + "storage", + hostType + ); + + const storage = _setupStoragePanelForTest(toolbox); + + return { + toolbox, + storage, + }; +}; + +/** + * Set global variables needed in helper functions + * + * @param toolbox {Toolbox} + * @return {StoragePanel} + */ +function _setupStoragePanelForTest(toolbox) { + const storage = toolbox.getPanel("storage"); + gPanelWindow = storage.panelWindow; + gUI = storage.UI; + gToolbox = toolbox; + + // The table animation flash causes some timeouts on Linux debug tests, + // so we disable it + gUI.animationsEnabled = false; + + return storage; +} + +/** + * Forces GC, CC and Shrinking GC to get rid of disconnected docshells and + * windows. + */ +function forceCollections() { + Cu.forceGC(); + Cu.forceCC(); + Cu.forceShrinkingGC(); +} + +// Sends a click event on the passed DOM node in an async manner +function click(node) { + node.scrollIntoView(); + + return new Promise(resolve => { + // We need setTimeout here to allow any scrolling to complete before clicking + // the node. + setTimeout(() => { + node.click(); + resolve(); + }, 200); + }); +} + +/** + * Recursively expand the variables view up to a given property. + * + * @param options + * Options for view expansion: + * - rootVariable: start from the given scope/variable/property. + * - expandTo: string made up of property names you want to expand. + * For example: "body.firstChild.nextSibling" given |rootVariable: + * document|. + * @return object + * A promise that is resolved only when the last property in |expandTo| + * is found, and rejected otherwise. Resolution reason is always the + * last property - |nextSibling| in the example above. Rejection is + * always the last property that was found. + */ +function variablesViewExpandTo(options) { + const root = options.rootVariable; + const expandTo = options.expandTo.split("."); + + return new Promise((resolve, reject) => { + function getNext(prop) { + const name = expandTo.shift(); + const newProp = prop.get(name); + + if (expandTo.length) { + ok(newProp, "found property " + name); + if (newProp && newProp.expand) { + newProp.expand(); + getNext(newProp); + } else { + reject(prop); + } + } else if (newProp) { + resolve(newProp); + } else { + reject(prop); + } + } + + if (root && root.expand) { + root.expand(); + getNext(root); + } else { + resolve(root); + } + }); +} + +/** + * Find variables or properties in a VariablesView instance. + * + * @param array ruleArray + * The array of rules you want to match. Each rule is an object with: + * - name (string|regexp): property name to match. + * - value (string|regexp): property value to match. + * - dontMatch (boolean): make sure the rule doesn't match any property. + * @param boolean parsed + * true if we want to test the rules in the parse value section of the + * storage sidebar + * @return object + * A promise object that is resolved when all the rules complete + * matching. The resolved callback is given an array of all the rules + * you wanted to check. Each rule has a new property: |matchedProp| + * which holds a reference to the Property object instance from the + * VariablesView. If the rule did not match, then |matchedProp| is + * undefined. + */ +function findVariableViewProperties(ruleArray, parsed) { + // Initialize the search. + function init() { + // If parsed is true, we are checking rules in the parsed value section of + // the storage sidebar. That scope uses a blank variable as a placeholder + // Thus, adding a blank parent to each name + if (parsed) { + ruleArray = ruleArray.map(({ name, value, dontMatch }) => { + return { name: "." + name, value, dontMatch }; + }); + } + // Separate out the rules that require expanding properties throughout the + // view. + const expandRules = []; + const rules = ruleArray.filter(rule => { + if (typeof rule.name == "string" && rule.name.indexOf(".") > -1) { + expandRules.push(rule); + return false; + } + return true; + }); + + // Search through the view those rules that do not require any properties to + // be expanded. Build the array of matchers, outstanding promises to be + // resolved. + const outstanding = []; + + finder(rules, gUI.view, outstanding); + + // Process the rules that need to expand properties. + const lastStep = processExpandRules.bind(null, expandRules); + + // Return the results - a promise resolved to hold the updated ruleArray. + const returnResults = onAllRulesMatched.bind(null, ruleArray); + + return Promise.all(outstanding).then(lastStep).then(returnResults); + } + + function onMatch(prop, rule, matched) { + if (matched && !rule.matchedProp) { + rule.matchedProp = prop; + } + } + + function finder(rules, view, promises) { + for (const scope of view) { + for (const [, prop] of scope) { + for (const rule of rules) { + const matcher = matchVariablesViewProperty(prop, rule); + promises.push(matcher.then(onMatch.bind(null, prop, rule))); + } + } + } + } + + function processExpandRules(rules) { + return new Promise(resolve => { + const rule = rules.shift(); + if (!rule) { + resolve(null); + } + + const expandOptions = { + rootVariable: gUI.view.getScopeAtIndex(parsed ? 1 : 0), + expandTo: rule.name, + }; + + variablesViewExpandTo(expandOptions) + .then( + function onSuccess(prop) { + const name = rule.name; + const lastName = name.split(".").pop(); + rule.name = lastName; + + const matched = matchVariablesViewProperty(prop, rule); + return matched + .then(onMatch.bind(null, prop, rule)) + .then(function () { + rule.name = name; + }); + }, + function onFailure() { + resolve(null); + } + ) + .then(processExpandRules.bind(null, rules)) + .then(function () { + resolve(null); + }); + }); + } + + function onAllRulesMatched(rules) { + for (const rule of rules) { + const matched = rule.matchedProp; + if (matched && !rule.dontMatch) { + ok(true, "rule " + rule.name + " matched for property " + matched.name); + } else if (matched && rule.dontMatch) { + ok( + false, + "rule " + rule.name + " should not match property " + matched.name + ); + } else { + ok(rule.dontMatch, "rule " + rule.name + " did not match any property"); + } + } + return rules; + } + + return init(); +} + +/** + * Check if a given Property object from the variables view matches the given + * rule. + * + * @param object prop + * The variable's view Property instance. + * @param object rule + * Rules for matching the property. See findVariableViewProperties() for + * details. + * @return object + * A promise that is resolved when all the checks complete. Resolution + * result is a boolean that tells your promise callback the match + * result: true or false. + */ +function matchVariablesViewProperty(prop, rule) { + function resolve(result) { + return Promise.resolve(result); + } + + if (!prop) { + return resolve(false); + } + + // Any kind of string is accepted as name, including empty ones + if (typeof rule.name == "string") { + const match = + rule.name instanceof RegExp + ? rule.name.test(prop.name) + : prop.name == rule.name; + if (!match) { + return resolve(false); + } + } + + if ("value" in rule) { + let displayValue = prop.displayValue; + if (prop.displayValueClassName == "token-string") { + displayValue = displayValue.substring(1, displayValue.length - 1); + } + + const match = + rule.value instanceof RegExp + ? rule.value.test(displayValue) + : displayValue == rule.value; + if (!match) { + info( + "rule " + + rule.name + + " did not match value, expected '" + + rule.value + + "', found '" + + displayValue + + "'" + ); + return resolve(false); + } + } + + return resolve(true); +} + +/** + * Click selects a row in the table. + * + * @param {[String]} ids + * The array id of the item in the tree + */ +async function selectTreeItem(ids) { + if (gUI.tree.isSelected(ids)) { + info(`"${ids}" is already selected, returning.`); + return; + } + if (!gUI.tree.exists(ids)) { + info(`"${ids}" does not exist, returning.`); + return; + } + + // The item exists but is not selected... select it. + info(`Selecting "${ids}".`); + if (ids.length > 1) { + const updated = gUI.once("store-objects-updated"); + gUI.tree.selectedItem = ids; + await updated; + } else { + // If the length of the IDs array is 1, a storage type + // gets selected and no 'store-objects-updated' event + // will be fired in that case. + gUI.tree.selectedItem = ids; + } +} + +/** + * Click selects a row in the table. + * + * @param {String} id + * The id of the row in the table widget + */ +async function selectTableItem(id) { + const table = gUI.table; + const selector = + ".table-widget-column#" + + table.uniqueId + + " .table-widget-cell[value='" + + id + + "']"; + const target = gPanelWindow.document.querySelector(selector); + + ok(target, `row found with id "${id}"`); + + if (!target) { + showAvailableIds(); + } + + const updated = gUI.once("sidebar-updated"); + + info(`selecting row "${id}"`); + await click(target); + await updated; +} + +/** + * Wait for eventName on target. + * @param {Object} target An observable object that either supports on/off or + * addEventListener/removeEventListener + * @param {String} eventName + * @param {Boolean} useCapture Optional, for addEventListener/removeEventListener + * @return A promise that resolves when the event has been handled + */ +function once(target, eventName, useCapture = false) { + info("Waiting for event: '" + eventName + "' on " + target + "."); + + return new Promise(resolve => { + for (const [add, remove] of [ + ["addEventListener", "removeEventListener"], + ["addListener", "removeListener"], + ["on", "off"], + ]) { + if (add in target && remove in target) { + target[add]( + eventName, + function onEvent(...aArgs) { + info("Got event: '" + eventName + "' on " + target + "."); + target[remove](eventName, onEvent, useCapture); + resolve(...aArgs); + }, + useCapture + ); + break; + } + } + }); +} + +/** + * Get values for a row. + * + * @param {String} id + * The uniqueId of the given row. + * @param {Boolean} includeHidden + * Include hidden columns. + * + * @return {Object} + * An object of column names to values for the given row. + */ +function getRowValues(id, includeHidden = false) { + const cells = getRowCells(id, includeHidden); + const values = {}; + + for (const name in cells) { + const cell = cells[name]; + + values[name] = cell.value; + } + + return values; +} + +/** + * Get cells for a row. + * + * @param {String} id + * The uniqueId of the given row. + * @param {Boolean} includeHidden + * Include hidden columns. + * + * @return {Object} + * An object of column names to cells for the given row. + */ +function getRowCells(id, includeHidden = false) { + const doc = gPanelWindow.document; + const table = gUI.table; + const item = doc.querySelector( + ".table-widget-column#" + + table.uniqueId + + " .table-widget-cell[value='" + + id + + "']" + ); + + if (!item) { + ok( + false, + `The row id '${id}' that was passed to getRowCells() does not ` + + `exist. ${getAvailableIds()}` + ); + } + + const index = table.columns.get(table.uniqueId).cellNodes.indexOf(item); + const cells = {}; + + for (const [name, column] of [...table.columns]) { + if (!includeHidden && column.column.parentNode.hidden) { + continue; + } + cells[name] = column.cellNodes[index]; + } + + return cells; +} + +/** + * Check for an empty table. + */ +function isTableEmpty() { + const doc = gPanelWindow.document; + const table = gUI.table; + const cells = doc.querySelectorAll( + ".table-widget-column#" + table.uniqueId + " .table-widget-cell" + ); + return cells.length === 0; +} + +/** + * Get available ids... useful for error reporting. + */ +function getAvailableIds() { + const doc = gPanelWindow.document; + const table = gUI.table; + + let out = "Available ids:\n"; + const cells = doc.querySelectorAll( + ".table-widget-column#" + table.uniqueId + " .table-widget-cell" + ); + for (const cell of cells) { + out += ` - ${cell.getAttribute("value")}\n`; + } + + return out; +} + +/** + * Show available ids. + */ +function showAvailableIds() { + info(getAvailableIds()); +} + +/** + * Get a cell value. + * + * @param {String} id + * The uniqueId of the row. + * @param {String} column + * The id of the column + * + * @yield {String} + * The cell value. + */ +function getCellValue(id, column) { + const row = getRowValues(id, true); + + if (typeof row[column] === "undefined") { + let out = ""; + for (const key in row) { + const value = row[key]; + + out += ` - ${key} = ${value}\n`; + } + + ok( + false, + `The column name '${column}' that was passed to ` + + `getCellValue() does not exist. Current column names and row ` + + `values are:\n${out}` + ); + } + + return row[column]; +} + +/** + * Edit a cell value. The cell is assumed to be in edit mode, see startCellEdit. + * + * @param {String} id + * The uniqueId of the row. + * @param {String} column + * The id of the column + * @param {String} newValue + * Replacement value. + * @param {Boolean} validate + * Validate result? Default true. + * + * @yield {String} + * The uniqueId of the changed row. + */ +async function editCell(id, column, newValue, validate = true) { + const row = getRowCells(id, true); + const editableFieldsEngine = gUI.table._editableFieldsEngine; + + editableFieldsEngine.edit(row[column]); + + await typeWithTerminator(newValue, "KEY_Enter", validate); +} + +/** + * Begin edit mode for a cell. + * + * @param {String} id + * The uniqueId of the row. + * @param {String} column + * The id of the column + * @param {Boolean} selectText + * Select text? Default true. + */ +function startCellEdit(id, column, selectText = true) { + const row = getRowCells(id, true); + const editableFieldsEngine = gUI.table._editableFieldsEngine; + const cell = row[column]; + + info("Selecting row " + id); + gUI.table.selectedRow = id; + + info("Starting cell edit (" + id + ", " + column + ")"); + editableFieldsEngine.edit(cell); + + if (!selectText) { + const textbox = gUI.table._editableFieldsEngine.textbox; + textbox.selectionEnd = textbox.selectionStart; + } +} + +/** + * Check a cell value. + * + * @param {String} id + * The uniqueId of the row. + * @param {String} column + * The id of the column + * @param {String} expected + * Expected value. + */ +function checkCell(id, column, expected) { + is( + getCellValue(id, column), + expected, + column + " column has the right value for " + id + ); +} + +/** + * Check that a cell is not in edit mode. + * + * @param {String} id + * The uniqueId of the row. + * @param {String} column + * The id of the column + */ +function checkCellUneditable(id, column) { + const row = getRowCells(id, true); + const cell = row[column]; + + const editableFieldsEngine = gUI.table._editableFieldsEngine; + const textbox = editableFieldsEngine.textbox; + + // When a field is being edited, the cell is hidden, and the textbox is made visible. + ok( + !cell.hidden && textbox.hidden, + `The cell located in column ${column} and row ${id} is not editable.` + ); +} + +/** + * Show or hide a column. + * + * @param {String} id + * The uniqueId of the given column. + * @param {Boolean} state + * true = show, false = hide + */ +function showColumn(id, state) { + const columns = gUI.table.columns; + const column = columns.get(id); + column.column.hidden = !state; +} + +/** + * Toggle sort direction on a column by clicking on the column header. + * + * @param {String} id + * The uniqueId of the given column. + */ +function clickColumnHeader(id) { + const columns = gUI.table.columns; + const column = columns.get(id); + const header = column.header; + + header.click(); +} + +/** + * Show or hide all columns. + * + * @param {Boolean} state + * true = show, false = hide + */ +function showAllColumns(state) { + const columns = gUI.table.columns; + + for (const [id] of columns) { + showColumn(id, state); + } +} + +/** + * Type a string in the currently selected editor and then wait for the row to + * be updated. + * + * @param {String} str + * The string to type. + * @param {String} terminator + * The terminating key e.g. KEY_Enter or KEY_Tab + * @param {Boolean} validate + * Validate result? Default true. + */ +async function typeWithTerminator(str, terminator, validate = true) { + const editableFieldsEngine = gUI.table._editableFieldsEngine; + const textbox = editableFieldsEngine.textbox; + const colName = textbox.closest(".table-widget-column").id; + + const changeExpected = str !== textbox.value; + + if (!changeExpected) { + return editableFieldsEngine.currentTarget.getAttribute("data-id"); + } + + info("Typing " + str); + EventUtils.sendString(str, gPanelWindow); + + info("Pressing " + terminator); + EventUtils.synthesizeKey(terminator, null, gPanelWindow); + + if (validate) { + info("Validating results... waiting for ROW_EDIT event."); + const uniqueId = await gUI.table.once(TableWidget.EVENTS.ROW_EDIT); + + checkCell(uniqueId, colName, str); + return uniqueId; + } + + return gUI.table.once(TableWidget.EVENTS.ROW_EDIT); +} + +function getCurrentEditorValue() { + const editableFieldsEngine = gUI.table._editableFieldsEngine; + const textbox = editableFieldsEngine.textbox; + + return textbox.value; +} + +/** + * Press a key x times. + * + * @param {String} key + * The key to press e.g. VK_RETURN or VK_TAB + * @param {Number} x + * The number of times to press the key. + * @param {Object} modifiers + * The event modifier e.g. {shiftKey: true} + */ +function PressKeyXTimes(key, x, modifiers = {}) { + for (let i = 0; i < x; i++) { + EventUtils.synthesizeKey(key, modifiers); + } +} + +/** + * Verify the storage inspector state: check that given type/host exists + * in the tree, and that the table contains rows with specified names. + * + * @param {Array} state Array of state specifications. For example, + * [["cookies", "example.com"], ["c1", "c2"]] means to select the + * "example.com" host in cookies and then verify there are "c1" and "c2" + * cookies (and no other ones). + */ +async function checkState(state) { + for (const [store, names] of state) { + const storeName = store.join(" > "); + info(`Selecting tree item ${storeName}`); + await selectTreeItem(store); + + const items = gUI.table.items; + + is( + items.size, + names.length, + `There is correct number of rows in ${storeName}` + ); + + if (names.length === 0) { + showAvailableIds(); + } + + for (const name of names) { + if (!items.has(name)) { + showAvailableIds(); + } + ok(items.has(name), `There is item with name '${name}' in ${storeName}`); + } + } +} + +/** + * Checks if document's active element is within the given element. + * @param {HTMLDocument} doc document with active element in question + * @param {DOMNode} container element tested on focus containment + * @return {Boolean} + */ +function containsFocus(doc, container) { + let elm = doc.activeElement; + while (elm) { + if (elm === container) { + return true; + } + elm = elm.parentNode; + } + return false; +} + +var focusSearchBoxUsingShortcut = async function (panelWin, callback) { + info("Focusing search box"); + const searchBox = panelWin.document.getElementById("storage-searchbox"); + const focused = once(searchBox, "focus"); + + panelWin.focus(); + + const shortcut = await panelWin.document.l10n.formatValue( + "storage-filter-key" + ); + synthesizeKeyShortcut(shortcut); + + await focused; + + if (callback) { + callback(); + } +}; + +function getCookieId(name, domain, path) { + return `${name}${SEPARATOR_GUID}${domain}${SEPARATOR_GUID}${path}`; +} + +function setPermission(url, permission) { + const nsIPermissionManager = Ci.nsIPermissionManager; + + const uri = Services.io.newURI(url); + const principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + {} + ); + + Cc["@mozilla.org/permissionmanager;1"] + .getService(nsIPermissionManager) + .addFromPrincipal(principal, permission, nsIPermissionManager.ALLOW_ACTION); +} + +function toggleSidebar() { + gUI.sidebarToggleBtn.click(); +} + +function sidebarToggleVisible() { + return !gUI.sidebarToggleBtn.hidden; +} + +/** + * Check whether the variables view in the sidebar contains a tree. + * + * @param {Boolean} state + * Should a tree be visible? + */ +function sidebarParseTreeVisible(state) { + if (state) { + Assert.greater( + gUI.view._currHierarchy.size, + 2, + "Parse tree should be visible." + ); + } else { + Assert.lessOrEqual( + gUI.view._currHierarchy.size, + 2, + "Parse tree should not be visible." + ); + } +} + +/** + * Add an item. + * @param {Array} store + * An array containing the path to the store to which we wish to add an + * item. + * @return {Promise} A Promise that resolves to the row id of the added item. + */ +async function performAdd(store) { + const storeName = store.join(" > "); + const toolbar = gPanelWindow.document.getElementById("storage-toolbar"); + const type = store[0]; + + await selectTreeItem(store); + + const menuAdd = toolbar.querySelector("#add-button"); + + if (menuAdd.hidden) { + is( + menuAdd.hidden, + false, + `performAdd called for ${storeName} but it is not supported` + ); + return ""; + } + + const eventEdit = gUI.table.once("row-edit"); + const eventWait = gUI.once("store-objects-edit"); + + menuAdd.click(); + + const rowId = await eventEdit; + await eventWait; + + const key = type === "cookies" ? "uniqueKey" : "name"; + const value = getCellValue(rowId, key); + + is(rowId, value, `Row '${rowId}' was successfully added.`); + + return rowId; +} + +// Cell css selector that can be used to count or select cells. +// The selector is restricted to a single column to avoid counting duplicates. +const CELL_SELECTOR = + "#storage-table .table-widget-column:first-child .table-widget-cell"; + +function getCellLength() { + return gPanelWindow.document.querySelectorAll(CELL_SELECTOR).length; +} + +function checkCellLength(len) { + is(getCellLength(), len, `Table should contain ${len} items`); +} + +async function scroll() { + const $ = id => gPanelWindow.document.querySelector(id); + const table = $("#storage-table .table-widget-body"); + const cell = $(CELL_SELECTOR); + const cellHeight = cell.getBoundingClientRect().height; + + const onStoresUpdate = gUI.once("store-objects-updated"); + table.scrollTop += cellHeight * 50; + await onStoresUpdate; +} + +/** + * Asserts that the given tree path exists + * @param {Document} doc + * @param {Array} path + * @param {Boolean} isExpected + */ +function checkTree(doc, path, isExpected = true) { + const doesExist = isInTree(doc, path); + ok( + isExpected ? doesExist : !doesExist, + `${path.join(" > ")} is ${isExpected ? "" : "not "}in the tree` + ); +} + +/** + * Returns whether a tree path exists + * @param {Document} doc + * @param {Array} path + */ +function isInTree(doc, path) { + const treeId = JSON.stringify(path); + return !!doc.querySelector(`[data-id='${treeId}']`); +} + +/** + * Returns the label of the node for the provided tree path + * @param {Document} doc + * @param {Array} path + * @returns {String} + */ +function getTreeNodeLabel(doc, path) { + const treeId = JSON.stringify(path); + return doc.querySelector(`[data-id='${treeId}'] .tree-widget-item`) + .textContent; +} + +/** + * Checks that the pair <name, value> is displayed at the data table + * @param {String} name + * @param {any} value + */ +function checkStorageData(name, value) { + ok( + hasStorageData(name, value), + `Table row has an entry for: ${name} with value: ${value}` + ); +} + +async function waitForStorageData(name, value) { + info("Waiting for data to appear in the table"); + await waitFor(() => hasStorageData(name, value)); + ok(true, `Table row has an entry for: ${name} with value: ${value}`); +} + +/** + * Returns whether the pair <name, value> is displayed at the data table + * @param {String} name + * @param {any} value + */ +function hasStorageData(name, value) { + return gUI.table.items.get(name)?.value === value; +} + +/** + * Returns an URL of a page that uses the document-builder to generate its content + * @param {String} domain + * @param {String} html + * @param {String} protocol + */ +function buildURLWithContent(domain, html, protocol = "https") { + return `${protocol}://${domain}/document-builder.sjs?html=${encodeURI(html)}`; +} + +/** + * Asserts that the given cookie holds the provided value in the data table + * @param {String} name + * @param {String} value + */ +function checkCookieData(name, value) { + ok( + hasCookieData(name, value), + `Table row has an entry for: ${name} with value: ${value}` + ); +} + +/** + * Returns whether the given cookie holds the provided value in the data table + * @param {String} name + * @param {String} value + */ +function hasCookieData(name, value) { + const rows = Array.from(gUI.table.items); + const cookie = rows.map(([, data]) => data).find(x => x.name === name); + + info(`found ${cookie?.value}`); + return cookie?.value === value; +} diff --git a/devtools/client/storage/test/storage-blank.html b/devtools/client/storage/test/storage-blank.html new file mode 100644 index 0000000000..81342ed690 --- /dev/null +++ b/devtools/client/storage/test/storage-blank.html @@ -0,0 +1,9 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8" /> + </head> + <body> + <h2>storage-blank.html</h2> + </body> +</html> diff --git a/devtools/client/storage/test/storage-cache-basic-iframe.html b/devtools/client/storage/test/storage-cache-basic-iframe.html new file mode 100644 index 0000000000..929660dc42 --- /dev/null +++ b/devtools/client/storage/test/storage-cache-basic-iframe.html @@ -0,0 +1,21 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for Cache</title> +</head> +<body> + <h1>Cache (iframe)</h1> +<script> + "use strict"; + async function setup() { // eslint-disable-line no-unused-vars + const cache = await caches.open("foo"); + await cache.add("storage-blank.html"); + } + function clear() { // eslint-disable-line no-unused-vars + return caches.delete("foo"); + } +</script> + +</body> +</html> diff --git a/devtools/client/storage/test/storage-cache-basic.html b/devtools/client/storage/test/storage-cache-basic.html new file mode 100644 index 0000000000..b87023ece1 --- /dev/null +++ b/devtools/client/storage/test/storage-cache-basic.html @@ -0,0 +1,22 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for Cache</title> +</head> +<body> + <h1>Cache</h1> +<script> + "use strict"; + async function setup() { // eslint-disable-line no-unused-vars + const cache = await caches.open("lorem"); + await cache.add("storage-blank.html"); + } + function clear() { // eslint-disable-line no-unused-vars + return caches.delete("lorem"); + } +</script> + +<iframe src="https://example.net/browser/devtools/client/storage/test/storage-cache-basic-iframe.html"></iframe> +</body> +</html> diff --git a/devtools/client/storage/test/storage-cache-error.html b/devtools/client/storage/test/storage-cache-error.html new file mode 100644 index 0000000000..1941c0dce0 --- /dev/null +++ b/devtools/client/storage/test/storage-cache-error.html @@ -0,0 +1,11 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for handling errors in CacheStorage</title> +</head> +<!-- The test case would load this page in a private browsing window --> +<body> + <iframe src="https://test2.example.org"></iframe> +</body> +</html> diff --git a/devtools/client/storage/test/storage-cache-overflow.html b/devtools/client/storage/test/storage-cache-overflow.html new file mode 100644 index 0000000000..83e6636817 --- /dev/null +++ b/devtools/client/storage/test/storage-cache-overflow.html @@ -0,0 +1,23 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for Cache</title> +</head> +<body> + <h1>Cache overflow</h1> +<script> + "use strict"; + async function setup() { // eslint-disable-line no-unused-vars + const cache = await caches.open("lorem"); + for (let i = 0; i < 100; i++) { + await cache.add(`storage-blank.html?${i}`); + } + } + function clear() { // eslint-disable-line no-unused-vars + return caches.delete("lorem"); + } +</script> + +</body> +</html> diff --git a/devtools/client/storage/test/storage-complex-keys.html b/devtools/client/storage/test/storage-complex-keys.html new file mode 100644 index 0000000000..b037190dea --- /dev/null +++ b/devtools/client/storage/test/storage-complex-keys.html @@ -0,0 +1,78 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for correct keys in the sidebar</title> +</head> +<body> +<script type="application/javascript"> +"use strict"; + +// Some local storage items ... +localStorage.setItem("", "1"); +localStorage.setItem("键", "2"); +// ... and finally some session storage items too +sessionStorage.setItem("Key with spaces", "3"); +sessionStorage.setItem("Key#with~special$characters", "4"); +// long string +const longKey = "a".repeat(1000); +sessionStorage.setItem(longKey, "5"); + +const idbGenerator = async function () { + const request = indexedDB.open("idb", 1); + request.onerror = function() { + throw new Error("Error opening database connection"); + }; + + const db = await new Promise(done => { + request.onupgradeneeded = event => { + const _db = event.target.result; + const store = _db.createObjectStore("obj", { keyPath: "id" }); + store.createIndex("name", "name", { unique: false }); + store.transaction.oncomplete = () => { + done(_db); + }; + }; + }); + + // Prevents AbortError + await new Promise(done => { + request.onsuccess = done; + }); + + const transaction = db.transaction("obj", "readwrite"); + const store = transaction.objectStore("obj"); + + store.add({id: "", name: "foo"}); + store.add({id: "键", name: "foo2"}); + store.add({id: "Key with spaces", name: "foo3"}); + store.add({id: "Key#with~special$characters", name: "foo4"}); + store.add({id: longKey, name: "foo5"}); + + db.close(); + + console.log("Added local and session storage items and indexedDB"); +}; + +function deleteDB(dbName) { + return new Promise(resolve => { + dump("removing database " + dbName + " from " + document.location + "\n"); + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); +} + +window.setup = async function () { + await idbGenerator(); +}; + +window.clear = async function () { + localStorage.clear(); + sessionStorage.clear(); + + await deleteDB("idb"); + + dump("Removed data from " + document.location + "\n"); +}; +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-complex-values.html b/devtools/client/storage/test/storage-complex-values.html new file mode 100644 index 0000000000..db7bc5e2ed --- /dev/null +++ b/devtools/client/storage/test/storage-complex-values.html @@ -0,0 +1,124 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 970517 - Storage inspector front end - tests +--> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for correct values in the sidebar</title> +</head> +<body> +<script type="application/javascript"> +"use strict"; +const partialHostname = location.hostname.match(/^[^.]+(\..*)$/)[1]; +const cookieExpiresTime = 2000000000000; +// Setting up some cookies to eat. +document.cookie = "c1=" + JSON.stringify([ + "foo", "Bar", { + foo: "Bar" + }]) + "; expires=" + new Date(cookieExpiresTime).toGMTString() + + "; path=/browser"; +document.cookie = "cs2=sessionCookie; path=/; domain=" + partialHostname; +// URLEncoded cookie +document.cookie = "c_encoded=" + encodeURIComponent(JSON.stringify({foo: {foo1: "bar"}})); + +// ... and some local storage items .. +const es6 = "for"; +localStorage.setItem("ls1", JSON.stringify({ + es6, the: "win", baz: [0, 2, 3, { + deep: "down", + nobody: "cares" + }]})); +localStorage.setItem("ls2", "foobar-2"); +localStorage.setItem("ls3", "http://foobar.com/baz.php"); +localStorage.setItem("ls4", "0x1"); +// ... and finally some session storage items too +sessionStorage.setItem("ss1", "This#is#an#array"); +sessionStorage.setItem("ss2", "This~is~another~array"); +sessionStorage.setItem("ss3", "this#is~an#object~foo#bar"); +sessionStorage.setItem("ss4", "#array##with#empty#items"); +// long string that is almost an object and might trigger exponential +// regexp backtracking +const s = "a".repeat(1000); +sessionStorage.setItem("ss5", `${s}=${s}=${s}=${s}&${s}=${s}&${s}`); +console.log("added cookies and stuff from main page"); + +const idbGenerator = async function () { + let request = indexedDB.open("idb1", 1); + request.onerror = function() { + throw new Error("error opening db connection"); + }; + const db = await new Promise(done => { + request.onupgradeneeded = event => { + const _db = event.target.result; + const store1 = _db.createObjectStore("obj1", { keyPath: "id" }); + store1.createIndex("name", "name", { unique: false }); + store1.createIndex("email", "email", { unique: true }); + _db.createObjectStore("obj2", { keyPath: "id2" }); + store1.transaction.oncomplete = () => { + done(_db); + }; + }; + }); + + // Prevents AbortError + await new Promise(done => { + request.onsuccess = done; + }); + + const transaction = db.transaction(["obj1", "obj2"], "readwrite"); + const store1 = transaction.objectStore("obj1"); + const store2 = transaction.objectStore("obj2"); + + store1.add({id: 1, name: "foo", email: "foo@bar.com"}); + store1.add({id: 2, name: "foo2", email: "foo2@bar.com"}); + store1.add({id: 3, name: "foo2", email: "foo3@bar.com"}); + store2.add({ + id2: 1, + name: "foo", + email: "foo@bar.com", + extra: "baz".repeat(10000)}); + + db.close(); + + request = indexedDB.open("idb2", 1); + const db2 = await new Promise(done => { + request.onupgradeneeded = event => { + const _db2 = event.target.result; + const store3 = _db2.createObjectStore("obj3", { keyPath: "id3" }); + store3.createIndex("name2", "name2", { unique: true }); + store3.transaction.oncomplete = () => { + done(_db2); + }; + }; + }); + + // Prevents AbortError during close() + await new Promise(done => { + request.onsuccess = done; + }); + + db2.close(); + console.log("added cookies and stuff from main page"); +}; + +function deleteDB(dbName) { + return new Promise(resolve => { + dump("removing database " + dbName + " from " + document.location + "\n"); + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); +} + +window.setup = async function () { + await idbGenerator(); +}; + +window.clear = async function () { + await deleteDB("idb1"); + await deleteDB("idb2"); + + dump("removed indexedDB data from " + document.location + "\n"); +}; +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-cookies-samesite.html b/devtools/client/storage/test/storage-cookies-samesite.html new file mode 100644 index 0000000000..90eb75d95e --- /dev/null +++ b/devtools/client/storage/test/storage-cookies-samesite.html @@ -0,0 +1,17 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf-8"> + <title>Storage inspector cookie samesite test</title> + </head> + <body> + <script type="application/javascript"> + "use strict"; + const expiresIn24Hours = new Date(Date.now() + 60 * 60 * 24 * 1000).toUTCString(); + + document.cookie = "test1=value1;expires=" + expiresIn24Hours + ";"; + document.cookie = "test2=value2;expires=" + expiresIn24Hours + ";SameSite=lax"; + document.cookie = "test3=value3;expires=" + expiresIn24Hours + ";SameSite=strict"; + </script> + </body> +</html> diff --git a/devtools/client/storage/test/storage-cookies-sort.html b/devtools/client/storage/test/storage-cookies-sort.html new file mode 100644 index 0000000000..fb590d5cb2 --- /dev/null +++ b/devtools/client/storage/test/storage-cookies-sort.html @@ -0,0 +1,26 @@ +<!DOCTYPE HTML> +<html> + <!-- + Bug 970517 - Storage inspector front end - tests + --> + <head> + <meta charset="utf-8"> + <title>Storage inspector cookie test</title> + </head> + <body> + <script type="application/javascript"> + "use strict"; + const ONE_HOUR = 60 * 60 * 1000; + const ONE_DAY = 24 * ONE_HOUR; + const expiresOneHour = new Date(Date.now() + 1 * ONE_HOUR).toUTCString(); + const expiresOneDay = new Date(Date.now() + 1 * ONE_DAY).toUTCString(); + const expiresOneYear = new Date(Date.now() + 365 * ONE_DAY).toUTCString(); + + document.cookie = "test_hour=hour;expires=" + expiresOneHour; + document.cookie = "test_session1=session1"; + document.cookie = "test_day=day;expires=" + expiresOneDay; + document.cookie = "test_session2=session2"; + document.cookie = "test_year=year;expires=" + expiresOneYear; + </script> + </body> +</html> diff --git a/devtools/client/storage/test/storage-cookies.html b/devtools/client/storage/test/storage-cookies.html new file mode 100644 index 0000000000..c0d0522961 --- /dev/null +++ b/devtools/client/storage/test/storage-cookies.html @@ -0,0 +1,24 @@ +<!DOCTYPE HTML> +<html> + <!-- + Bug 970517 - Storage inspector front end - tests + --> + <head> + <meta charset="utf-8"> + <title>Storage inspector cookie test</title> + </head> + <body> + <script type="application/javascript"> + "use strict"; + const expiresIn24Hours = new Date(Date.now() + 60 * 60 * 24 * 1000).toUTCString(); + for (let i = 1; i <= 5; i++) { + let cookieString = "test" + i + "=value" + i + + ";expires=" + expiresIn24Hours + ";path=/browser"; + if (i % 2) { + cookieString += ";domain=test1.example.org"; + } + document.cookie = cookieString; + } + </script> + </body> +</html> diff --git a/devtools/client/storage/test/storage-dfpi.html b/devtools/client/storage/test/storage-dfpi.html new file mode 100644 index 0000000000..e440819df7 --- /dev/null +++ b/devtools/client/storage/test/storage-dfpi.html @@ -0,0 +1,11 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8" /> + </head> + <body> + <h2>storage-iframe.html</h2> + <!-- Sync iframe.src to browser_storage_dfpi.js:PREFIX --> + <iframe src="https://sub1.test1.example.com/browser/devtools/client/storage/test/storage-blank.html"></iframe> + </body> +</html> diff --git a/devtools/client/storage/test/storage-empty-objectstores.html b/devtools/client/storage/test/storage-empty-objectstores.html new file mode 100644 index 0000000000..4479fc0972 --- /dev/null +++ b/devtools/client/storage/test/storage-empty-objectstores.html @@ -0,0 +1,62 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test for proper listing indexedDB databases with no object stores</title> +</head> +<body> +<script type="application/javascript"> +"use strict"; +window.setup = async function () { + let request = indexedDB.open("idb1", 1); + const db = await new Promise((resolve, reject) => { + request.onerror = e => reject(Error("error opening db connection")); + request.onupgradeneeded = event => { + const _db = event.target.result; + const store1 = _db.createObjectStore("obj1", { keyPath: "id" }); + store1.createIndex("name", "name", { unique: false }); + store1.createIndex("email", "email", { unique: true }); + _db.createObjectStore("obj2", { keyPath: "id2" }); + store1.transaction.oncomplete = () => resolve(_db); + }; + }); + + await new Promise(resolve => (request.onsuccess = resolve)); + + const transaction = db.transaction(["obj1", "obj2"], "readwrite"); + const store1 = transaction.objectStore("obj1"); + const store2 = transaction.objectStore("obj2"); + + store1.add({id: 1, name: "foo", email: "foo@bar.com"}); + store1.add({id: 2, name: "foo2", email: "foo2@bar.com"}); + store1.add({id: 3, name: "foo2", email: "foo3@bar.com"}); + store2.add({id2: 1, name: "foo", email: "foo@bar.com", extra: "baz"}); + + await new Promise(resolve => (transaction.oncomplete = resolve)); + + db.close(); + + request = indexedDB.open("idb2", 1); + const db2 = await new Promise((resolve, reject) => { + request.onerror = e => reject(Error("error opening db2 connection")); + request.onupgradeneeded = event => resolve(event.target.result); + }); + + await new Promise(resolve => (request.onsuccess = resolve)); + + db2.close(); + dump("added indexedDB items from main page\n"); +}; + +window.clear = async function () { + for (const dbName of ["idb1", "idb2"]) { + await new Promise(resolve => { + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); + } + dump("removed indexedDB items from main page\n"); +}; + +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-file-url.html b/devtools/client/storage/test/storage-file-url.html new file mode 100644 index 0000000000..1d10ab12b3 --- /dev/null +++ b/devtools/client/storage/test/storage-file-url.html @@ -0,0 +1,59 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8" /> + <title>Storage Test</title> + <script> + "use strict"; + /* exported setup */ + function setup() { + createIndexedDB(); + createCookies(); + createLocalStorage(); + createSessionStorage(); + } + + function createIndexedDB() { + const open = indexedDB.open("MyDatabase", 1); + + open.onupgradeneeded = function () { + const db = open.result; + db.createObjectStore("MyObjectStore", {keyPath: "id"}); + }; + + open.onsuccess = function () { + const db = open.result; + const tx = db.transaction("MyObjectStore", "readwrite"); + const store = tx.objectStore("MyObjectStore"); + + store.put({id: 12345, name: {first: "John", last: "Doe"}, age: 42}); + store.put({id: 54321, name: {first: "Ralph", last: "Wood"}, age: 38}); + store.put({id: 67890, name: {first: "Bob", last: "Smith"}, age: 35}); + store.put({id: 98765, name: {first: "Freddie", last: "Krueger"}, age: 40}); + + tx.oncomplete = function () { + db.close(); + }; + }; + } + + function createCookies() { + document.cookie = "test1=Jean Dupond"; + document.cookie = "test2=dnopuD naeJ"; + } + + function createLocalStorage() { + localStorage.setItem("test3", "John Doe"); + localStorage.setItem("test4", "eoD nhoJ"); + } + + function createSessionStorage() { + sessionStorage.setItem("test5", "John Smith"); + sessionStorage.setItem("test6", "htimS nhoJ"); + } + </script> +</head> +<body> + <h1>IndexedDB Test</h1> +</body> +</html> diff --git a/devtools/client/storage/test/storage-idb-delete-blocked.html b/devtools/client/storage/test/storage-idb-delete-blocked.html new file mode 100644 index 0000000000..7c7b597421 --- /dev/null +++ b/devtools/client/storage/test/storage-idb-delete-blocked.html @@ -0,0 +1,47 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test for proper listing indexedDB databases with no object stores</title> +</head> +<body> +<script type="application/javascript"> +"use strict"; +let db; + +window.setup = async function () { + db = await new Promise((resolve, reject) => { + const request = indexedDB.open("idb", 1); + + request.onsuccess = e => resolve(e.target.result); + request.onerror = e => reject(new Error("error opening db connection")); + }); + + dump("opened indexedDB\n"); +}; + +window.closeDb = function() { + db.close(); +}; + +window.deleteDb = async function () { + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase("idb"); + + request.onsuccess = resolve; + request.onerror = e => reject(new Error("error deleting db")); + }); +}; + +window.clear = async function () { + for (const dbName of ["idb1", "idb2"]) { + await new Promise(resolve => { + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); + } + dump("removed indexedDB items from main page\n"); +}; + +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-indexeddb-duplicate-names.html b/devtools/client/storage/test/storage-indexeddb-duplicate-names.html new file mode 100644 index 0000000000..0f448f3727 --- /dev/null +++ b/devtools/client/storage/test/storage-indexeddb-duplicate-names.html @@ -0,0 +1,43 @@ +<!DOCTYPE html> +<html><head> +<meta http-equiv="content-type" content="text/html; charset=UTF-8"> + <meta charset="utf-8"> + <title>Storage inspector IndexedDBs with duplicate names</title> + + <script type="application/javascript"> + "use strict"; + + /* exported setup */ + function setup() { + createIndexedDB("idb1"); + createIndexedDB("idb2"); + } + + function createIndexedDB(name) { + const open = indexedDB.open(name); + + open.onsuccess = function () { + const db = open.result; + db.close(); + }; + } + + function deleteDB(dbName) { + return new Promise(resolve => { + dump(`removing database ${dbName} from ${document.location}\n`); + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); + } + + window.clear = async function () { + await deleteDB("idb1"); + await deleteDB("idb2"); + + dump(`removed indexedDB data from ${document.location}\n`); + }; + </script> +</head> +<body> + <h1>storage-indexeddb-duplicate-names.html</h1> +</body> +</html> diff --git a/devtools/client/storage/test/storage-indexeddb-iframe.html b/devtools/client/storage/test/storage-indexeddb-iframe.html new file mode 100644 index 0000000000..8cf0071bd0 --- /dev/null +++ b/devtools/client/storage/test/storage-indexeddb-iframe.html @@ -0,0 +1,37 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for indexedDB - simple (alt)</title> +</head> + +<body> + <h1>IndexedDB storage - with iframe</h1> + <iframe src="https://example.net/browser/devtools/client/storage/test/storage-indexeddb-simple.html"></iframe> + +<script> +"use strict"; + +const DB_NAME = "db"; + +async function setup() { // eslint-disable-line no-unused-vars + await new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onerror = event => reject(Error("Error opening DB")); + request.onupgradeneeded = event => { + const db = event.target.result; + const store = db.createObjectStore("store", { keyPath: "key" }); + store.add({key: "foo", value: "bar"}); + store.transaction.oncomplete = () => resolve(db); + } + }); +} + +async function clear() { // eslint-disable-line no-unused-vars + await new Promise(resolve => { + indexedDB.deleteDatabase(DB_NAME).onsuccess = resolve; + }); +} +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-indexeddb-simple-alt.html b/devtools/client/storage/test/storage-indexeddb-simple-alt.html new file mode 100644 index 0000000000..0c5e56f795 --- /dev/null +++ b/devtools/client/storage/test/storage-indexeddb-simple-alt.html @@ -0,0 +1,38 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for indexedDB - simple (alt)</title> +</head> + +<body> + <h1>IndexedDB storage - simple (alt)</h1> + +<script> +"use strict"; + +const DB_NAME = "db-alt"; + +async function setup() { // eslint-disable-line no-unused-vars + await new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onerror = event => reject(Error("Error opening DB")); + request.onupgradeneeded = event => { + const db = event.target.result; + const store = db.createObjectStore("store", { keyPath: "key" }); + store.add({key: "foo", value: "bar"}); + store.transaction.oncomplete = () => resolve(db); + } + }); +} + +async function clear() { // eslint-disable-line no-unused-vars + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(DB_NAME); + request.onsuccess = resolve; + request.onerror = () => reject(Error("Error deleting DB")); + }); +} +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-indexeddb-simple.html b/devtools/client/storage/test/storage-indexeddb-simple.html new file mode 100644 index 0000000000..9839240646 --- /dev/null +++ b/devtools/client/storage/test/storage-indexeddb-simple.html @@ -0,0 +1,38 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for indexedDB - simple</title> +</head> + +<body> + <h1>IndexedDB storage - simple</h1> + +<script> +"use strict"; + +const DB_NAME = "db"; + +async function setup() { // eslint-disable-line no-unused-vars + await new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onerror = event => reject(Error("Error opening DB")); + request.onupgradeneeded = event => { + const db = event.target.result; + const store = db.createObjectStore("store", { keyPath: "key" }); + store.add({key: "lorem", value: "ipsum"}); + store.transaction.oncomplete = () => resolve(db); + } + }); +} + +async function clear() { // eslint-disable-line no-unused-vars + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(DB_NAME) + request.onsuccess = resolve; + request.onerror = () => reject(Error("Error deleting DB")); + }); +} +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-listings-usercontextid.html b/devtools/client/storage/test/storage-listings-usercontextid.html new file mode 100644 index 0000000000..d6eb6baf3c --- /dev/null +++ b/devtools/client/storage/test/storage-listings-usercontextid.html @@ -0,0 +1,131 @@ +<!DOCTYPE HTML> +<html> +<!-- +Storage inspector front end for userContextId - tests +--> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for listing hosts and storages</title> +</head> +<body> +<iframe src="http://sectest1.example.org/browser/devtools/client/storage/test/storage-unsecured-iframe-usercontextid.html"></iframe> +<iframe src="https://sectest1.example.org:443/browser/devtools/client/storage/test/storage-secured-iframe-usercontextid.html"></iframe> +<script type="application/javascript"> +"use strict"; +const partialHostname = location.hostname.match(/^[^.]+(\..*)$/)[1]; +const cookieExpiresTime1 = 2000000000000; +const cookieExpiresTime2 = 2000000001000; +// Setting up some cookies to eat. +document.cookie = "c1uc1=foobar; expires=" + + new Date(cookieExpiresTime1).toGMTString() + "; path=/browser"; +document.cookie = "cs2uc1=sessionCookie; path=/; domain=" + partialHostname; +document.cookie = "c3uc1=foobar-2; expires=" + + new Date(cookieExpiresTime2).toGMTString() + "; path=/"; +// ... and some local storage items .. +localStorage.setItem("ls1uc1", "foobar"); +localStorage.setItem("ls2uc1", "foobar-2"); +// ... and finally some session storage items too +sessionStorage.setItem("ss1uc1", "foobar-3"); +dump("added cookies and storage from main page\n"); + +const idbGenerator = async function () { + let request = indexedDB.open("idb1uc1", 1); + request.onerror = function() { + throw new Error("error opening db connection"); + }; + const db = await new Promise(done => { + request.onupgradeneeded = event => { + const _db = event.target.result; + const store1 = _db.createObjectStore("obj1uc1", { keyPath: "id" }); + store1.createIndex("name", "name", { unique: false }); + store1.createIndex("email", "email", { unique: true }); + _db.createObjectStore("obj2uc1", { keyPath: "id2" }); + store1.transaction.oncomplete = () => { + done(_db); + }; + }; + }); + + // Prevents AbortError + await new Promise(done => { + request.onsuccess = done; + }); + + const transaction = db.transaction(["obj1uc1", "obj2uc1"], "readwrite"); + const store1 = transaction.objectStore("obj1uc1"); + const store2 = transaction.objectStore("obj2uc1"); + store1.add({id: 1, name: "foo", email: "foo@bar.com"}); + store1.add({id: 2, name: "foo2", email: "foo2@bar.com"}); + store1.add({id: 3, name: "foo2", email: "foo3@bar.com"}); + store2.add({ + id2: 1, + name: "foo", + email: "foo@bar.com", + extra: "baz" + }); + // Prevents AbortError during close() + await new Promise(success => { + transaction.oncomplete = success; + }); + + db.close(); + + request = indexedDB.open("idb2uc1", 1); + const db2 = await new Promise(done => { + request.onupgradeneeded = event => { + const _db2 = event.target.result; + const store3 = _db2.createObjectStore("obj3uc1", { keyPath: "id3" }); + store3.createIndex("name2", "name2", { unique: true }); + store3.transaction.oncomplete = () => { + done(_db2); + } + }; + }); + // Prevents AbortError during close() + await new Promise(done => { + request.onsuccess = done; + }); + db2.close(); + + dump("added indexedDB from main page\n"); +}; + +function deleteDB(dbName) { + return new Promise(resolve => { + dump("removing database " + dbName + " from " + document.location + "\n"); + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); +} + +async function fetchPut(cache, url) { + const response = await fetch(url); + await cache.put(url, response); +} + +const cacheGenerator = async function () { + const cache = await caches.open("plopuc1"); + await fetchPut(cache, "404_cached_file.js"); + await fetchPut(cache, "browser_storage_basic.js"); +}; + +window.setup = async function () { + await idbGenerator(); + + if (window.caches) { + await cacheGenerator(); + } +}; + +window.clear = async function () { + await deleteDB("idb1uc1"); + await deleteDB("idb2uc1"); + + if (window.caches) { + await caches.delete("plopuc1"); + } + + dump("removed indexedDB and cache data from " + document.location + "\n"); +}; +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-listings-with-fragment.html b/devtools/client/storage/test/storage-listings-with-fragment.html new file mode 100644 index 0000000000..2698f6ebac --- /dev/null +++ b/devtools/client/storage/test/storage-listings-with-fragment.html @@ -0,0 +1,134 @@ +<!DOCTYPE HTML> +<html> +<!-- +This test differs from browser_storage_listings.html only because the URLs we load +include fragments e.g. http://example.com/test.js#abcdefg + ^^^^^^^^ + fragment +--> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for listing hosts and storages with URL fragments</title> +</head> +<body> +<iframe src="http://sectest1.example.org/browser/devtools/client/storage/test/storage-unsecured-iframe.html#def"></iframe> +<iframe src="https://sectest1.example.org:443/browser/devtools/client/storage/test/storage-secured-iframe.html#ghi"></iframe> +<script type="application/javascript"> +"use strict"; +const partialHostname = location.hostname.match(/^[^.]+(\..*)$/)[1]; +const cookieExpiresTime1 = 2000000000000; +const cookieExpiresTime2 = 2000000001000; +// Setting up some cookies to eat. +document.cookie = "c1=foobar; expires=" + + new Date(cookieExpiresTime1).toGMTString() + "; path=/browser"; +document.cookie = "cs2=sessionCookie; path=/; domain=" + partialHostname; +document.cookie = "c3=foobar-2; expires=" + + new Date(cookieExpiresTime2).toGMTString() + "; path=/"; +// ... and some local storage items .. +localStorage.setItem("ls1", "foobar"); +localStorage.setItem("ls2", "foobar-2"); +// ... and finally some session storage items too +sessionStorage.setItem("ss1", "foobar-3"); +dump("added cookies and storage from main page\n"); + +const idbGenerator = async function () { + let request = indexedDB.open("idb1", 1); + request.onerror = function() { + throw new Error("error opening db connection"); + }; + const db = await new Promise(done => { + request.onupgradeneeded = event => { + const _db = event.target.result; + const store1 = _db.createObjectStore("obj1", { keyPath: "id" }); + store1.createIndex("name", "name", { unique: false }); + store1.createIndex("email", "email", { unique: true }); + _db.createObjectStore("obj2", { keyPath: "id2" }); // eslint-disable-line no-unused-vars + store1.transaction.oncomplete = () => { + done(_db); + }; + }; + }); + + // Prevents AbortError + await new Promise(done => { + request.onsuccess = done; + }); + + const transaction = db.transaction(["obj1", "obj2"], "readwrite"); + const store1 = transaction.objectStore("obj1"); + const store2 = transaction.objectStore("obj2"); + store1.add({id: 1, name: "foo", email: "foo@bar.com"}); + store1.add({id: 2, name: "foo2", email: "foo2@bar.com"}); + store1.add({id: 3, name: "foo2", email: "foo3@bar.com"}); + store2.add({ + id2: 1, + name: "foo", + email: "foo@bar.com", + extra: "baz" + }); + // Prevents AbortError during close() + await new Promise(success => { + transaction.oncomplete = success; + }); + + db.close(); + + request = indexedDB.open("idb2", 1); + const db2 = await new Promise(done => { + request.onupgradeneeded = event => { + const _db2 = event.target.result; + const store3 = _db2.createObjectStore("obj3", { keyPath: "id3" }); + store3.createIndex("name2", "name2", { unique: true }); + store3.transaction.oncomplete = () => { + done(_db2); + } + }; + }); + // Prevents AbortError during close() + await new Promise(done => { + request.onsuccess = done; + }); + db2.close(); + + dump("added indexedDB from main page\n"); +}; + +function deleteDB(dbName) { + return new Promise(resolve => { + dump("removing database " + dbName + " from " + document.location + "\n"); + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); +} + +async function fetchPut(cache, url) { + const response = await fetch(url); + await cache.put(url, response); +} + +const cacheGenerator = async function () { + const cache = await caches.open("plop"); + await fetchPut(cache, "404_cached_file.js"); + await fetchPut(cache, "browser_storage_basic.js"); +}; + +window.setup = async function () { + await idbGenerator(); + + if (window.caches) { + await cacheGenerator(); + } +}; + +window.clear = async function () { + await deleteDB("idb1"); + await deleteDB("idb2"); + + if (window.caches) { + await caches.delete("plop"); + } + + dump("removed indexedDB and cache data from " + document.location + "\n"); +}; +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-listings.html b/devtools/client/storage/test/storage-listings.html new file mode 100644 index 0000000000..84ab005c50 --- /dev/null +++ b/devtools/client/storage/test/storage-listings.html @@ -0,0 +1,145 @@ +<!DOCTYPE HTML> +<html> +<!-- +Storage inspector front end - tests +--> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for listing hosts and storages</title> +</head> +<body> +<iframe src="http://sectest1.example.org/browser/devtools/client/storage/test/storage-unsecured-iframe.html"></iframe> +<iframe src="https://sectest1.example.org:443/browser/devtools/client/storage/test/storage-secured-iframe.html"></iframe> +<script type="application/javascript"> +"use strict"; +const partialHostname = location.hostname.match(/^[^.]+(\..*)$/)[1]; +const cookieExpiresTime1 = 2000000000000; +const cookieExpiresTime2 = 2000000001000; +// Setting up some cookies to eat. +document.cookie = "c1=foobar; expires=" + + new Date(cookieExpiresTime1).toGMTString() + "; path=/browser"; +document.cookie = "cs2=sessionCookie; path=/; domain=" + partialHostname; +document.cookie = "c3=foobar-2; expires=" + + new Date(cookieExpiresTime1).toGMTString() + "; path=/"; +document.cookie = "c4=foobar-3; expires=" + + new Date(cookieExpiresTime2).toGMTString() + "; path=/; domain=" + + partialHostname; +// ... and some local storage items .. +localStorage.setItem("ls1", "foobar"); +localStorage.setItem("ls2", "foobar-2"); + +// Because localStorage contains key() on the prototype and it can't be iterated +// using object.keys() we check the the value "key" exists. +// See bug 1451991 for details. +localStorage.setItem("key", "value1"); + +// ... and finally some session storage items too +sessionStorage.setItem("ss1", "foobar-3"); + +// Because sessionStorage contains key() on the prototype and it can't be +// iterated using object.keys() we check the the value "key" exists. +// See bug 1451991 for details. +sessionStorage.setItem("key", "value2"); + +dump("added cookies and storage from main page\n"); + +const idbGenerator = async function () { + let request = indexedDB.open("idb1", 1); + request.onerror = function() { + throw new Error("error opening db connection"); + }; + const db = await new Promise(done => { + request.onupgradeneeded = event => { + const _db = event.target.result; + const store1 = _db.createObjectStore("obj1", { keyPath: "id" }); + store1.createIndex("name", "name", { unique: false }); + store1.createIndex("email", "email", { unique: true }); + _db.createObjectStore("obj2", { keyPath: "id2" }); // eslint-disable-line no-unused-vars + store1.transaction.oncomplete = () => { + done(_db); + }; + }; + }); + + // Prevents AbortError + await new Promise(done => { + request.onsuccess = done; + }); + + const transaction = db.transaction(["obj1", "obj2"], "readwrite"); + const store1 = transaction.objectStore("obj1"); + const store2 = transaction.objectStore("obj2"); + store1.add({id: 1, name: "foo", email: "foo@bar.com"}); + store1.add({id: 2, name: "foo2", email: "foo2@bar.com"}); + store1.add({id: 3, name: "foo2", email: "foo3@bar.com"}); + store2.add({ + id2: 1, + name: "foo", + email: "foo@bar.com", + extra: "baz" + }); + // Prevents AbortError during close() + await new Promise(success => { + transaction.oncomplete = success; + }); + + db.close(); + + request = indexedDB.open("idb2", 1); + const db2 = await new Promise(done => { + request.onupgradeneeded = event => { + const _db2 = event.target.result; + const store3 = _db2.createObjectStore("obj3", { keyPath: "id3" }); + store3.createIndex("name2", "name2", { unique: true }); + store3.transaction.oncomplete = () => { + done(_db2); + } + }; + }); + // Prevents AbortError during close() + await new Promise(done => { + request.onsuccess = done; + }); + db2.close(); + + dump("added indexedDB from main page\n"); +}; + +function deleteDB(dbName) { + return new Promise(resolve => { + dump("removing database " + dbName + " from " + document.location + "\n"); + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); +} + +async function fetchPut(cache, url) { + const response = await fetch(url); + await cache.put(url, response); +} + +const cacheGenerator = async function () { + const cache = await caches.open("plop"); + await fetchPut(cache, "404_cached_file.js"); + await fetchPut(cache, "browser_storage_basic.js"); +}; + +window.setup = async function () { + await idbGenerator(); + + if (window.caches) { + await cacheGenerator(); + } +}; + +window.clear = async function () { + await deleteDB("idb1"); + await deleteDB("idb2"); + + if (window.caches) { + await caches.delete("plop"); + } + dump("removed indexedDB and cache data from " + document.location + "\n"); +}; +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-localstorage.html b/devtools/client/storage/test/storage-localstorage.html new file mode 100644 index 0000000000..b22c7d42f6 --- /dev/null +++ b/devtools/client/storage/test/storage-localstorage.html @@ -0,0 +1,23 @@ +<!doctype html> +<html> + <!-- + Bug 1231155 - Storage inspector front end - tests + --> + <head> + <meta charset="utf-8" /> + <title>Storage inspector localStorage test</title> + <script type="application/javascript"> + "use strict"; + /* exported setup */ + function setup() { + localStorage.setItem("TestLS1", "ValueLS1"); + localStorage.setItem("TestLS2", "ValueLS2"); + localStorage.setItem("TestLS3", "ValueLS3"); + localStorage.setItem("TestLS4", "ValueLS4"); + localStorage.setItem("TestLS5", "ValueLS5"); + } + </script> + </head> + <body> + </body> +</html> diff --git a/devtools/client/storage/test/storage-overflow-indexeddb.html b/devtools/client/storage/test/storage-overflow-indexeddb.html new file mode 100644 index 0000000000..68bc6522b0 --- /dev/null +++ b/devtools/client/storage/test/storage-overflow-indexeddb.html @@ -0,0 +1,49 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1171903 - Storage Inspector endless scrolling +--> +<head> + <meta charset="utf-8"> + <title>Storage inspector endless scrolling test</title> +</head> +<body> +<script type="text/javascript"> +"use strict"; + +window.setup = async function() { + await new Promise(resolve => { + const open = indexedDB.open("database", 1); + open.onupgradeneeded = function() { + const db = open.result; + const store = db.createObjectStore("store", {keyPath: "id"}); + store.transaction.oncomplete = () => { + const transaction = db.transaction(["store"], "readwrite"); + for (let i = 1; i < 150; i++) { + transaction.objectStore("store").add({id: i}); + } + + transaction.oncomplete = function() { + db.close(); + resolve(); + }; + }; + }; + }); +}; + +function deleteDB(dbName) { + return new Promise(resolve => { + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); +} + +window.clear = async function() { + await deleteDB("database"); + + dump(`removed indexedDB data from ${document.location}\n`); +}; + +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-overflow.html b/devtools/client/storage/test/storage-overflow.html new file mode 100644 index 0000000000..c922a18e69 --- /dev/null +++ b/devtools/client/storage/test/storage-overflow.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1171903 - Storage Inspector endless scrolling +--> +<head> + <meta charset="utf-8"> + <title>Storage inspector endless scrolling test</title> +</head> +<body> +<script type="text/javascript"> +"use strict"; + +for (let i = 1; i < 152; i++) { + localStorage.setItem(`item-${i}`, `value-${i}`); +} +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-search.html b/devtools/client/storage/test/storage-search.html new file mode 100644 index 0000000000..914138aa39 --- /dev/null +++ b/devtools/client/storage/test/storage-search.html @@ -0,0 +1,28 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1224115 - Storage Inspector table filtering +--> +<head> + <meta charset="utf-8"> + <title>Storage inspector table filtering test</title> + + <script type="text/javascript"> + "use strict"; + + /* exported setup */ + function setup() { + document.cookie = "01234=56789"; + document.cookie = "ANIMAL=hOrSe"; + document.cookie = "food=energy bar"; + document.cookie = "FOO=bArBaz"; + document.cookie = "money=##$$$**"; + document.cookie = "sport=football"; + document.cookie = "year=2016"; + } + </script> + +</head> +<body> +</body> +</html> diff --git a/devtools/client/storage/test/storage-secured-iframe-usercontextid.html b/devtools/client/storage/test/storage-secured-iframe-usercontextid.html new file mode 100644 index 0000000000..c3958661d4 --- /dev/null +++ b/devtools/client/storage/test/storage-secured-iframe-usercontextid.html @@ -0,0 +1,91 @@ +<!DOCTYPE HTML> +<html> +<!-- +Iframe for testing multiple host detetion in storage actor +--> +<head> + <meta charset="utf-8"> +</head> +<body> +<script type="application/javascript"> +"use strict"; +document.cookie = "sc1uc1=foobar;"; +localStorage.setItem("iframe-s-ls1uc1", "foobar"); +sessionStorage.setItem("iframe-s-ss1uc1", "foobar-2"); +dump("added cookies and storage from secured iframe\n"); + +const idbGenerator = async function () { + let request = indexedDB.open("idb-s1uc1", 1); + request.onerror = function() { + throw new Error("error opening db connection"); + }; + const db = await new Promise(done => { + request.onupgradeneeded = event => { + const _db = event.target.result; + const store1 = _db.createObjectStore("obj-s1uc1", { keyPath: "id" }); + store1.transaction.oncomplete = () => { + done(_db); + }; + }; + }); + await new Promise(done => { + request.onsuccess = done; + }); + + let transaction = db.transaction(["obj-s1uc1"], "readwrite"); + const store1 = transaction.objectStore("obj-s1uc1"); + store1.add({id: 6, name: "foo", email: "foo@bar.com"}); + store1.add({id: 7, name: "foo2", email: "foo2@bar.com"}); + await new Promise(success => { + transaction.oncomplete = success; + }); + + db.close(); + + request = indexedDB.open("idb-s2uc1", 1); + const db2 = await new Promise(done => { + request.onupgradeneeded = event => { + const _db2 = event.target.result; + const store3 = + _db2.createObjectStore("obj-s2uc1", { keyPath: "id3", autoIncrement: true }); + store3.createIndex("name2", "name2", { unique: true }); + store3.transaction.oncomplete = () => { + done(_db2); + }; + }; + }); + await new Promise(done => { + request.onsuccess = done; + }); + + transaction = db2.transaction(["obj-s2uc1"], "readwrite"); + const store3 = transaction.objectStore("obj-s2uc1"); + store3.add({id3: 16, name2: "foo", email: "foo@bar.com"}); + await new Promise(success => { + transaction.oncomplete = success; + }); + + db2.close(); + dump("added indexedDB from secured iframe\n"); +}; + +function deleteDB(dbName) { + return new Promise(resolve => { + dump("removing database " + dbName + " from " + document.location + "\n"); + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); +} + +window.setup = async function () { + await idbGenerator(); +}; + +window.clear = async function () { + await deleteDB("idb-s1uc1"); + await deleteDB("idb-s2uc1"); + + dump("removed indexedDB data from " + document.location + "\n"); +}; +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-secured-iframe.html b/devtools/client/storage/test/storage-secured-iframe.html new file mode 100644 index 0000000000..e0b7cc3716 --- /dev/null +++ b/devtools/client/storage/test/storage-secured-iframe.html @@ -0,0 +1,94 @@ +<!DOCTYPE HTML> +<html> +<!-- +Iframe for testing multiple host detetion in storage actor +--> +<head> + <meta charset="utf-8"> +</head> +<body> +<script type="application/javascript"> +"use strict"; +const cookieExpiresTime = 2000000000000; +document.cookie = "sc1=foobar;"; +document.cookie = "sc2=foobar-2; expires=" + + new Date(cookieExpiresTime).toGMTString() + ";"; +localStorage.setItem("iframe-s-ls1", "foobar"); +sessionStorage.setItem("iframe-s-ss1", "foobar-2"); +dump("added cookies and storage from secured iframe\n"); + +const idbGenerator = async function () { + let request = indexedDB.open("idb-s1", 1); + request.onerror = function() { + throw new Error("error opening db connection"); + }; + const db = await new Promise(done => { + request.onupgradeneeded = event => { + const _db = event.target.result; + const store1 = _db.createObjectStore("obj-s1", { keyPath: "id" }); + store1.transaction.oncomplete = () => { + done(_db); + }; + }; + }); + await new Promise(done => { + request.onsuccess = done; + }); + + let transaction = db.transaction(["obj-s1"], "readwrite"); + const store1 = transaction.objectStore("obj-s1"); + store1.add({id: 6, name: "foo", email: "foo@bar.com"}); + store1.add({id: 7, name: "foo2", email: "foo2@bar.com"}); + await new Promise(success => { + transaction.oncomplete = success; + }); + + db.close(); + + request = indexedDB.open("idb-s2", 1); + const db2 = await new Promise(done => { + request.onupgradeneeded = event => { + const _db2 = event.target.result; + const store3 = + _db2.createObjectStore("obj-s2", { keyPath: "id3", autoIncrement: true }); + store3.createIndex("name2", "name2", { unique: true }); + store3.transaction.oncomplete = () => { + done(_db2); + }; + }; + }); + await new Promise(done => { + request.onsuccess = done; + }); + + transaction = db2.transaction(["obj-s2"], "readwrite"); + const store3 = transaction.objectStore("obj-s2"); + store3.add({id3: 16, name2: "foo", email: "foo@bar.com"}); + await new Promise(success => { + transaction.oncomplete = success; + }); + + db2.close(); + dump("added indexedDB from secured iframe\n"); +}; + +function deleteDB(dbName) { + return new Promise(resolve => { + dump("removing database " + dbName + " from " + document.location + "\n"); + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); +} + +window.setup = async function () { + await idbGenerator(); +}; + +window.clear = async function () { + await deleteDB("idb-s1"); + await deleteDB("idb-s2"); + + dump("removed indexedDB data from " + document.location + "\n"); +}; +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-sessionstorage.html b/devtools/client/storage/test/storage-sessionstorage.html new file mode 100644 index 0000000000..2e0fb2f131 --- /dev/null +++ b/devtools/client/storage/test/storage-sessionstorage.html @@ -0,0 +1,23 @@ +<!doctype html> +<html> + <!-- + Bug 1231179 - Storage inspector front end - tests + --> + <head> + <meta charset="utf-8" /> + <title>Storage inspector sessionStorage test</title> + <script type="application/javascript"> + "use strict"; + /* exported setup */ + function setup() { + sessionStorage.setItem("TestSS1", "ValueSS1"); + sessionStorage.setItem("TestSS2", "ValueSS2"); + sessionStorage.setItem("TestSS3", "ValueSS3"); + sessionStorage.setItem("TestSS4", "ValueSS4"); + sessionStorage.setItem("TestSS5", "ValueSS5"); + } + </script> + </head> + <body> + </body> +</html> diff --git a/devtools/client/storage/test/storage-sidebar-parsetree.html b/devtools/client/storage/test/storage-sidebar-parsetree.html new file mode 100644 index 0000000000..47f669dd18 --- /dev/null +++ b/devtools/client/storage/test/storage-sidebar-parsetree.html @@ -0,0 +1,40 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <title>Storage inspector sidebar parsetree test</title> + <script type="application/javascript"> + "use strict"; + /* exported setup */ + function setup() { + // These values should not be parsed into a tree. + localStorage.setItem("base64", "aGVsbG93b3JsZA=="); + localStorage.setItem("boolean", "true"); + localStorage.setItem("color", "#ff0034"); + localStorage.setItem("dataURI", "data:,Hello World!"); + localStorage.setItem("date", "2009-05-19 14:39:22-01"); + localStorage.setItem("email", "foo@bar.co.uk"); + localStorage.setItem("FQDN", "xn--froschgrn-x9a.co.uk"); + localStorage.setItem("IP", "192.168.1.1"); + localStorage.setItem("MacAddress", "01:AB:03:04:05:06"); + localStorage.setItem("maths", "9-1"); + localStorage.setItem("numbers", "10,123,456"); + localStorage.setItem("SemVer", "1.0.4"); + localStorage.setItem("URL", "www.google.co.uk"); + localStorage.setItem("URL2", "http://www.google.co.uk"); + + // These values should be parsed into a tree. + localStorage.setItem("ampersand", "a&b&c&d&e&f&g"); + localStorage.setItem("asterisk", "a*b*c*d*e*f*g"); + localStorage.setItem("colon", "a:b:c:d:e:f:g"); + localStorage.setItem("comma", "a,b,c,d,e,f,g"); + localStorage.setItem("equals", "a=b=c=d=e=f=g"); + localStorage.setItem("hash", "a#b#c#d#e#f#g"); + localStorage.setItem("period", "a.b.c.d.e.f.g"); + localStorage.setItem("tilde", "a~b~c~d~e~f~g"); + } + </script> + </head> + <body> + </body> +</html> diff --git a/devtools/client/storage/test/storage-unsecured-iframe-usercontextid.html b/devtools/client/storage/test/storage-unsecured-iframe-usercontextid.html new file mode 100644 index 0000000000..c40ac8a761 --- /dev/null +++ b/devtools/client/storage/test/storage-unsecured-iframe-usercontextid.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> +<html> +<!-- +Iframe for testing multiple host detetion in storage actor +--> +<head> + <meta charset="utf-8"> +</head> +<body> +<script> +"use strict"; +document.cookie = "uc1uc1=foobar; domain=.example.org; path=/"; +localStorage.setItem("iframe-u-ls1uc1", "foobar"); +sessionStorage.setItem("iframe-u-ss1uc1", "foobar1"); +sessionStorage.setItem("iframe-u-ss2uc1", "foobar2"); +dump("added cookies and storage from unsecured iframe\n"); +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-unsecured-iframe.html b/devtools/client/storage/test/storage-unsecured-iframe.html new file mode 100644 index 0000000000..ad737dae0c --- /dev/null +++ b/devtools/client/storage/test/storage-unsecured-iframe.html @@ -0,0 +1,22 @@ +<!DOCTYPE HTML> +<html> +<!-- +Iframe for testing multiple host detetion in storage actor +--> +<head> + <meta charset="utf-8"> +</head> +<body> +<script> +"use strict"; +const cookieExpiresTime = 2000000000000; +document.cookie = "uc1=foobar; domain=.example.org; path=/"; +document.cookie = "uc2=foobar-2; expires=" + + new Date(cookieExpiresTime).toGMTString() + "; path=/; domain=.example.org"; +localStorage.setItem("iframe-u-ls1", "foobar"); +sessionStorage.setItem("iframe-u-ss1", "foobar1"); +sessionStorage.setItem("iframe-u-ss2", "foobar2"); +dump("added cookies and storage from unsecured iframe\n"); +</script> +</body> +</html> diff --git a/devtools/client/storage/test/storage-updates.html b/devtools/client/storage/test/storage-updates.html new file mode 100644 index 0000000000..5fcbd10c4c --- /dev/null +++ b/devtools/client/storage/test/storage-updates.html @@ -0,0 +1,68 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 965872 - Storage inspector actor with cookies, local storage and session storage. +--> +<head> + <meta charset="utf-8"> + <title>Storage inspector blank html for tests</title> +</head> +<body> +<script type="application/javascript"> +"use strict"; +window.addCookie = function(name, value, path, domain, expires, secure) { + let cookieString = name + "=" + value + ";"; + if (path) { + cookieString += "path=" + path + ";"; + } + if (domain) { + cookieString += "domain=" + domain + ";"; + } + if (expires) { + cookieString += "expires=" + expires + ";"; + } + if (secure) { + cookieString += "secure=true;"; + } + document.cookie = cookieString; +}; + +window.removeCookie = function(name, path) { + document.cookie = + name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=" + path; +}; + +/** + * We keep this method here even though these items are automatically cleared + * after the test is complete. this is so that the store-objects-cleared event + * can be tested. + */ +window.clear = function() { + localStorage.clear(); + dump("removed localStorage from " + document.location + "\n"); + + sessionStorage.clear(); + dump("removed sessionStorage from " + document.location + "\n"); +}; + +window.onload = function() { + window.addCookie("c1", "1.2.3.4.5.6.7", "/browser"); + window.addCookie("c2", "foobar", "/browser"); + + // Some keys have to be set to strings that JSON.parse can parse successfully + // instead of throwings (to verify the issue fixed by Bug 1578447 doesn't regress). + localStorage.setItem("1", "testing"); + localStorage.setItem("2", "testing"); + localStorage.setItem("3", "testing"); + localStorage.setItem("4", "testing"); + localStorage.setItem("5", "testing"); + localStorage.setItem("null", "testing"); + localStorage.setItem("non-json-parsable", "testing"); + + sessionStorage.setItem("ss1", "foobar"); + sessionStorage.setItem("ss2", "foobar"); + sessionStorage.setItem("ss3", "foobar"); +}; +</script> +</body> +</html> diff --git a/devtools/client/storage/ui.js b/devtools/client/storage/ui.js new file mode 100644 index 0000000000..095bb4730b --- /dev/null +++ b/devtools/client/storage/ui.js @@ -0,0 +1,1754 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const { ELLIPSIS } = require("resource://devtools/shared/l10n.js"); +const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js"); +const { + parseItemValue, +} = require("resource://devtools/shared/storage/utils.js"); +const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); +const { + getUnicodeHostname, +} = require("resource://devtools/client/shared/unicode-url.js"); +const getStorageTypeURL = require("resource://devtools/client/storage/utils/doc-utils.js"); + +// GUID to be used as a separator in compound keys. This must match the same +// constant in devtools/server/actors/resources/storage/index.js, +// devtools/client/storage/test/head.js and +// devtools/server/tests/browser/head.js +const SEPARATOR_GUID = "{9d414cc5-8319-0a04-0586-c0a6ae01670a}"; + +loader.lazyRequireGetter( + this, + "TreeWidget", + "resource://devtools/client/shared/widgets/TreeWidget.js", + true +); +loader.lazyRequireGetter( + this, + "TableWidget", + "resource://devtools/client/shared/widgets/TableWidget.js", + true +); +loader.lazyRequireGetter( + this, + "debounce", + "resource://devtools/shared/debounce.js", + true +); +loader.lazyGetter(this, "standardSessionString", () => { + const l10n = new Localization(["devtools/client/storage.ftl"], true); + return l10n.formatValueSync("storage-expires-session"); +}); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + VariablesView: "resource://devtools/client/storage/VariablesView.sys.mjs", +}); + +const REASON = { + NEW_ROW: "new-row", + NEXT_50_ITEMS: "next-50-items", + POPULATE: "populate", + UPDATE: "update", +}; + +// How long we wait to debounce resize events +const LAZY_RESIZE_INTERVAL_MS = 200; + +// Maximum length of item name to show in context menu label - will be +// trimmed with ellipsis if it's longer. +const ITEM_NAME_MAX_LENGTH = 32; + +const HEADERS_L10N_IDS = { + Cache: { + status: "storage-table-headers-cache-status", + }, + cookies: { + creationTime: "storage-table-headers-cookies-creation-time", + expires: "storage-table-headers-cookies-expires", + lastAccessed: "storage-table-headers-cookies-last-accessed", + name: "storage-table-headers-cookies-name", + size: "storage-table-headers-cookies-size", + value: "storage-table-headers-cookies-value", + }, + extensionStorage: { + area: "storage-table-headers-extension-storage-area", + }, +}; + +// We only localize certain table headers. The headers that we do not localize +// along with their label are stored in this dictionary for easy reference. +const HEADERS_NON_L10N_STRINGS = { + Cache: { + url: "URL", + }, + cookies: { + host: "Domain", + hostOnly: "HostOnly", + isHttpOnly: "HttpOnly", + isSecure: "Secure", + path: "Path", + sameSite: "SameSite", + uniqueKey: "Unique key", + }, + extensionStorage: { + name: "Key", + value: "Value", + }, + indexedDB: { + autoIncrement: "Auto Increment", + db: "Database Name", + indexes: "Indexes", + keyPath: "Key Path", + name: "Key", + objectStore: "Object Store Name", + objectStores: "Object Stores", + origin: "Origin", + storage: "Storage", + uniqueKey: "Unique key", + value: "Value", + version: "Version", + }, + localStorage: { + name: "Key", + value: "Value", + }, + sessionStorage: { + name: "Key", + value: "Value", + }, +}; + +/** + * StorageUI is controls and builds the UI of the Storage Inspector. + * + * @param {Window} panelWin + * Window of the toolbox panel to populate UI in. + * @param {Object} commands + * The commands object with all interfaces defined from devtools/shared/commands/ + */ +class StorageUI { + constructor(panelWin, toolbox, commands) { + EventEmitter.decorate(this); + this._window = panelWin; + this._panelDoc = panelWin.document; + this._toolbox = toolbox; + this._commands = commands; + this.sidebarToggledOpen = null; + this.shouldLoadMoreItems = true; + + const treeNode = this._panelDoc.getElementById("storage-tree"); + this.tree = new TreeWidget(treeNode, { + defaultType: "dir", + contextMenuId: "storage-tree-popup", + }); + this.onHostSelect = this.onHostSelect.bind(this); + this.tree.on("select", this.onHostSelect); + + const tableNode = this._panelDoc.getElementById("storage-table"); + this.table = new TableWidget(tableNode, { + emptyText: "storage-table-empty-text", + highlightUpdated: true, + cellContextMenuId: "storage-table-popup", + l10n: this._panelDoc.l10n, + }); + + this.updateObjectSidebar = this.updateObjectSidebar.bind(this); + this.table.on(TableWidget.EVENTS.ROW_SELECTED, this.updateObjectSidebar); + + this.handleScrollEnd = this.loadMoreItems.bind(this); + this.table.on(TableWidget.EVENTS.SCROLL_END, this.handleScrollEnd); + + this.editItem = this.editItem.bind(this); + this.table.on(TableWidget.EVENTS.CELL_EDIT, this.editItem); + + this.sidebar = this._panelDoc.getElementById("storage-sidebar"); + this.sidebar.style.width = "300px"; + this.view = new lazy.VariablesView(this.sidebar.firstChild, { + lazyEmpty: true, + // ms + lazyEmptyDelay: 10, + searchEnabled: true, + contextMenuId: "variable-view-popup", + preventDescriptorModifiers: true, + }); + + this.filterItems = this.filterItems.bind(this); + this.onPaneToggleButtonClicked = this.onPaneToggleButtonClicked.bind(this); + this.setupToolbar(); + + this.handleKeypress = this.handleKeypress.bind(this); + this._panelDoc.addEventListener("keypress", this.handleKeypress); + + this.onTreePopupShowing = this.onTreePopupShowing.bind(this); + this._treePopup = this._panelDoc.getElementById("storage-tree-popup"); + this._treePopup.addEventListener("popupshowing", this.onTreePopupShowing); + + this.onTablePopupShowing = this.onTablePopupShowing.bind(this); + this._tablePopup = this._panelDoc.getElementById("storage-table-popup"); + this._tablePopup.addEventListener("popupshowing", this.onTablePopupShowing); + + this.onVariableViewPopupShowing = + this.onVariableViewPopupShowing.bind(this); + this._variableViewPopup = this._panelDoc.getElementById( + "variable-view-popup" + ); + this._variableViewPopup.addEventListener( + "popupshowing", + this.onVariableViewPopupShowing + ); + + this.onRefreshTable = this.onRefreshTable.bind(this); + this.onAddItem = this.onAddItem.bind(this); + this.onCopyItem = this.onCopyItem.bind(this); + this.onPanelWindowResize = debounce( + this.#onLazyPanelResize, + LAZY_RESIZE_INTERVAL_MS, + this + ); + this.onRemoveItem = this.onRemoveItem.bind(this); + this.onRemoveAllFrom = this.onRemoveAllFrom.bind(this); + this.onRemoveAll = this.onRemoveAll.bind(this); + this.onRemoveAllSessionCookies = this.onRemoveAllSessionCookies.bind(this); + this.onRemoveTreeItem = this.onRemoveTreeItem.bind(this); + + this._refreshButton = this._panelDoc.getElementById("refresh-button"); + this._refreshButton.addEventListener("click", this.onRefreshTable); + + this._addButton = this._panelDoc.getElementById("add-button"); + this._addButton.addEventListener("click", this.onAddItem); + + this._window.addEventListener("resize", this.onPanelWindowResize, true); + + this._variableViewPopupCopy = this._panelDoc.getElementById( + "variable-view-popup-copy" + ); + this._variableViewPopupCopy.addEventListener("command", this.onCopyItem); + + this._tablePopupAddItem = this._panelDoc.getElementById( + "storage-table-popup-add" + ); + this._tablePopupAddItem.addEventListener("command", this.onAddItem); + + this._tablePopupDelete = this._panelDoc.getElementById( + "storage-table-popup-delete" + ); + this._tablePopupDelete.addEventListener("command", this.onRemoveItem); + + this._tablePopupDeleteAllFrom = this._panelDoc.getElementById( + "storage-table-popup-delete-all-from" + ); + this._tablePopupDeleteAllFrom.addEventListener( + "command", + this.onRemoveAllFrom + ); + + this._tablePopupDeleteAll = this._panelDoc.getElementById( + "storage-table-popup-delete-all" + ); + this._tablePopupDeleteAll.addEventListener("command", this.onRemoveAll); + + this._tablePopupDeleteAllSessionCookies = this._panelDoc.getElementById( + "storage-table-popup-delete-all-session-cookies" + ); + this._tablePopupDeleteAllSessionCookies.addEventListener( + "command", + this.onRemoveAllSessionCookies + ); + + this._treePopupDeleteAll = this._panelDoc.getElementById( + "storage-tree-popup-delete-all" + ); + this._treePopupDeleteAll.addEventListener("command", this.onRemoveAll); + + this._treePopupDeleteAllSessionCookies = this._panelDoc.getElementById( + "storage-tree-popup-delete-all-session-cookies" + ); + this._treePopupDeleteAllSessionCookies.addEventListener( + "command", + this.onRemoveAllSessionCookies + ); + + this._treePopupDelete = this._panelDoc.getElementById( + "storage-tree-popup-delete" + ); + this._treePopupDelete.addEventListener("command", this.onRemoveTreeItem); + } + + get currentTarget() { + return this._commands.targetCommand.targetFront; + } + + async init() { + // This is a distionary of arrays, keyed by storage key + // - Keys are storage keys, available on each storage resource, via ${resource.resourceKey} + // and are typically "Cache", "cookies", "indexedDB", "localStorage", ... + // - Values are arrays of storage fronts. This isn't the deprecated global storage front (target.getFront(storage), only used by legacy listener), + // but rather the storage specific front, i.e. a storage resource. Storage resources are fronts. + this.storageResources = {}; + + await this._initL10NStringsMap(); + + // This can only be done after l10n strings were retrieved as we're using "storage-filter-key" + const shortcuts = new KeyShortcuts({ + window: this._panelDoc.defaultView, + }); + const key = this._l10nStrings.get("storage-filter-key"); + shortcuts.on(key, event => { + event.preventDefault(); + this.searchBox.focus(); + }); + + this._onTargetAvailable = this._onTargetAvailable.bind(this); + this._onTargetDestroyed = this._onTargetDestroyed.bind(this); + await this._commands.targetCommand.watchTargets({ + types: [this._commands.targetCommand.TYPES.FRAME], + onAvailable: this._onTargetAvailable, + onDestroyed: this._onTargetDestroyed, + }); + + this._onResourceListAvailable = this._onResourceListAvailable.bind(this); + + const { resourceCommand } = this._toolbox; + + this._listenedResourceTypes = [ + // The first item in this list will be the first selected storage item + // Tests assume Cookie -- moving cookie will break tests + resourceCommand.TYPES.COOKIE, + resourceCommand.TYPES.CACHE_STORAGE, + resourceCommand.TYPES.INDEXED_DB, + resourceCommand.TYPES.LOCAL_STORAGE, + resourceCommand.TYPES.SESSION_STORAGE, + ]; + // EXTENSION_STORAGE is only relevant when debugging web extensions + if (this._commands.descriptorFront.isWebExtensionDescriptor) { + this._listenedResourceTypes.push(resourceCommand.TYPES.EXTENSION_STORAGE); + } + await this._toolbox.resourceCommand.watchResources( + this._listenedResourceTypes, + { + onAvailable: this._onResourceListAvailable, + } + ); + } + + async _initL10NStringsMap() { + const ids = [ + "storage-filter-key", + "storage-table-headers-cookies-name", + "storage-table-headers-cookies-value", + "storage-table-headers-cookies-expires", + "storage-table-headers-cookies-size", + "storage-table-headers-cookies-last-accessed", + "storage-table-headers-cookies-creation-time", + "storage-table-headers-cache-status", + "storage-table-headers-extension-storage-area", + "storage-tree-labels-cookies", + "storage-tree-labels-local-storage", + "storage-tree-labels-session-storage", + "storage-tree-labels-indexed-db", + "storage-tree-labels-cache", + "storage-tree-labels-extension-storage", + "storage-expires-session", + ]; + const results = await this._panelDoc.l10n.formatValues( + ids.map(s => ({ id: s })) + ); + + this._l10nStrings = new Map(ids.map((id, i) => [id, results[i]])); + } + + async _onResourceListAvailable(resources) { + for (const resource of resources) { + if (resource.isDestroyed()) { + continue; + } + const { resourceKey } = resource; + + // NOTE: We might be getting more than 1 resource per storage type when + // we have remote frames in content process resources, so we need + // an array to store these. + if (!this.storageResources[resourceKey]) { + this.storageResources[resourceKey] = []; + } + this.storageResources[resourceKey].push(resource); + + resource.on( + "single-store-update", + this._onStoreUpdate.bind(this, resource) + ); + resource.on( + "single-store-cleared", + this._onStoreCleared.bind(this, resource) + ); + } + + try { + await this.populateStorageTree(); + } catch (e) { + if (!this._toolbox || this._toolbox._destroyer) { + // The toolbox is in the process of being destroyed... in this case throwing here + // is expected and normal so let's ignore the error. + return; + } + + // The toolbox is open so the error is unexpected and real so let's log it. + console.error(e); + } + } + + // We only need to listen to target destruction, but TargetCommand.watchTarget + // requires a target available function... + async _onTargetAvailable({ targetFront }) {} + + _onTargetDestroyed({ targetFront }) { + // Remove all storages related to this target + for (const type in this.storageResources) { + this.storageResources[type] = this.storageResources[type].filter( + storage => { + // Note that the storage front may already be destroyed, + // and have a null targetFront attribute. So also remove all already + // destroyed fronts. + return !storage.isDestroyed() && storage.targetFront != targetFront; + } + ); + } + + // Only support top level target and navigation to new processes. + // i.e. ignore additional targets created for remote <iframes> + if (!targetFront.isTopLevel) { + return; + } + + this.storageResources = {}; + this.table.clear(); + this.hideSidebar(); + this.tree.clear(); + } + + set animationsEnabled(value) { + this._panelDoc.documentElement.classList.toggle("no-animate", !value); + } + + destroy() { + if (this._destroyed) { + return; + } + this._destroyed = true; + + const { resourceCommand } = this._toolbox; + resourceCommand.unwatchResources(this._listenedResourceTypes, { + onAvailable: this._onResourceListAvailable, + }); + + this.table.off(TableWidget.EVENTS.ROW_SELECTED, this.updateObjectSidebar); + this.table.off(TableWidget.EVENTS.SCROLL_END, this.loadMoreItems); + this.table.off(TableWidget.EVENTS.CELL_EDIT, this.editItem); + this.table.destroy(); + + this._panelDoc.removeEventListener("keypress", this.handleKeypress); + this.searchBox.removeEventListener("input", this.filterItems); + this.searchBox = null; + + this.sidebarToggleBtn.removeEventListener( + "click", + this.onPaneToggleButtonClicked + ); + this.sidebarToggleBtn = null; + + this._window.removeEventListener("resize", this.#onLazyPanelResize, true); + + this._treePopup.removeEventListener( + "popupshowing", + this.onTreePopupShowing + ); + this._refreshButton.removeEventListener("click", this.onRefreshTable); + this._addButton.removeEventListener("click", this.onAddItem); + this._tablePopupAddItem.removeEventListener("command", this.onAddItem); + this._treePopupDeleteAll.removeEventListener("command", this.onRemoveAll); + this._treePopupDeleteAllSessionCookies.removeEventListener( + "command", + this.onRemoveAllSessionCookies + ); + this._treePopupDelete.removeEventListener("command", this.onRemoveTreeItem); + + this._tablePopup.removeEventListener( + "popupshowing", + this.onTablePopupShowing + ); + this._tablePopupDelete.removeEventListener("command", this.onRemoveItem); + this._tablePopupDeleteAllFrom.removeEventListener( + "command", + this.onRemoveAllFrom + ); + this._tablePopupDeleteAll.removeEventListener("command", this.onRemoveAll); + this._tablePopupDeleteAllSessionCookies.removeEventListener( + "command", + this.onRemoveAllSessionCookies + ); + } + + setupToolbar() { + this.searchBox = this._panelDoc.getElementById("storage-searchbox"); + this.searchBox.addEventListener("input", this.filterItems); + + // Setup the sidebar toggle button. + this.sidebarToggleBtn = this._panelDoc.querySelector(".sidebar-toggle"); + this.updateSidebarToggleButton(); + + this.sidebarToggleBtn.addEventListener( + "click", + this.onPaneToggleButtonClicked + ); + } + + onPaneToggleButtonClicked() { + if (this.sidebar.hidden && this.table.selectedRow) { + this.sidebar.hidden = false; + this.sidebarToggledOpen = true; + this.updateSidebarToggleButton(); + } else { + this.sidebarToggledOpen = false; + this.hideSidebar(); + } + } + + updateSidebarToggleButton() { + let dataL10nId; + this.sidebarToggleBtn.hidden = !this.table.hasSelectedRow; + + if (this.sidebar.hidden) { + this.sidebarToggleBtn.classList.add("pane-collapsed"); + dataL10nId = "storage-expand-pane"; + } else { + this.sidebarToggleBtn.classList.remove("pane-collapsed"); + dataL10nId = "storage-collapse-pane"; + } + + this._panelDoc.l10n.setAttributes(this.sidebarToggleBtn, dataL10nId); + } + + /** + * Hide the object viewer sidebar + */ + hideSidebar() { + this.sidebar.hidden = true; + this.updateSidebarToggleButton(); + } + + getCurrentFront() { + const { datatype, host } = this.table; + return this._getStorage(datatype, host); + } + + _getStorage(type, host) { + const storageType = this.storageResources[type]; + return storageType.find(x => host in x.hosts); + } + + /** + * Make column fields editable + * + * @param {Array} editableFields + * An array of keys of columns to be made editable + */ + makeFieldsEditable(editableFields) { + if (editableFields && editableFields.length) { + this.table.makeFieldsEditable(editableFields); + } else if (this.table._editableFieldsEngine) { + this.table._editableFieldsEngine.destroy(); + } + } + + editItem(data) { + const selectedItem = this.tree.selectedItem; + if (!selectedItem) { + return; + } + const front = this.getCurrentFront(); + + front.editItem(data); + } + + /** + * Removes the given item from the storage table. Reselects the next item in + * the table and repopulates the sidebar with that item's data if the item + * being removed was selected. + */ + async removeItemFromTable(name) { + if (this.table.isSelected(name) && this.table.items.size > 1) { + if (this.table.selectedIndex == 0) { + this.table.selectNextRow(); + } else { + this.table.selectPreviousRow(); + } + } + + this.table.remove(name); + await this.updateObjectSidebar(); + } + + /** + * Event handler for "stores-cleared" event coming from the storage actor. + * + * @param {object} + * An object containing which hosts/paths are cleared from a + * storage + */ + _onStoreCleared(resource, { clearedHostsOrPaths }) { + const { resourceKey } = resource; + function* enumPaths() { + if (Array.isArray(clearedHostsOrPaths)) { + // Handle the legacy response with array of hosts + for (const host of clearedHostsOrPaths) { + yield [host]; + } + } else { + // Handle the new format that supports clearing sub-stores in a host + for (const host in clearedHostsOrPaths) { + const paths = clearedHostsOrPaths[host]; + + if (!paths.length) { + yield [host]; + } else { + for (let path of paths) { + try { + path = JSON.parse(path); + yield [host, ...path]; + } catch (ex) { + // ignore + } + } + } + } + } + } + + for (const path of enumPaths()) { + // Find if the path is selected (there is max one) and clear it + if (this.tree.isSelected([resourceKey, ...path])) { + this.table.clear(); + this.hideSidebar(); + + // Reset itemOffset to 0 so that items added after local storate is + // cleared will be shown + this.itemOffset = 0; + + this.emit("store-objects-cleared"); + break; + } + } + } + + /** + * Event handler for "stores-update" event coming from the storage actor. + * + * @param {object} argument0 + * An object containing the details of the added, changed and deleted + * storage objects. + * Each of these 3 objects are of the following format: + * { + * <store_type1>: { + * <host1>: [<store_names1>, <store_name2>...], + * <host2>: [<store_names34>...], ... + * }, + * <store_type2>: { + * <host1>: [<store_names1>, <store_name2>...], + * <host2>: [<store_names34>...], ... + * }, ... + * } + * Where store_type1 and store_type2 is one of cookies, indexedDB, + * sessionStorage and localStorage; host1, host2 are the host in which + * this change happened; and [<store_namesX] is an array of the names + * of the changed store objects. This array is empty for deleted object + * if the host was completely removed. + */ + async _onStoreUpdate(resource, update) { + const { changed, added, deleted } = update; + if (added) { + await this.handleAddedItems(added); + } + + if (changed) { + await this.handleChangedItems(changed); + } + + // We are dealing with batches of changes here. Deleted **MUST** come last in case it + // is in the same batch as added and changed events e.g. + // - An item is changed then deleted in the same batch: deleted then changed will + // display an item that has been deleted. + // - An item is added then deleted in the same batch: deleted then added will + // display an item that has been deleted. + if (deleted) { + await this.handleDeletedItems(deleted); + } + + if (added || deleted || changed) { + this.emit("store-objects-edit"); + } + } + + /** + * If the panel is resized we need to check if we should load the next batch of + * storage items. + */ + async #onLazyPanelResize() { + // We can be called on a closed window or destroyed toolbox because of the + // deferred task. + if (this._window.closed || this._destroyed || this.table.hasScrollbar) { + return; + } + + await this.loadMoreItems(); + this.emit("storage-resize"); + } + + /** + * Get a string for a column name automatically choosing whether or not the + * string should be localized. + * + * @param {String} type + * The storage type. + * @param {String} name + * The field name that may need to be localized. + */ + _getColumnName(type, name) { + // If the ID exists in HEADERS_NON_L10N_STRINGS then we do not translate it + const columnName = HEADERS_NON_L10N_STRINGS[type]?.[name]; + if (columnName) { + return columnName; + } + + // otherwise we get it from the L10N Map (populated during init) + const l10nId = HEADERS_L10N_IDS[type]?.[name]; + if (l10nId && this._l10nStrings.has(l10nId)) { + return this._l10nStrings.get(l10nId); + } + + // If the string isn't localized, we will just use the field name. + return name; + } + + /** + * Handle added items received by onEdit + * + * @param {object} See onEdit docs + */ + async handleAddedItems(added) { + for (const type in added) { + for (const host in added[type]) { + const label = this.getReadableLabelFromHostname(host); + this.tree.add([type, { id: host, label, type: "url" }]); + for (let name of added[type][host]) { + try { + name = JSON.parse(name); + if (name.length == 3) { + name.splice(2, 1); + } + this.tree.add([type, host, ...name]); + if (!this.tree.selectedItem) { + this.tree.selectedItem = [type, host, name[0], name[1]]; + await this.fetchStorageObjects( + type, + host, + [JSON.stringify(name)], + REASON.NEW_ROW + ); + } + } catch (ex) { + // Do nothing + } + } + + if (this.tree.isSelected([type, host])) { + await this.fetchStorageObjects( + type, + host, + added[type][host], + REASON.NEW_ROW + ); + } + } + } + } + + /** + * Handle deleted items received by onEdit + * + * @param {object} See onEdit docs + */ + async handleDeletedItems(deleted) { + for (const type in deleted) { + for (const host in deleted[type]) { + if (!deleted[type][host].length) { + // This means that the whole host is deleted, thus the item should + // be removed from the storage tree + if (this.tree.isSelected([type, host])) { + this.table.clear(); + this.hideSidebar(); + this.tree.selectPreviousItem(); + } + + this.tree.remove([type, host]); + } else { + for (const name of deleted[type][host]) { + try { + if (["indexedDB", "Cache"].includes(type)) { + // For indexedDB and Cache, the key is being parsed because + // these storages are represented as a tree and the key + // used to notify their changes is not a simple string. + const names = JSON.parse(name); + // Is a whole cache, database or objectstore deleted? + // Then remove it from the tree. + if (names.length < 3) { + if (this.tree.isSelected([type, host, ...names])) { + this.table.clear(); + this.hideSidebar(); + this.tree.selectPreviousItem(); + } + this.tree.remove([type, host, ...names]); + } + + // Remove the item from table if currently displayed. + if (names.length) { + const tableItemName = names.pop(); + if (this.tree.isSelected([type, host, ...names])) { + await this.removeItemFromTable(tableItemName); + } + } + } else if (this.tree.isSelected([type, host])) { + // For all the other storage types with a simple string key, + // remove the item from the table by name without any parsing. + await this.removeItemFromTable(name); + } + } catch (ex) { + if (this.tree.isSelected([type, host])) { + await this.removeItemFromTable(name); + } + } + } + } + } + } + } + + /** + * Handle changed items received by onEdit + * + * @param {object} See onEdit docs + */ + async handleChangedItems(changed) { + const selectedItem = this.tree.selectedItem; + if (!selectedItem) { + return; + } + + const [type, host, db, objectStore] = selectedItem; + if (!changed[type] || !changed[type][host] || !changed[type][host].length) { + return; + } + try { + const toUpdate = []; + for (const name of changed[type][host]) { + if (["indexedDB", "Cache"].includes(type)) { + // For indexedDB and Cache, the key is being parsed because + // these storage are represented as a tree and the key + // used to notify their changes is not a simple string. + const names = JSON.parse(name); + if (names[0] == db && names[1] == objectStore && names[2]) { + toUpdate.push(name); + } + } else { + // For all the other storage types with a simple string key, + // update the item from the table by name without any parsing. + toUpdate.push(name); + } + } + await this.fetchStorageObjects(type, host, toUpdate, REASON.UPDATE); + } catch (ex) { + await this.fetchStorageObjects( + type, + host, + changed[type][host], + REASON.UPDATE + ); + } + } + + /** + * Fetches the storage objects from the storage actor and populates the + * storage table with the returned data. + * + * @param {string} type + * The type of storage. Ex. "cookies" + * @param {string} host + * Hostname + * @param {array} names + * Names of particular store objects. Empty if all are requested + * @param {Constant} reason + * See REASON constant at top of file. + */ + async fetchStorageObjects(type, host, names, reason) { + const fetchOpts = + reason === REASON.NEXT_50_ITEMS ? { offset: this.itemOffset } : {}; + fetchOpts.sessionString = standardSessionString; + const storage = this._getStorage(type, host); + this.sidebarToggledOpen = null; + + if ( + reason !== REASON.NEXT_50_ITEMS && + reason !== REASON.UPDATE && + reason !== REASON.NEW_ROW && + reason !== REASON.POPULATE + ) { + throw new Error("Invalid reason specified"); + } + + try { + if ( + reason === REASON.POPULATE || + (reason === REASON.NEW_ROW && this.table.items.size === 0) + ) { + let subType = null; + // The indexedDB type could have sub-type data to fetch. + // If having names specified, then it means + // we are fetching details of specific database or of object store. + if (type === "indexedDB" && names) { + const [dbName, objectStoreName] = JSON.parse(names[0]); + if (dbName) { + subType = "database"; + } + if (objectStoreName) { + subType = "object store"; + } + } + + await this.resetColumns(type, host, subType); + } + + const { data, total } = await storage.getStoreObjects( + host, + names, + fetchOpts + ); + if (data.length) { + await this.populateTable(data, reason, total); + } else if (reason === REASON.POPULATE) { + await this.clearHeaders(); + } + this.updateToolbar(); + this.emit("store-objects-updated"); + } catch (ex) { + console.error(ex); + } + } + + supportsAddItem(type, host) { + const storage = this._getStorage(type, host); + return storage?.traits.supportsAddItem || false; + } + + supportsRemoveItem(type, host) { + const storage = this._getStorage(type, host); + return storage?.traits.supportsRemoveItem || false; + } + + supportsRemoveAll(type, host) { + const storage = this._getStorage(type, host); + return storage?.traits.supportsRemoveAll || false; + } + + supportsRemoveAllSessionCookies(type, host) { + const storage = this._getStorage(type, host); + return storage?.traits.supportsRemoveAllSessionCookies || false; + } + + /** + * Updates the toolbar hiding and showing buttons as appropriate. + */ + updateToolbar() { + const item = this.tree.selectedItem; + if (!item) { + return; + } + + const [type, host] = item; + + // Add is only supported if the selected item has a host. + this._addButton.hidden = !host || !this.supportsAddItem(type, host); + } + + /** + * Populates the storage tree which displays the list of storages present for + * the page. + */ + async populateStorageTree() { + const populateTreeFromResource = (type, resource) => { + for (const host in resource.hosts) { + const label = this.getReadableLabelFromHostname(host); + this.tree.add([type, { id: host, label, type: "url" }]); + for (const name of resource.hosts[host]) { + try { + const names = JSON.parse(name); + this.tree.add([type, host, ...names]); + if (!this.tree.selectedItem) { + this.tree.selectedItem = [type, host, names[0], names[1]]; + } + } catch (ex) { + // Do Nothing + } + } + if (!this.tree.selectedItem) { + this.tree.selectedItem = [type, host]; + } + } + }; + + // When can we expect the "store-objects-updated" event? + // -> TreeWidget setter `selectedItem` emits a "select" event + // -> on tree "select" event, this module calls `onHostSelect` + // -> finally `onHostSelect` calls `fetchStorageObjects`, which will emit + // "store-objects-updated" at the end of the method. + // So if the selection changed, we can wait for "store-objects-updated", + // which is emitted at the end of `fetchStorageObjects`. + const onStoresObjectsUpdated = this.once("store-objects-updated"); + + // Save the initially selected item to check if tree.selected was updated, + // see comment above. + const initialSelectedItem = this.tree.selectedItem; + + for (const [type, resources] of Object.entries(this.storageResources)) { + let typeLabel = type; + try { + typeLabel = this.getStorageTypeLabel(type); + } catch (e) { + console.error("Unable to localize tree label type:" + type); + } + + this.tree.add([{ id: type, label: typeLabel, type: "store" }]); + + // storageResources values are arrays, with storage resources. + // we may have many storage resources per type if we get remote iframes. + for (const resource of resources) { + populateTreeFromResource(type, resource); + } + } + + if (initialSelectedItem !== this.tree.selectedItem) { + await onStoresObjectsUpdated; + } + } + + getStorageTypeLabel(type) { + let dataL10nId; + + switch (type) { + case "cookies": + dataL10nId = "storage-tree-labels-cookies"; + break; + case "localStorage": + dataL10nId = "storage-tree-labels-local-storage"; + break; + case "sessionStorage": + dataL10nId = "storage-tree-labels-session-storage"; + break; + case "indexedDB": + dataL10nId = "storage-tree-labels-indexed-db"; + break; + case "Cache": + dataL10nId = "storage-tree-labels-cache"; + break; + case "extensionStorage": + dataL10nId = "storage-tree-labels-extension-storage"; + break; + default: + throw new Error("Unknown storage type"); + } + + return this._l10nStrings.get(dataL10nId); + } + + /** + * Populates the selected entry from the table in the sidebar for a more + * detailed view. + */ + /* eslint-disable-next-line */ + async updateObjectSidebar() { + const item = this.table.selectedRow; + let value; + + // Get the string value (async action) and the update the UI synchronously. + if ((item?.name || item?.name === "") && item?.valueActor) { + value = await item.valueActor.string(); + } + + // Bail if the selectedRow is no longer selected, the item doesn't exist or the state + // changed in another way during the above yield. + if ( + this.table.items.size === 0 || + !item || + !this.table.selectedRow || + item.uniqueKey !== this.table.selectedRow.uniqueKey + ) { + this.hideSidebar(); + return; + } + + // Start updating the UI. Everything is sync beyond this point. + if (this.sidebarToggledOpen === null || this.sidebarToggledOpen === true) { + this.sidebar.hidden = false; + } + + this.updateSidebarToggleButton(); + this.view.empty(); + const mainScope = this.view.addScope("storage-data"); + mainScope.expanded = true; + + if (value) { + const itemVar = mainScope.addItem(item.name + "", {}, { relaxed: true }); + + // The main area where the value will be displayed + itemVar.setGrip(value); + + // May be the item value is a json or a key value pair itself + const obj = parseItemValue(value); + if (typeof obj === "object") { + this.populateSidebar(item.name, obj); + } + + // By default the item name and value are shown. If this is the only + // information available, then nothing else is to be displayed. + const itemProps = Object.keys(item); + if (itemProps.length > 3) { + // Display any other information other than the item name and value + // which may be available. + const rawObject = Object.create(null); + const otherProps = itemProps.filter( + e => !["name", "value", "valueActor"].includes(e) + ); + for (const prop of otherProps) { + const column = this.table.columns.get(prop); + if (column?.private) { + continue; + } + + const fieldName = this._getColumnName(this.table.datatype, prop); + rawObject[fieldName] = item[prop]; + } + itemVar.populate(rawObject, { sorted: true }); + itemVar.twisty = true; + itemVar.expanded = true; + } + } else { + // Case when displaying IndexedDB db/object store properties. + for (const key in item) { + const column = this.table.columns.get(key); + if (column?.private) { + continue; + } + + mainScope.addItem(key, {}, true).setGrip(item[key]); + const obj = parseItemValue(item[key]); + if (typeof obj === "object") { + this.populateSidebar(item.name, obj); + } + } + } + + this.emit("sidebar-updated"); + } + + /** + * Gets a readable label from the hostname. If the hostname is a Punycode + * domain(I.e. an ASCII domain name representing a Unicode domain name), then + * this function decodes it to the readable Unicode domain name, and label + * the Unicode domain name toggether with the original domian name, and then + * return the label; if the hostname isn't a Punycode domain(I.e. it isn't + * encoded and is readable on its own), then this function simply returns the + * original hostname. + * + * @param {string} host + * The string representing a host, e.g, example.com, example.com:8000 + */ + getReadableLabelFromHostname(host) { + try { + const { hostname } = new URL(host); + const unicodeHostname = getUnicodeHostname(hostname); + if (hostname !== unicodeHostname) { + // If the hostname is a Punycode domain representing a Unicode domain, + // we decode it to the Unicode domain name, and then label the Unicode + // domain name together with the original domain name. + return host.replace(hostname, unicodeHostname) + " [ " + host + " ]"; + } + } catch (_) { + // Skip decoding for a host which doesn't include a domain name, simply + // consider them to be readable. + } + return host; + } + + /** + * Populates the sidebar with a parsed object. + * + * @param {object} obj - Either a json or a key-value separated object or a + * key separated array + */ + populateSidebar(name, obj) { + const jsonObject = Object.create(null); + const view = this.view; + jsonObject[name] = obj; + const valueScope = + view.getScopeAtIndex(1) || view.addScope("storage-parsed-value"); + valueScope.expanded = true; + const jsonVar = valueScope.addItem("", Object.create(null), { + relaxed: true, + }); + jsonVar.expanded = true; + jsonVar.twisty = true; + jsonVar.populate(jsonObject, { expanded: true }); + } + + /** + * Select handler for the storage tree. Fetches details of the selected item + * from the storage details and populates the storage tree. + * + * @param {array} item + * An array of ids which represent the location of the selected item in + * the storage tree + */ + async onHostSelect(item) { + if (!item) { + return; + } + + this.table.clear(); + this.hideSidebar(); + this.searchBox.value = ""; + + const [type, host] = item; + this.table.host = host; + this.table.datatype = type; + + this.updateToolbar(); + + let names = null; + if (!host) { + let storageTypeHintL10nId = ""; + switch (type) { + case "Cache": + storageTypeHintL10nId = "storage-table-type-cache-hint"; + break; + case "cookies": + storageTypeHintL10nId = "storage-table-type-cookies-hint"; + break; + case "extensionStorage": + storageTypeHintL10nId = "storage-table-type-extensionstorage-hint"; + break; + case "localStorage": + storageTypeHintL10nId = "storage-table-type-localstorage-hint"; + break; + case "indexedDB": + storageTypeHintL10nId = "storage-table-type-indexeddb-hint"; + break; + case "sessionStorage": + storageTypeHintL10nId = "storage-table-type-sessionstorage-hint"; + break; + } + this.table.setPlaceholder( + storageTypeHintL10nId, + getStorageTypeURL(this.table.datatype) + ); + + // If selected item has no host then reset table headers + await this.clearHeaders(); + return; + } + if (item.length > 2) { + names = [JSON.stringify(item.slice(2))]; + } + + this.itemOffset = 0; + await this.fetchStorageObjects(type, host, names, REASON.POPULATE); + } + + /** + * Clear the column headers in the storage table + */ + async clearHeaders() { + this.table.setColumns({}, null, {}, {}); + } + + /** + * Resets the column headers in the storage table with the pased object `data` + * + * @param {string} type + * The type of storage corresponding to the after-reset columns in the + * table. + * @param {string} host + * The host name corresponding to the table after reset. + * + * @param {string} [subType] + * The sub type under the given type. + */ + async resetColumns(type, host, subtype) { + this.table.host = host; + this.table.datatype = type; + + let uniqueKey = null; + const columns = {}; + const editableFields = []; + const hiddenFields = []; + const privateFields = []; + const fields = await this.getCurrentFront().getFields(subtype); + + fields.forEach(f => { + if (!uniqueKey) { + this.table.uniqueId = uniqueKey = f.name; + } + + if (f.editable) { + editableFields.push(f.name); + } + + if (f.hidden) { + hiddenFields.push(f.name); + } + + if (f.private) { + privateFields.push(f.name); + } + + const columnName = this._getColumnName(type, f.name); + if (columnName) { + columns[f.name] = columnName; + } else if (!f.private) { + // Private fields are only displayed when running tests so there is no + // need to log an error if they are not localized. + columns[f.name] = f.name; + console.error( + `No string defined in HEADERS_NON_L10N_STRINGS for '${type}.${f.name}'` + ); + } + }); + + this.table.setColumns(columns, null, hiddenFields, privateFields); + this.hideSidebar(); + + this.makeFieldsEditable(editableFields); + } + + /** + * Populates or updates the rows in the storage table. + * + * @param {array[object]} data + * Array of objects to be populated in the storage table + * @param {Constant} reason + * See REASON constant at top of file. + * @param {number} totalAvailable + * The total number of items available in the current storage type. + */ + async populateTable(data, reason, totalAvailable) { + for (const item of data) { + if (item.value) { + item.valueActor = item.value; + item.value = item.value.initial || ""; + } + if (item.expires != null) { + item.expires = item.expires + ? new Date(item.expires).toUTCString() + : this._l10nStrings.get("storage-expires-session"); + } + if (item.creationTime != null) { + item.creationTime = new Date(item.creationTime).toUTCString(); + } + if (item.lastAccessed != null) { + item.lastAccessed = new Date(item.lastAccessed).toUTCString(); + } + + switch (reason) { + case REASON.POPULATE: + case REASON.NEXT_50_ITEMS: + // Update without flashing the row. + this.table.push(item, true); + break; + case REASON.NEW_ROW: + // Update and flash the row. + this.table.push(item, false); + break; + case REASON.UPDATE: + this.table.update(item); + if (item == this.table.selectedRow && !this.sidebar.hidden) { + await this.updateObjectSidebar(); + } + break; + } + + this.shouldLoadMoreItems = true; + } + + if ( + (reason === REASON.POPULATE || reason === REASON.NEXT_50_ITEMS) && + this.table.items.size < totalAvailable && + !this.table.hasScrollbar + ) { + await this.loadMoreItems(); + } + } + + /** + * Handles keypress event on the body table to close the sidebar when open + * + * @param {DOMEvent} event + * The event passed by the keypress event. + */ + handleKeypress(event) { + if (event.keyCode == KeyCodes.DOM_VK_ESCAPE) { + if (!this.sidebar.hidden) { + this.hideSidebar(); + this.sidebarToggledOpen = false; + // Stop Propagation to prevent opening up of split console + event.stopPropagation(); + event.preventDefault(); + } + } else if ( + event.keyCode == KeyCodes.DOM_VK_BACK_SPACE || + event.keyCode == KeyCodes.DOM_VK_DELETE + ) { + if (this.table.selectedRow && event.target.localName != "input") { + this.onRemoveItem(event); + event.stopPropagation(); + event.preventDefault(); + } + } + } + + /** + * Handles filtering the table + */ + filterItems() { + const value = this.searchBox.value; + this.table.filterItems(value, ["valueActor"]); + this._panelDoc.documentElement.classList.toggle("filtering", !!value); + } + + /** + * Load the next batch of 50 items + */ + async loadMoreItems() { + if ( + !this.shouldLoadMoreItems || + this._toolbox.currentToolId !== "storage" || + !this.tree.selectedItem + ) { + return; + } + this.shouldLoadMoreItems = false; + this.itemOffset += 50; + + const item = this.tree.selectedItem; + const [type, host] = item; + let names = null; + if (item.length > 2) { + names = [JSON.stringify(item.slice(2))]; + } + await this.fetchStorageObjects(type, host, names, REASON.NEXT_50_ITEMS); + } + + /** + * Fires before a cell context menu with the "Add" or "Delete" action is + * shown. If the currently selected storage object doesn't support adding or + * removing items, prevent showing the menu. + */ + onTablePopupShowing(event) { + const selectedItem = this.tree.selectedItem; + const [type, host] = selectedItem; + + // IndexedDB only supports removing items from object stores (level 4 of the tree) + if ( + (!this.supportsAddItem(type, host) && + !this.supportsRemoveItem(type, host)) || + (type === "indexedDB" && selectedItem.length !== 4) + ) { + event.preventDefault(); + return; + } + + const rowId = this.table.contextMenuRowId; + const data = this.table.items.get(rowId); + + if (this.supportsRemoveItem(type, host)) { + const name = data[this.table.uniqueId]; + const separatorRegex = new RegExp(SEPARATOR_GUID, "g"); + const label = addEllipsis((name + "").replace(separatorRegex, "-")); + + this._panelDoc.l10n.setArgs(this._tablePopupDelete, { itemName: label }); + this._tablePopupDelete.hidden = false; + } else { + this._tablePopupDelete.hidden = true; + } + + this._tablePopupAddItem.hidden = !this.supportsAddItem(type, host); + + let showDeleteAllSessionCookies = false; + if (this.supportsRemoveAllSessionCookies(type, host)) { + if (selectedItem.length === 2) { + showDeleteAllSessionCookies = true; + } + } + + this._tablePopupDeleteAllSessionCookies.hidden = + !showDeleteAllSessionCookies; + + if (type === "cookies") { + const hostString = addEllipsis(data.host); + + this._panelDoc.l10n.setArgs(this._tablePopupDeleteAllFrom, { + host: hostString, + }); + this._tablePopupDeleteAllFrom.hidden = false; + } else { + this._tablePopupDeleteAllFrom.hidden = true; + } + } + + onTreePopupShowing(event) { + let showMenu = false; + const selectedItem = this.tree.selectedItem; + + if (selectedItem) { + const [type, host] = selectedItem; + + // The delete all (aka clear) action is displayed for IndexedDB object stores + // (level 4 of tree), for Cache objects (level 3) and for the whole host (level 2) + // for other storage types (cookies, localStorage, ...). + let showDeleteAll = false; + if (this.supportsRemoveAll(type, host)) { + let level; + if (type == "indexedDB") { + level = 4; + } else if (type == "Cache") { + level = 3; + } else { + level = 2; + } + + if (selectedItem.length == level) { + showDeleteAll = true; + } + } + + this._treePopupDeleteAll.hidden = !showDeleteAll; + + // The delete all session cookies action is displayed for cookie object stores + // (level 2 of tree) + let showDeleteAllSessionCookies = false; + if (this.supportsRemoveAllSessionCookies(type, host)) { + if (type === "cookies" && selectedItem.length === 2) { + showDeleteAllSessionCookies = true; + } + } + + this._treePopupDeleteAllSessionCookies.hidden = + !showDeleteAllSessionCookies; + + // The delete action is displayed for: + // - IndexedDB databases (level 3 of the tree) + // - Cache objects (level 3 of the tree) + const showDelete = + (type == "indexedDB" || type == "Cache") && selectedItem.length == 3; + this._treePopupDelete.hidden = !showDelete; + if (showDelete) { + const itemName = addEllipsis(selectedItem[selectedItem.length - 1]); + this._panelDoc.l10n.setArgs(this._treePopupDelete, { itemName }); + } + + showMenu = showDeleteAll || showDelete; + } + + if (!showMenu) { + event.preventDefault(); + } + } + + onVariableViewPopupShowing(event) { + const item = this.view.getFocusedItem(); + this._variableViewPopupCopy.setAttribute("disabled", !item); + } + + /** + * Handles refreshing the selected storage + */ + async onRefreshTable() { + await this.onHostSelect(this.tree.selectedItem); + } + + /** + * Handles adding an item from the storage + */ + onAddItem() { + const selectedItem = this.tree.selectedItem; + if (!selectedItem) { + return; + } + + const front = this.getCurrentFront(); + const [, host] = selectedItem; + + // Prepare to scroll into view. + this.table.scrollIntoViewOnUpdate = true; + this.table.editBookmark = createGUID(); + front.addItem(this.table.editBookmark, host); + } + + /** + * Handles copy an item from the storage + */ + onCopyItem() { + this.view._copyItem(); + } + + /** + * Handles removing an item from the storage + * + * @param {DOMEvent} event + * The event passed by the command or keypress event. + */ + onRemoveItem(event) { + const [, host, ...path] = this.tree.selectedItem; + const front = this.getCurrentFront(); + const uniqueId = this.table.uniqueId; + const rowId = + event.type == "command" + ? this.table.contextMenuRowId + : this.table.selectedRow[uniqueId]; + const data = this.table.items.get(rowId); + + let name = data[uniqueId]; + if (path.length) { + name = JSON.stringify([...path, name]); + } + front.removeItem(host, name); + + return false; + } + + /** + * Handles removing all items from the storage + */ + onRemoveAll() { + // Cannot use this.currentActor() if the handler is called from the + // tree context menu: it returns correct value only after the table + // data from server are successfully fetched (and that's async). + const [, host, ...path] = this.tree.selectedItem; + const front = this.getCurrentFront(); + const name = path.length ? JSON.stringify(path) : undefined; + front.removeAll(host, name); + } + + /** + * Handles removing all session cookies from the storage + */ + onRemoveAllSessionCookies() { + // Cannot use this.currentActor() if the handler is called from the + // tree context menu: it returns the correct value only after the + // table data from server is successfully fetched (and that's async). + const [, host, ...path] = this.tree.selectedItem; + const front = this.getCurrentFront(); + const name = path.length ? JSON.stringify(path) : undefined; + front.removeAllSessionCookies(host, name); + } + + /** + * Handles removing all cookies with exactly the same domain as the + * cookie in the selected row. + */ + onRemoveAllFrom() { + const [, host] = this.tree.selectedItem; + const front = this.getCurrentFront(); + const rowId = this.table.contextMenuRowId; + const data = this.table.items.get(rowId); + + front.removeAll(host, data.host); + } + + onRemoveTreeItem() { + const [type, host, ...path] = this.tree.selectedItem; + + if (type == "indexedDB" && path.length == 1) { + this.removeDatabase(host, path[0]); + } else if (type == "Cache" && path.length == 1) { + this.removeCache(host, path[0]); + } + } + + async removeDatabase(host, dbName) { + const front = this.getCurrentFront(); + + try { + const result = await front.removeDatabase(host, dbName); + if (result.blocked) { + const notificationBox = this._toolbox.getNotificationBox(); + const message = await this._panelDoc.l10n.formatValue( + "storage-idb-delete-blocked", + { dbName } + ); + + notificationBox.appendNotification( + message, + "storage-idb-delete-blocked", + null, + notificationBox.PRIORITY_WARNING_LOW + ); + } + } catch (error) { + const notificationBox = this._toolbox.getNotificationBox(); + const message = await this._panelDoc.l10n.formatValue( + "storage-idb-delete-error", + { dbName } + ); + notificationBox.appendNotification( + message, + "storage-idb-delete-error", + null, + notificationBox.PRIORITY_CRITICAL_LOW + ); + } + } + + removeCache(host, cacheName) { + const front = this.getCurrentFront(); + + front.removeItem(host, JSON.stringify([cacheName])); + } +} + +exports.StorageUI = StorageUI; + +// Helper Functions + +function createGUID() { + return "{cccccccc-cccc-4ccc-yccc-cccccccccccc}".replace(/[cy]/g, c => { + const r = (Math.random() * 16) | 0; + const v = c == "c" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +function addEllipsis(name) { + if (name.length > ITEM_NAME_MAX_LENGTH) { + if (/^https?:/.test(name)) { + // For URLs, add ellipsis in the middle + const halfLen = ITEM_NAME_MAX_LENGTH / 2; + return name.slice(0, halfLen) + ELLIPSIS + name.slice(-halfLen); + } + + // For other strings, add ellipsis at the end + return name.substr(0, ITEM_NAME_MAX_LENGTH) + ELLIPSIS; + } + + return name; +} diff --git a/devtools/client/storage/utils/doc-utils.js b/devtools/client/storage/utils/doc-utils.js new file mode 100644 index 0000000000..261e2a99bb --- /dev/null +++ b/devtools/client/storage/utils/doc-utils.js @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 MDN_BASE_URL = + "https://firefox-source-docs.mozilla.org/devtools-user/storage_inspector/"; + +/** + * Get the MDN URL for the specified storage type. + * + * @param {string} type Type of the storage. + * + * @return {string} The MDN URL for the storage type, or null if not available. + */ +function getStorageTypeURL(type) { + switch (type) { + case "cookies": + return `${MDN_BASE_URL}cookies`; + case "localStorage": + case "sessionStorage": + return `${MDN_BASE_URL}local_storage_session_storage`; + case "indexedDB": + return `${MDN_BASE_URL}indexeddb`; + case "Cache": + return `${MDN_BASE_URL}cache_storage`; + case "extensionStorage": + return `${MDN_BASE_URL}extension_storage`; + default: + return null; + } +} + +module.exports = getStorageTypeURL; diff --git a/devtools/client/storage/utils/l10n.js b/devtools/client/storage/utils/l10n.js new file mode 100644 index 0000000000..3c360dd7a2 --- /dev/null +++ b/devtools/client/storage/utils/l10n.js @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + FluentL10n, +} = require("resource://devtools/client/shared/fluent-l10n/fluent-l10n.js"); + +// exports a singleton, which will be used across all storage panel modules +exports.l10n = new FluentL10n(); diff --git a/devtools/client/storage/utils/moz.build b/devtools/client/storage/utils/moz.build new file mode 100644 index 0000000000..b9e1ad7354 --- /dev/null +++ b/devtools/client/storage/utils/moz.build @@ -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/. + +DevToolsModules( + "doc-utils.js", + "l10n.js", +) |