From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- devtools/client/storage/VariablesView.sys.mjs | 4445 ++++++++++++++++++++ devtools/client/storage/index.xhtml | 113 + devtools/client/storage/moz.build | 20 + devtools/client/storage/panel.js | 57 + devtools/client/storage/test/browser.toml | 194 + .../client/storage/test/browser_storage_basic.js | 172 + .../test/browser_storage_basic_usercontextid_1.js | 162 + .../test/browser_storage_basic_usercontextid_2.js | 169 + .../test/browser_storage_basic_with_fragment.js | 177 + .../storage/test/browser_storage_cache_delete.js | 53 + .../storage/test/browser_storage_cache_error.js | 35 + .../test/browser_storage_cache_navigation.js | 84 + .../storage/test/browser_storage_cache_overflow.js | 32 + .../storage/test/browser_storage_cookies_add.js | 59 + .../test/browser_storage_cookies_delete_all.js | 185 + .../storage/test/browser_storage_cookies_domain.js | 25 + .../test/browser_storage_cookies_domain_port.js | 25 + .../storage/test/browser_storage_cookies_edit.js | 27 + .../test/browser_storage_cookies_edit_keyboard.js | 23 + .../test/browser_storage_cookies_hostOnly.js | 27 + .../test/browser_storage_cookies_navigation.js | 139 + .../test/browser_storage_cookies_samesite.js | 42 + .../storage/test/browser_storage_cookies_sort.js | 64 + .../test/browser_storage_cookies_tab_navigation.js | 25 + .../client/storage/test/browser_storage_delete.js | 79 + .../storage/test/browser_storage_delete_all.js | 115 + .../storage/test/browser_storage_delete_tree.js | 93 + .../test/browser_storage_delete_usercontextid.js | 238 ++ .../client/storage/test/browser_storage_dfpi.js | 164 + ...rowser_storage_dfpi_always_partition_storage.js | 70 + .../browser_storage_dynamic_updates_cookies.js | 239 ++ ...browser_storage_dynamic_updates_localStorage.js | 70 + ...owser_storage_dynamic_updates_sessionStorage.js | 90 + .../test/browser_storage_empty_objectstores.js | 90 + .../storage/test/browser_storage_file_url.js | 64 + .../storage/test/browser_storage_fission_cache.js | 44 + .../test/browser_storage_fission_cookies.js | 64 + .../browser_storage_fission_hide_aboutblank.js | 26 + .../test/browser_storage_fission_indexeddb.js | 62 + .../test/browser_storage_fission_local_storage.js | 45 + .../browser_storage_fission_session_storage.js | 45 + .../browser_storage_indexeddb_add_button_hidden.js | 35 + .../test/browser_storage_indexeddb_delete.js | 54 + .../browser_storage_indexeddb_delete_blocked.js | 60 + .../browser_storage_indexeddb_duplicate_names.js | 24 + .../browser_storage_indexeddb_hide_internal_dbs.js | 62 + .../test/browser_storage_indexeddb_navigation.js | 72 + .../test/browser_storage_indexeddb_overflow.js | 36 + .../client/storage/test/browser_storage_keys.js | 164 + .../test/browser_storage_localstorage_add.js | 20 + .../test/browser_storage_localstorage_edit.js | 24 + .../test/browser_storage_localstorage_error.js | 25 + .../browser_storage_localstorage_navigation.js | 63 + ...rowser_storage_localstorage_rapid_add_remove.js | 30 + .../storage/test/browser_storage_overflow.js | 104 + .../client/storage/test/browser_storage_search.js | 140 + .../test/browser_storage_search_keyboard_trap.js | 15 + .../test/browser_storage_sessionstorage_add.js | 20 + .../test/browser_storage_sessionstorage_edit.js | 24 + .../browser_storage_sessionstorage_navigation.js | 60 + .../client/storage/test/browser_storage_sidebar.js | 136 + .../storage/test/browser_storage_sidebar_filter.js | 43 + .../test/browser_storage_sidebar_parsetree.js | 115 + .../storage/test/browser_storage_sidebar_toggle.js | 65 + .../storage/test/browser_storage_sidebar_update.js | 45 + .../test/browser_storage_type_descriptions.js | 79 + .../client/storage/test/browser_storage_values.js | 265 ++ .../test/browser_storage_webext_storage_local.js | 296 ++ devtools/client/storage/test/head.js | 1186 ++++++ devtools/client/storage/test/storage-blank.html | 9 + .../storage/test/storage-cache-basic-iframe.html | 21 + .../client/storage/test/storage-cache-basic.html | 22 + .../client/storage/test/storage-cache-error.html | 11 + .../storage/test/storage-cache-overflow.html | 23 + .../client/storage/test/storage-complex-keys.html | 78 + .../storage/test/storage-complex-values.html | 124 + .../storage/test/storage-cookies-samesite.html | 17 + .../client/storage/test/storage-cookies-sort.html | 26 + devtools/client/storage/test/storage-cookies.html | 24 + devtools/client/storage/test/storage-dfpi.html | 11 + .../storage/test/storage-empty-objectstores.html | 62 + devtools/client/storage/test/storage-file-url.html | 59 + .../storage/test/storage-idb-delete-blocked.html | 47 + .../test/storage-indexeddb-duplicate-names.html | 43 + .../storage/test/storage-indexeddb-iframe.html | 37 + .../storage/test/storage-indexeddb-simple-alt.html | 38 + .../storage/test/storage-indexeddb-simple.html | 38 + .../test/storage-listings-usercontextid.html | 131 + .../test/storage-listings-with-fragment.html | 134 + devtools/client/storage/test/storage-listings.html | 145 + .../client/storage/test/storage-localstorage.html | 23 + .../storage/test/storage-overflow-indexeddb.html | 49 + devtools/client/storage/test/storage-overflow.html | 19 + devtools/client/storage/test/storage-search.html | 28 + .../test/storage-secured-iframe-usercontextid.html | 91 + .../storage/test/storage-secured-iframe.html | 94 + .../storage/test/storage-sessionstorage.html | 23 + .../storage/test/storage-sidebar-parsetree.html | 40 + .../storage-unsecured-iframe-usercontextid.html | 19 + .../storage/test/storage-unsecured-iframe.html | 22 + devtools/client/storage/test/storage-updates.html | 68 + devtools/client/storage/ui.js | 1754 ++++++++ devtools/client/storage/utils/doc-utils.js | 35 + devtools/client/storage/utils/l10n.js | 12 + devtools/client/storage/utils/moz.build | 8 + 105 files changed, 14599 insertions(+) create mode 100644 devtools/client/storage/VariablesView.sys.mjs create mode 100644 devtools/client/storage/index.xhtml create mode 100644 devtools/client/storage/moz.build create mode 100644 devtools/client/storage/panel.js create mode 100644 devtools/client/storage/test/browser.toml create mode 100644 devtools/client/storage/test/browser_storage_basic.js create mode 100644 devtools/client/storage/test/browser_storage_basic_usercontextid_1.js create mode 100644 devtools/client/storage/test/browser_storage_basic_usercontextid_2.js create mode 100644 devtools/client/storage/test/browser_storage_basic_with_fragment.js create mode 100644 devtools/client/storage/test/browser_storage_cache_delete.js create mode 100644 devtools/client/storage/test/browser_storage_cache_error.js create mode 100644 devtools/client/storage/test/browser_storage_cache_navigation.js create mode 100644 devtools/client/storage/test/browser_storage_cache_overflow.js create mode 100644 devtools/client/storage/test/browser_storage_cookies_add.js create mode 100644 devtools/client/storage/test/browser_storage_cookies_delete_all.js create mode 100644 devtools/client/storage/test/browser_storage_cookies_domain.js create mode 100644 devtools/client/storage/test/browser_storage_cookies_domain_port.js create mode 100644 devtools/client/storage/test/browser_storage_cookies_edit.js create mode 100644 devtools/client/storage/test/browser_storage_cookies_edit_keyboard.js create mode 100644 devtools/client/storage/test/browser_storage_cookies_hostOnly.js create mode 100644 devtools/client/storage/test/browser_storage_cookies_navigation.js create mode 100644 devtools/client/storage/test/browser_storage_cookies_samesite.js create mode 100644 devtools/client/storage/test/browser_storage_cookies_sort.js create mode 100644 devtools/client/storage/test/browser_storage_cookies_tab_navigation.js create mode 100644 devtools/client/storage/test/browser_storage_delete.js create mode 100644 devtools/client/storage/test/browser_storage_delete_all.js create mode 100644 devtools/client/storage/test/browser_storage_delete_tree.js create mode 100644 devtools/client/storage/test/browser_storage_delete_usercontextid.js create mode 100644 devtools/client/storage/test/browser_storage_dfpi.js create mode 100644 devtools/client/storage/test/browser_storage_dfpi_always_partition_storage.js create mode 100644 devtools/client/storage/test/browser_storage_dynamic_updates_cookies.js create mode 100644 devtools/client/storage/test/browser_storage_dynamic_updates_localStorage.js create mode 100644 devtools/client/storage/test/browser_storage_dynamic_updates_sessionStorage.js create mode 100644 devtools/client/storage/test/browser_storage_empty_objectstores.js create mode 100644 devtools/client/storage/test/browser_storage_file_url.js create mode 100644 devtools/client/storage/test/browser_storage_fission_cache.js create mode 100644 devtools/client/storage/test/browser_storage_fission_cookies.js create mode 100644 devtools/client/storage/test/browser_storage_fission_hide_aboutblank.js create mode 100644 devtools/client/storage/test/browser_storage_fission_indexeddb.js create mode 100644 devtools/client/storage/test/browser_storage_fission_local_storage.js create mode 100644 devtools/client/storage/test/browser_storage_fission_session_storage.js create mode 100644 devtools/client/storage/test/browser_storage_indexeddb_add_button_hidden.js create mode 100644 devtools/client/storage/test/browser_storage_indexeddb_delete.js create mode 100644 devtools/client/storage/test/browser_storage_indexeddb_delete_blocked.js create mode 100644 devtools/client/storage/test/browser_storage_indexeddb_duplicate_names.js create mode 100644 devtools/client/storage/test/browser_storage_indexeddb_hide_internal_dbs.js create mode 100644 devtools/client/storage/test/browser_storage_indexeddb_navigation.js create mode 100644 devtools/client/storage/test/browser_storage_indexeddb_overflow.js create mode 100644 devtools/client/storage/test/browser_storage_keys.js create mode 100644 devtools/client/storage/test/browser_storage_localstorage_add.js create mode 100644 devtools/client/storage/test/browser_storage_localstorage_edit.js create mode 100644 devtools/client/storage/test/browser_storage_localstorage_error.js create mode 100644 devtools/client/storage/test/browser_storage_localstorage_navigation.js create mode 100644 devtools/client/storage/test/browser_storage_localstorage_rapid_add_remove.js create mode 100644 devtools/client/storage/test/browser_storage_overflow.js create mode 100644 devtools/client/storage/test/browser_storage_search.js create mode 100644 devtools/client/storage/test/browser_storage_search_keyboard_trap.js create mode 100644 devtools/client/storage/test/browser_storage_sessionstorage_add.js create mode 100644 devtools/client/storage/test/browser_storage_sessionstorage_edit.js create mode 100644 devtools/client/storage/test/browser_storage_sessionstorage_navigation.js create mode 100644 devtools/client/storage/test/browser_storage_sidebar.js create mode 100644 devtools/client/storage/test/browser_storage_sidebar_filter.js create mode 100644 devtools/client/storage/test/browser_storage_sidebar_parsetree.js create mode 100644 devtools/client/storage/test/browser_storage_sidebar_toggle.js create mode 100644 devtools/client/storage/test/browser_storage_sidebar_update.js create mode 100644 devtools/client/storage/test/browser_storage_type_descriptions.js create mode 100644 devtools/client/storage/test/browser_storage_values.js create mode 100644 devtools/client/storage/test/browser_storage_webext_storage_local.js create mode 100644 devtools/client/storage/test/head.js create mode 100644 devtools/client/storage/test/storage-blank.html create mode 100644 devtools/client/storage/test/storage-cache-basic-iframe.html create mode 100644 devtools/client/storage/test/storage-cache-basic.html create mode 100644 devtools/client/storage/test/storage-cache-error.html create mode 100644 devtools/client/storage/test/storage-cache-overflow.html create mode 100644 devtools/client/storage/test/storage-complex-keys.html create mode 100644 devtools/client/storage/test/storage-complex-values.html create mode 100644 devtools/client/storage/test/storage-cookies-samesite.html create mode 100644 devtools/client/storage/test/storage-cookies-sort.html create mode 100644 devtools/client/storage/test/storage-cookies.html create mode 100644 devtools/client/storage/test/storage-dfpi.html create mode 100644 devtools/client/storage/test/storage-empty-objectstores.html create mode 100644 devtools/client/storage/test/storage-file-url.html create mode 100644 devtools/client/storage/test/storage-idb-delete-blocked.html create mode 100644 devtools/client/storage/test/storage-indexeddb-duplicate-names.html create mode 100644 devtools/client/storage/test/storage-indexeddb-iframe.html create mode 100644 devtools/client/storage/test/storage-indexeddb-simple-alt.html create mode 100644 devtools/client/storage/test/storage-indexeddb-simple.html create mode 100644 devtools/client/storage/test/storage-listings-usercontextid.html create mode 100644 devtools/client/storage/test/storage-listings-with-fragment.html create mode 100644 devtools/client/storage/test/storage-listings.html create mode 100644 devtools/client/storage/test/storage-localstorage.html create mode 100644 devtools/client/storage/test/storage-overflow-indexeddb.html create mode 100644 devtools/client/storage/test/storage-overflow.html create mode 100644 devtools/client/storage/test/storage-search.html create mode 100644 devtools/client/storage/test/storage-secured-iframe-usercontextid.html create mode 100644 devtools/client/storage/test/storage-secured-iframe.html create mode 100644 devtools/client/storage/test/storage-sessionstorage.html create mode 100644 devtools/client/storage/test/storage-sidebar-parsetree.html create mode 100644 devtools/client/storage/test/storage-unsecured-iframe-usercontextid.html create mode 100644 devtools/client/storage/test/storage-unsecured-iframe.html create mode 100644 devtools/client/storage/test/storage-updates.html create mode 100644 devtools/client/storage/ui.js create mode 100644 devtools/client/storage/utils/doc-utils.js create mode 100644 devtools/client/storage/utils/l10n.js create mode 100644 devtools/client/storage/utils/moz.build (limited to 'devtools/client/storage') 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 or 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', '', '' 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 == "") { + target.setAttribute("exception", ""); + target.setAttribute("pseudo-item", ""); + } else if (this._internalItem && name == "") { + 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(": " + VariablesView.getString(state)); + if (state == "fulfilled") { + props.push( + ": " + VariablesView.getString(value, { concise: true }) + ); + } else if (state == "rejected") { + props.push( + ": " + 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 ""; + } + + 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, ">"); +} + +/** + * 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 @@ + + + + + + + + + + + + + + + ` + ); + const URL2 = buildURLWithContent( + "example.net", + `

example.net

` + + `` + ); + + // 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", + `

example.com

` + `` + ); + const URL2 = buildURLWithContent( + "example.net", + `

example.net

` + + `` + + `` + ); + const URL_IFRAME = buildURLWithContent( + "example.org", + `

example.org

` + `` + ); + + // 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= + +`; + +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", + `

iframe

` + `` + ); + + const URL_MAIN = buildURLWithContent( + "example.com", + `

Main

` + + `` + + ``; + 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", + `

iframe

` + + `` + ); + const URL_MAIN = buildURLWithContent( + "example.com", + `

Main

` + + `` + + ` + + 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 @@ + + + + + Storage inspector test for handling errors in CacheStorage + + + + + + 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 @@ + + + + + Storage inspector test for Cache + + +

Cache overflow

+ + + + 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 @@ + + + + + Storage inspector test for correct keys in the sidebar + + + + + 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 @@ + + + + + + Storage inspector test for correct values in the sidebar + + + + + 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 @@ + + + + + Storage inspector cookie samesite test + + + + + 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 @@ + + + + + + Storage inspector cookie test + + + + + 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 @@ + + + + + + Storage inspector cookie test + + + + + 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 @@ + + + + + + +

storage-iframe.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 @@ + + + + + Test for proper listing indexedDB databases with no object stores + + + + + 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 @@ + + + + + Storage Test + + + +

IndexedDB Test

+ + 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 @@ + + + + + Test for proper listing indexedDB databases with no object stores + + + + + 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 @@ + + + + + Storage inspector IndexedDBs with duplicate names + + + + +

storage-indexeddb-duplicate-names.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 @@ + + + + + Storage inspector test for indexedDB - simple (alt) + + + +

IndexedDB storage - with iframe

+ + + + + 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 @@ + + + + + Storage inspector test for indexedDB - simple (alt) + + + +

IndexedDB storage - simple (alt)

+ + + + 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 @@ + + + + + Storage inspector test for indexedDB - simple + + + +

IndexedDB storage - simple

+ + + + 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 @@ + + + + + + Storage inspector test for listing hosts and storages + + + + + + + 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 @@ + + + + + + Storage inspector test for listing hosts and storages with URL fragments + + + + + + + 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 @@ + + + + + + Storage inspector test for listing hosts and storages + + + + + + + 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 @@ + + + + + + Storage inspector localStorage test + + + + + 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 @@ + + + + + + Storage inspector endless scrolling test + + + + + 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 @@ + + + + + + Storage inspector endless scrolling test + + + + + 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 @@ + + + + + + Storage inspector table filtering test + + + + + + + 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 @@ + + + + + + + + + + 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 @@ + + + + + + + + + + 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 @@ + + + + + + Storage inspector sessionStorage test + + + + + 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 @@ + + + + + Storage inspector sidebar parsetree test + + + + + 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 @@ + + + + + + + + + + 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 @@ + + + + + + + + + + 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 @@ + + + + + + Storage inspector blank html for tests + + + + + 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 + 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: + * { + * : { + * : [, ...], + * : [...], ... + * }, + * : { + * : [, ...], + * : [...], ... + * }, ... + * } + * 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 [ { + 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", +) -- cgit v1.2.3