summaryrefslogtreecommitdiffstats
path: root/devtools/client/storage
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/client/storage
parentInitial commit. (diff)
downloadfirefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz
firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/storage')
-rw-r--r--devtools/client/storage/VariablesView.sys.mjs4445
-rw-r--r--devtools/client/storage/index.xhtml106
-rw-r--r--devtools/client/storage/moz.build20
-rw-r--r--devtools/client/storage/panel.js57
-rw-r--r--devtools/client/storage/test/browser.ini122
-rw-r--r--devtools/client/storage/test/browser_storage_basic.js172
-rw-r--r--devtools/client/storage/test/browser_storage_basic_usercontextid_1.js162
-rw-r--r--devtools/client/storage/test/browser_storage_basic_usercontextid_2.js169
-rw-r--r--devtools/client/storage/test/browser_storage_basic_with_fragment.js177
-rw-r--r--devtools/client/storage/test/browser_storage_cache_delete.js53
-rw-r--r--devtools/client/storage/test/browser_storage_cache_error.js35
-rw-r--r--devtools/client/storage/test/browser_storage_cache_navigation.js84
-rw-r--r--devtools/client/storage/test/browser_storage_cache_overflow.js32
-rw-r--r--devtools/client/storage/test/browser_storage_cookies_add.js30
-rw-r--r--devtools/client/storage/test/browser_storage_cookies_delete_all.js185
-rw-r--r--devtools/client/storage/test/browser_storage_cookies_domain.js25
-rw-r--r--devtools/client/storage/test/browser_storage_cookies_domain_port.js25
-rw-r--r--devtools/client/storage/test/browser_storage_cookies_edit.js27
-rw-r--r--devtools/client/storage/test/browser_storage_cookies_edit_keyboard.js23
-rw-r--r--devtools/client/storage/test/browser_storage_cookies_hostOnly.js27
-rw-r--r--devtools/client/storage/test/browser_storage_cookies_navigation.js139
-rw-r--r--devtools/client/storage/test/browser_storage_cookies_samesite.js42
-rw-r--r--devtools/client/storage/test/browser_storage_cookies_sort.js64
-rw-r--r--devtools/client/storage/test/browser_storage_cookies_tab_navigation.js25
-rw-r--r--devtools/client/storage/test/browser_storage_delete.js79
-rw-r--r--devtools/client/storage/test/browser_storage_delete_all.js115
-rw-r--r--devtools/client/storage/test/browser_storage_delete_tree.js93
-rw-r--r--devtools/client/storage/test/browser_storage_delete_usercontextid.js238
-rw-r--r--devtools/client/storage/test/browser_storage_dfpi.js164
-rw-r--r--devtools/client/storage/test/browser_storage_dfpi_always_partition_storage.js70
-rw-r--r--devtools/client/storage/test/browser_storage_dom_cache_disabled.js42
-rw-r--r--devtools/client/storage/test/browser_storage_dynamic_updates_cookies.js239
-rw-r--r--devtools/client/storage/test/browser_storage_dynamic_updates_localStorage.js70
-rw-r--r--devtools/client/storage/test/browser_storage_dynamic_updates_sessionStorage.js90
-rw-r--r--devtools/client/storage/test/browser_storage_empty_objectstores.js90
-rw-r--r--devtools/client/storage/test/browser_storage_file_url.js64
-rw-r--r--devtools/client/storage/test/browser_storage_fission_cache.js44
-rw-r--r--devtools/client/storage/test/browser_storage_fission_cookies.js64
-rw-r--r--devtools/client/storage/test/browser_storage_fission_hide_aboutblank.js26
-rw-r--r--devtools/client/storage/test/browser_storage_fission_indexeddb.js62
-rw-r--r--devtools/client/storage/test/browser_storage_fission_local_storage.js45
-rw-r--r--devtools/client/storage/test/browser_storage_fission_session_storage.js45
-rw-r--r--devtools/client/storage/test/browser_storage_indexeddb_add_button_hidden.js35
-rw-r--r--devtools/client/storage/test/browser_storage_indexeddb_delete.js54
-rw-r--r--devtools/client/storage/test/browser_storage_indexeddb_delete_blocked.js60
-rw-r--r--devtools/client/storage/test/browser_storage_indexeddb_duplicate_names.js24
-rw-r--r--devtools/client/storage/test/browser_storage_indexeddb_hide_internal_dbs.js62
-rw-r--r--devtools/client/storage/test/browser_storage_indexeddb_navigation.js72
-rw-r--r--devtools/client/storage/test/browser_storage_indexeddb_overflow.js36
-rw-r--r--devtools/client/storage/test/browser_storage_keys.js164
-rw-r--r--devtools/client/storage/test/browser_storage_localstorage_add.js20
-rw-r--r--devtools/client/storage/test/browser_storage_localstorage_edit.js24
-rw-r--r--devtools/client/storage/test/browser_storage_localstorage_error.js25
-rw-r--r--devtools/client/storage/test/browser_storage_localstorage_navigation.js63
-rw-r--r--devtools/client/storage/test/browser_storage_localstorage_rapid_add_remove.js30
-rw-r--r--devtools/client/storage/test/browser_storage_overflow.js104
-rw-r--r--devtools/client/storage/test/browser_storage_search.js140
-rw-r--r--devtools/client/storage/test/browser_storage_search_keyboard_trap.js15
-rw-r--r--devtools/client/storage/test/browser_storage_sessionstorage_add.js20
-rw-r--r--devtools/client/storage/test/browser_storage_sessionstorage_edit.js24
-rw-r--r--devtools/client/storage/test/browser_storage_sessionstorage_navigation.js60
-rw-r--r--devtools/client/storage/test/browser_storage_sidebar.js136
-rw-r--r--devtools/client/storage/test/browser_storage_sidebar_parsetree.js115
-rw-r--r--devtools/client/storage/test/browser_storage_sidebar_toggle.js65
-rw-r--r--devtools/client/storage/test/browser_storage_sidebar_update.js45
-rw-r--r--devtools/client/storage/test/browser_storage_type_descriptions.js79
-rw-r--r--devtools/client/storage/test/browser_storage_values.js261
-rw-r--r--devtools/client/storage/test/browser_storage_webext_storage_local.js296
-rw-r--r--devtools/client/storage/test/head.js1177
-rw-r--r--devtools/client/storage/test/storage-blank.html9
-rw-r--r--devtools/client/storage/test/storage-cache-basic-iframe.html21
-rw-r--r--devtools/client/storage/test/storage-cache-basic.html22
-rw-r--r--devtools/client/storage/test/storage-cache-error.html11
-rw-r--r--devtools/client/storage/test/storage-cache-overflow.html23
-rw-r--r--devtools/client/storage/test/storage-complex-keys.html78
-rw-r--r--devtools/client/storage/test/storage-complex-values.html124
-rw-r--r--devtools/client/storage/test/storage-cookies-samesite.html17
-rw-r--r--devtools/client/storage/test/storage-cookies-sort.html26
-rw-r--r--devtools/client/storage/test/storage-cookies.html24
-rw-r--r--devtools/client/storage/test/storage-dfpi.html11
-rw-r--r--devtools/client/storage/test/storage-empty-objectstores.html62
-rw-r--r--devtools/client/storage/test/storage-file-url.html59
-rw-r--r--devtools/client/storage/test/storage-idb-delete-blocked.html47
-rw-r--r--devtools/client/storage/test/storage-indexeddb-duplicate-names.html43
-rw-r--r--devtools/client/storage/test/storage-indexeddb-iframe.html37
-rw-r--r--devtools/client/storage/test/storage-indexeddb-simple-alt.html38
-rw-r--r--devtools/client/storage/test/storage-indexeddb-simple.html38
-rw-r--r--devtools/client/storage/test/storage-listings-usercontextid.html131
-rw-r--r--devtools/client/storage/test/storage-listings-with-fragment.html134
-rw-r--r--devtools/client/storage/test/storage-listings.html145
-rw-r--r--devtools/client/storage/test/storage-localstorage.html23
-rw-r--r--devtools/client/storage/test/storage-overflow-indexeddb.html49
-rw-r--r--devtools/client/storage/test/storage-overflow.html19
-rw-r--r--devtools/client/storage/test/storage-search.html28
-rw-r--r--devtools/client/storage/test/storage-secured-iframe-usercontextid.html91
-rw-r--r--devtools/client/storage/test/storage-secured-iframe.html94
-rw-r--r--devtools/client/storage/test/storage-sessionstorage.html23
-rw-r--r--devtools/client/storage/test/storage-sidebar-parsetree.html40
-rw-r--r--devtools/client/storage/test/storage-unsecured-iframe-usercontextid.html19
-rw-r--r--devtools/client/storage/test/storage-unsecured-iframe.html22
-rw-r--r--devtools/client/storage/test/storage-updates.html68
-rw-r--r--devtools/client/storage/ui.js1769
-rw-r--r--devtools/client/storage/utils/doc-utils.js35
-rw-r--r--devtools/client/storage/utils/l10n.js12
-rw-r--r--devtools/client/storage/utils/moz.build8
105 files changed, 14492 insertions, 0 deletions
diff --git a/devtools/client/storage/VariablesView.sys.mjs b/devtools/client/storage/VariablesView.sys.mjs
new file mode 100644
index 0000000000..769840c022
--- /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";
+ searchbox.setAttribute("data-l10n-id", "storage-variable-view-search-box");
+ searchbox.addEventListener("input", this._onSearchboxInput);
+ searchbox.addEventListener("keydown", this._onSearchboxKeyDown);
+
+ container.appendChild(searchbox);
+ ownerNode.insertBefore(container, this._parent);
+ },
+
+ /**
+ * Disables variable and property searching in this view.
+ * Use the "searchEnabled" setter to disable searching.
+ */
+ _disableSearch() {
+ // If searching was already disabled, no need to re-disable it again.
+ if (!this._searchboxContainer) {
+ return;
+ }
+ this._searchboxContainer.remove();
+ this._searchboxNode.removeEventListener("input", this._onSearchboxInput);
+ this._searchboxNode.removeEventListener(
+ "keydown",
+ this._onSearchboxKeyDown
+ );
+
+ this._searchboxContainer = null;
+ this._searchboxNode = null;
+ },
+
+ /**
+ * Sets the variables searchbox container hidden or visible.
+ * It's hidden by default.
+ *
+ * @param boolean aVisibleFlag
+ * Specifies the intended visibility.
+ */
+ _toggleSearchVisibility(aVisibleFlag) {
+ // If searching was already disabled, there's no need to hide it.
+ if (!this._searchboxContainer) {
+ return;
+ }
+ this._searchboxContainer.hidden = !aVisibleFlag;
+ },
+
+ /**
+ * Listener handling the searchbox input event.
+ */
+ _onSearchboxInput() {
+ this.scheduleSearch(this._searchboxNode.value);
+ },
+
+ /**
+ * Listener handling the searchbox keydown event.
+ */
+ _onSearchboxKeyDown(e) {
+ switch (e.keyCode) {
+ case KeyCodes.DOM_VK_RETURN:
+ this._onSearchboxInput();
+ return;
+ case KeyCodes.DOM_VK_ESCAPE:
+ this._searchboxNode.value = "";
+ this._onSearchboxInput();
+ }
+ },
+
+ /**
+ * Schedules searching for variables or properties matching the query.
+ *
+ * @param string aToken
+ * The variable or property to search for.
+ * @param number aWait
+ * The amount of milliseconds to wait until draining.
+ */
+ scheduleSearch(aToken, aWait) {
+ // Check if this search operation may not be executed lazily.
+ if (!this.lazySearch) {
+ this._doSearch(aToken);
+ return;
+ }
+
+ // The amount of time to wait for the requests to settle.
+ const maxDelay = SEARCH_ACTION_MAX_DELAY;
+ const delay = aWait === undefined ? maxDelay / aToken.length : aWait;
+
+ // Allow requests to settle down first.
+ setNamedTimeout("vview-search", delay, () => this._doSearch(aToken));
+ },
+
+ /**
+ * Performs a case insensitive search for variables or properties matching
+ * the query, and hides non-matched items.
+ *
+ * If aToken is falsy, then all the scopes are unhidden and expanded,
+ * while the available variables and properties inside those scopes are
+ * just unhidden.
+ *
+ * @param string aToken
+ * The variable or property to search for.
+ */
+ _doSearch(aToken) {
+ if (this.controller && this.controller.supportsSearch()) {
+ // Retrieve the main Scope in which we add attributes
+ const scope = this._store[0]._store.get(undefined);
+ if (!aToken) {
+ // Prune the view from old previous content
+ // so that we delete the intermediate search results
+ // we created in previous searches
+ for (const property of scope._store.values()) {
+ property.remove();
+ }
+ }
+ // Retrieve new attributes eventually hidden in splits
+ this.controller.performSearch(scope, aToken);
+ // Filter already displayed attributes
+ if (aToken) {
+ scope._performSearch(aToken.toLowerCase());
+ }
+ return;
+ }
+ for (const scope of this._store) {
+ switch (aToken) {
+ case "":
+ case null:
+ case undefined:
+ scope.expand();
+ scope._performSearch("");
+ break;
+ default:
+ scope._performSearch(aToken.toLowerCase());
+ break;
+ }
+ }
+ },
+
+ /**
+ * Find the first item in the tree of visible items in this container that
+ * matches the predicate. Searches in visual order (the order seen by the
+ * user). Descends into each scope to check the scope and its children.
+ *
+ * @param function aPredicate
+ * A function that returns true when a match is found.
+ * @return Scope | Variable | Property
+ * The first visible scope, variable or property, or null if nothing
+ * is found.
+ */
+ _findInVisibleItems(aPredicate) {
+ for (const scope of this._store) {
+ const result = scope._findInVisibleItems(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Find the last item in the tree of visible items in this container that
+ * matches the predicate. Searches in reverse visual order (opposite of the
+ * order seen by the user). Descends into each scope to check the scope and
+ * its children.
+ *
+ * @param function aPredicate
+ * A function that returns true when a match is found.
+ * @return Scope | Variable | Property
+ * The last visible scope, variable or property, or null if nothing
+ * is found.
+ */
+ _findInVisibleItemsReverse(aPredicate) {
+ for (let i = this._store.length - 1; i >= 0; i--) {
+ const scope = this._store[i];
+ const result = scope._findInVisibleItemsReverse(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Gets the scope at the specified index.
+ *
+ * @param number aIndex
+ * The scope's index.
+ * @return Scope
+ * The scope if found, undefined if not.
+ */
+ getScopeAtIndex(aIndex) {
+ return this._store[aIndex];
+ },
+
+ /**
+ * Recursively searches this container for the scope, variable or property
+ * displayed by the specified node.
+ *
+ * @param Node aNode
+ * The node to search for.
+ * @return Scope | Variable | Property
+ * The matched scope, variable or property, or null if nothing is found.
+ */
+ getItemForNode(aNode) {
+ return this._itemsByElement.get(aNode);
+ },
+
+ /**
+ * Gets the scope owning a Variable or Property.
+ *
+ * @param Variable | Property
+ * The variable or property to retrieven the owner scope for.
+ * @return Scope
+ * The owner scope.
+ */
+ getOwnerScopeForVariableOrProperty(aItem) {
+ if (!aItem) {
+ return null;
+ }
+ // If this is a Scope, return it.
+ if (!(aItem instanceof Variable)) {
+ return aItem;
+ }
+ // If this is a Variable or Property, find its owner scope.
+ if (aItem instanceof Variable && aItem.ownerView) {
+ return this.getOwnerScopeForVariableOrProperty(aItem.ownerView);
+ }
+ return null;
+ },
+
+ /**
+ * Gets the parent scopes for a specified Variable or Property.
+ * The returned list will not include the owner scope.
+ *
+ * @param Variable | Property
+ * The variable or property for which to find the parent scopes.
+ * @return array
+ * A list of parent Scopes.
+ */
+ getParentScopesForVariableOrProperty(aItem) {
+ const scope = this.getOwnerScopeForVariableOrProperty(aItem);
+ return this._store.slice(0, Math.max(this._store.indexOf(scope), 0));
+ },
+
+ /**
+ * Gets the currently focused scope, variable or property in this view.
+ *
+ * @return Scope | Variable | Property
+ * The focused scope, variable or property, or null if nothing is found.
+ */
+ getFocusedItem() {
+ const focused = this.document.commandDispatcher.focusedElement;
+ return this.getItemForNode(focused);
+ },
+
+ /**
+ * Focuses the first visible scope, variable, or property in this container.
+ */
+ focusFirstVisibleItem() {
+ const focusableItem = this._findInVisibleItems(item => item.focusable);
+ if (focusableItem) {
+ this._focusItem(focusableItem);
+ }
+ this._parent.scrollTop = 0;
+ this._parent.scrollLeft = 0;
+ },
+
+ /**
+ * Focuses the last visible scope, variable, or property in this container.
+ */
+ focusLastVisibleItem() {
+ const focusableItem = this._findInVisibleItemsReverse(
+ item => item.focusable
+ );
+ if (focusableItem) {
+ this._focusItem(focusableItem);
+ }
+ this._parent.scrollTop = this._parent.scrollHeight;
+ this._parent.scrollLeft = 0;
+ },
+
+ /**
+ * Focuses the next scope, variable or property in this view.
+ */
+ focusNextItem() {
+ this.focusItemAtDelta(+1);
+ },
+
+ /**
+ * Focuses the previous scope, variable or property in this view.
+ */
+ focusPrevItem() {
+ this.focusItemAtDelta(-1);
+ },
+
+ /**
+ * Focuses another scope, variable or property in this view, based on
+ * the index distance from the currently focused item.
+ *
+ * @param number aDelta
+ * A scalar specifying by how many items should the selection change.
+ */
+ focusItemAtDelta(aDelta) {
+ const direction = aDelta > 0 ? "advanceFocus" : "rewindFocus";
+ let distance = Math.abs(Math[aDelta > 0 ? "ceil" : "floor"](aDelta));
+ while (distance--) {
+ if (!this._focusChange(direction)) {
+ break; // Out of bounds.
+ }
+ }
+ },
+
+ /**
+ * Focuses the next or previous scope, variable or property in this view.
+ *
+ * @param string aDirection
+ * Either "advanceFocus" or "rewindFocus".
+ * @return boolean
+ * False if the focus went out of bounds and the first or last element
+ * in this view was focused instead.
+ */
+ _focusChange(aDirection) {
+ const commandDispatcher = this.document.commandDispatcher;
+ const prevFocusedElement = commandDispatcher.focusedElement;
+ let currFocusedItem = null;
+
+ do {
+ commandDispatcher[aDirection]();
+
+ // Make sure the newly focused item is a part of this view.
+ // If the focus goes out of bounds, revert the previously focused item.
+ if (!(currFocusedItem = this.getFocusedItem())) {
+ prevFocusedElement.focus();
+ return false;
+ }
+ } while (!currFocusedItem.focusable);
+
+ // Focus remained within bounds.
+ return true;
+ },
+
+ /**
+ * Focuses a scope, variable or property and makes sure it's visible.
+ *
+ * @param aItem Scope | Variable | Property
+ * The item to focus.
+ * @param boolean aCollapseFlag
+ * True if the focused item should also be collapsed.
+ * @return boolean
+ * True if the item was successfully focused.
+ */
+ _focusItem(aItem, aCollapseFlag) {
+ if (!aItem.focusable) {
+ return false;
+ }
+ if (aCollapseFlag) {
+ aItem.collapse();
+ }
+ aItem._target.focus();
+ aItem._arrow.scrollIntoView({ block: "nearest" });
+ return true;
+ },
+
+ /**
+ * Copy current selection to clipboard.
+ */
+ _copyItem() {
+ const item = this.getFocusedItem();
+ lazy.clipboardHelper.copyString(
+ item._nameString + item.separatorStr + item._valueString
+ );
+ },
+
+ /**
+ * Listener handling a key down event on the view.
+ */
+ // eslint-disable-next-line complexity
+ _onViewKeyDown(e) {
+ const item = this.getFocusedItem();
+
+ // Prevent scrolling when pressing navigation keys.
+ ViewHelpers.preventScrolling(e);
+
+ switch (e.keyCode) {
+ case KeyCodes.DOM_VK_C:
+ if (e.ctrlKey || e.metaKey) {
+ this._copyItem();
+ }
+ return;
+
+ case KeyCodes.DOM_VK_UP:
+ // Always rewind focus.
+ this.focusPrevItem(true);
+ return;
+
+ case KeyCodes.DOM_VK_DOWN:
+ // Always advance focus.
+ this.focusNextItem(true);
+ return;
+
+ case KeyCodes.DOM_VK_LEFT:
+ // Collapse scopes, variables and properties before rewinding focus.
+ if (item._isExpanded && item._isArrowVisible) {
+ item.collapse();
+ } else {
+ this._focusItem(item.ownerView);
+ }
+ return;
+
+ case KeyCodes.DOM_VK_RIGHT:
+ // Nothing to do here if this item never expands.
+ if (!item._isArrowVisible) {
+ return;
+ }
+ // Expand scopes, variables and properties before advancing focus.
+ if (!item._isExpanded) {
+ item.expand();
+ } else {
+ this.focusNextItem(true);
+ }
+ return;
+
+ case KeyCodes.DOM_VK_PAGE_UP:
+ // Rewind a certain number of elements based on the container height.
+ this.focusItemAtDelta(
+ -(
+ this.scrollPageSize ||
+ Math.min(
+ Math.floor(
+ this._list.scrollHeight / PAGE_SIZE_SCROLL_HEIGHT_RATIO
+ ),
+ PAGE_SIZE_MAX_JUMPS
+ )
+ )
+ );
+ return;
+
+ case KeyCodes.DOM_VK_PAGE_DOWN:
+ // Advance a certain number of elements based on the container height.
+ this.focusItemAtDelta(
+ +(
+ this.scrollPageSize ||
+ Math.min(
+ Math.floor(
+ this._list.scrollHeight / PAGE_SIZE_SCROLL_HEIGHT_RATIO
+ ),
+ PAGE_SIZE_MAX_JUMPS
+ )
+ )
+ );
+ return;
+
+ case KeyCodes.DOM_VK_HOME:
+ this.focusFirstVisibleItem();
+ return;
+
+ case KeyCodes.DOM_VK_END:
+ this.focusLastVisibleItem();
+ return;
+
+ case KeyCodes.DOM_VK_RETURN:
+ // Start editing the value or name of the Variable or Property.
+ if (item instanceof Variable) {
+ if (e.metaKey || e.altKey || e.shiftKey) {
+ item._activateNameInput();
+ } else {
+ item._activateValueInput();
+ }
+ }
+ return;
+
+ case KeyCodes.DOM_VK_DELETE:
+ case KeyCodes.DOM_VK_BACK_SPACE:
+ // Delete the Variable or Property if allowed.
+ if (item instanceof Variable) {
+ item._onDelete(e);
+ }
+ return;
+
+ case KeyCodes.DOM_VK_INSERT:
+ item._onAddProperty(e);
+ }
+ },
+
+ /**
+ * Sets the text displayed in this container when there are no available items.
+ * @param string aValue
+ */
+ set emptyText(aValue) {
+ if (this._emptyTextNode) {
+ this._emptyTextNode.setAttribute("value", aValue);
+ }
+ this._emptyTextValue = aValue;
+ this._appendEmptyNotice();
+ },
+
+ /**
+ * Creates and appends a label signaling that this container is empty.
+ */
+ _appendEmptyNotice() {
+ if (this._emptyTextNode || !this._emptyTextValue) {
+ return;
+ }
+
+ const label = this.document.createXULElement("label");
+ label.className = "variables-view-empty-notice";
+ label.setAttribute("value", this._emptyTextValue);
+
+ this._parent.appendChild(label);
+ this._emptyTextNode = label;
+ },
+
+ /**
+ * Removes the label signaling that this container is empty.
+ */
+ _removeEmptyNotice() {
+ if (!this._emptyTextNode) {
+ return;
+ }
+
+ this._parent.removeChild(this._emptyTextNode);
+ this._emptyTextNode = null;
+ },
+
+ /**
+ * Gets if all values should be aligned together.
+ * @return boolean
+ */
+ get alignedValues() {
+ return this._alignedValues;
+ },
+
+ /**
+ * Sets if all values should be aligned together.
+ * @param boolean aFlag
+ */
+ set alignedValues(aFlag) {
+ this._alignedValues = aFlag;
+ if (aFlag) {
+ this._parent.setAttribute("aligned-values", "");
+ } else {
+ this._parent.removeAttribute("aligned-values");
+ }
+ },
+
+ /**
+ * Gets if action buttons (like delete) should be placed at the beginning or
+ * end of a line.
+ * @return boolean
+ */
+ get actionsFirst() {
+ return this._actionsFirst;
+ },
+
+ /**
+ * Sets if action buttons (like delete) should be placed at the beginning or
+ * end of a line.
+ * @param boolean aFlag
+ */
+ set actionsFirst(aFlag) {
+ this._actionsFirst = aFlag;
+ if (aFlag) {
+ this._parent.setAttribute("actions-first", "");
+ } else {
+ this._parent.removeAttribute("actions-first");
+ }
+ },
+
+ /**
+ * Gets the parent node holding this view.
+ * @return Node
+ */
+ get parentNode() {
+ return this._parent;
+ },
+
+ /**
+ * Gets the owner document holding this view.
+ * @return HTMLDocument
+ */
+ get document() {
+ return this._document || (this._document = this._parent.ownerDocument);
+ },
+
+ /**
+ * Gets the default window holding this view.
+ * @return nsIDOMWindow
+ */
+ get window() {
+ return this._window || (this._window = this.document.defaultView);
+ },
+
+ _document: null,
+ _window: null,
+
+ _store: null,
+ _itemsByElement: null,
+ _prevHierarchy: null,
+ _currHierarchy: null,
+
+ _enumVisible: true,
+ _nonEnumVisible: true,
+ _alignedValues: false,
+ _actionsFirst: false,
+
+ _parent: null,
+ _list: null,
+ _searchboxNode: null,
+ _searchboxContainer: null,
+ _emptyTextNode: null,
+ _emptyTextValue: "",
+};
+
+VariablesView.NON_SORTABLE_CLASSES = [
+ "Array",
+ "Int8Array",
+ "Uint8Array",
+ "Uint8ClampedArray",
+ "Int16Array",
+ "Uint16Array",
+ "Int32Array",
+ "Uint32Array",
+ "Float32Array",
+ "Float64Array",
+ "NodeList",
+];
+
+/**
+ * Determine whether an object's properties should be sorted based on its class.
+ *
+ * @param string aClassName
+ * The class of the object.
+ */
+VariablesView.isSortable = function (aClassName) {
+ return !VariablesView.NON_SORTABLE_CLASSES.includes(aClassName);
+};
+
+/**
+ * Generates the string evaluated when performing simple value changes.
+ *
+ * @param Variable | Property aItem
+ * The current variable or property.
+ * @param string aCurrentString
+ * The trimmed user inputted string.
+ * @param string aPrefix [optional]
+ * Prefix for the symbolic name.
+ * @return string
+ * The string to be evaluated.
+ */
+VariablesView.simpleValueEvalMacro = function (
+ aItem,
+ aCurrentString,
+ aPrefix = ""
+) {
+ return aPrefix + aItem.symbolicName + "=" + aCurrentString;
+};
+
+/**
+ * Generates the string evaluated when overriding getters and setters with
+ * plain values.
+ *
+ * @param Property aItem
+ * The current getter or setter property.
+ * @param string aCurrentString
+ * The trimmed user inputted string.
+ * @param string aPrefix [optional]
+ * Prefix for the symbolic name.
+ * @return string
+ * The string to be evaluated.
+ */
+VariablesView.overrideValueEvalMacro = function (
+ aItem,
+ aCurrentString,
+ aPrefix = ""
+) {
+ const property = escapeString(aItem._nameString);
+ const parent = aPrefix + aItem.ownerView.symbolicName || "this";
+
+ return (
+ "Object.defineProperty(" +
+ parent +
+ "," +
+ property +
+ "," +
+ "{ value: " +
+ aCurrentString +
+ ", enumerable: " +
+ parent +
+ ".propertyIsEnumerable(" +
+ property +
+ ")" +
+ ", configurable: true" +
+ ", writable: true" +
+ "})"
+ );
+};
+
+/**
+ * Generates the string evaluated when performing getters and setters changes.
+ *
+ * @param Property aItem
+ * The current getter or setter property.
+ * @param string aCurrentString
+ * The trimmed user inputted string.
+ * @param string aPrefix [optional]
+ * Prefix for the symbolic name.
+ * @return string
+ * The string to be evaluated.
+ */
+VariablesView.getterOrSetterEvalMacro = function (
+ aItem,
+ aCurrentString,
+ aPrefix = ""
+) {
+ const type = aItem._nameString;
+ const propertyObject = aItem.ownerView;
+ const parentObject = propertyObject.ownerView;
+ const property = escapeString(propertyObject._nameString);
+ const parent = aPrefix + parentObject.symbolicName || "this";
+
+ switch (aCurrentString) {
+ case "":
+ case "null":
+ case "undefined":
+ const mirrorType = type == "get" ? "set" : "get";
+ const mirrorLookup =
+ type == "get" ? "__lookupSetter__" : "__lookupGetter__";
+
+ // If the parent object will end up without any getter or setter,
+ // morph it into a plain value.
+ if (
+ (type == "set" && propertyObject.getter.type == "undefined") ||
+ (type == "get" && propertyObject.setter.type == "undefined")
+ ) {
+ // Make sure the right getter/setter to value override macro is applied
+ // to the target object.
+ return propertyObject.evaluationMacro(
+ propertyObject,
+ "undefined",
+ aPrefix
+ );
+ }
+
+ // Construct and return the getter/setter removal evaluation string.
+ // e.g: Object.defineProperty(foo, "bar", {
+ // get: foo.__lookupGetter__("bar"),
+ // set: undefined,
+ // enumerable: true,
+ // configurable: true
+ // })
+ return (
+ "Object.defineProperty(" +
+ parent +
+ "," +
+ property +
+ "," +
+ "{" +
+ mirrorType +
+ ":" +
+ parent +
+ "." +
+ mirrorLookup +
+ "(" +
+ property +
+ ")" +
+ "," +
+ type +
+ ":" +
+ undefined +
+ ", enumerable: " +
+ parent +
+ ".propertyIsEnumerable(" +
+ property +
+ ")" +
+ ", configurable: true" +
+ "})"
+ );
+
+ default:
+ // Wrap statements inside a function declaration if not already wrapped.
+ if (!aCurrentString.startsWith("function")) {
+ const header = "function(" + (type == "set" ? "value" : "") + ")";
+ let body = "";
+ // If there's a return statement explicitly written, always use the
+ // standard function definition syntax
+ if (aCurrentString.includes("return ")) {
+ body = "{" + aCurrentString + "}";
+ } else if (aCurrentString.startsWith("{")) {
+ // If block syntax is used, use the whole string as the function body.
+ body = aCurrentString;
+ } else {
+ // Prefer an expression closure.
+ body = "(" + aCurrentString + ")";
+ }
+ aCurrentString = header + body;
+ }
+
+ // Determine if a new getter or setter should be defined.
+ const defineType =
+ type == "get" ? "__defineGetter__" : "__defineSetter__";
+
+ // Make sure all quotes are escaped in the expression's syntax,
+ const defineFunc =
+ 'eval("(' + aCurrentString.replace(/"/g, "\\$&") + ')")';
+
+ // Construct and return the getter/setter evaluation string.
+ // e.g: foo.__defineGetter__("bar", eval("(function() { return 42; })"))
+ return (
+ parent + "." + defineType + "(" + property + "," + defineFunc + ")"
+ );
+ }
+};
+
+/**
+ * Function invoked when a getter or setter is deleted.
+ *
+ * @param Property aItem
+ * The current getter or setter property.
+ */
+VariablesView.getterOrSetterDeleteCallback = function (aItem) {
+ aItem._disable();
+
+ // Make sure the right getter/setter to value override macro is applied
+ // to the target object.
+ aItem.ownerView.eval(aItem, "");
+
+ return true; // Don't hide the element.
+};
+
+/**
+ * A Scope is an object holding Variable instances.
+ * Iterable via "for (let [name, variable] of instance) { }".
+ *
+ * @param VariablesView aView
+ * The view to contain this scope.
+ * @param string l10nId
+ * The scope localized string id.
+ * @param object aFlags [optional]
+ * Additional options or flags for this scope.
+ */
+function Scope(aView, l10nId, aFlags = {}) {
+ this.ownerView = aView;
+
+ this._onClick = this._onClick.bind(this);
+ this._openEnum = this._openEnum.bind(this);
+ this._openNonEnum = this._openNonEnum.bind(this);
+
+ // Inherit properties and flags from the parent view. You can override
+ // each of these directly onto any scope, variable or property instance.
+ this.scrollPageSize = aView.scrollPageSize;
+ this.eval = aView.eval;
+ this.switch = aView.switch;
+ this.delete = aView.delete;
+ this.new = aView.new;
+ this.preventDisableOnChange = aView.preventDisableOnChange;
+ this.preventDescriptorModifiers = aView.preventDescriptorModifiers;
+ this.editableNameTooltip = aView.editableNameTooltip;
+ this.editableValueTooltip = aView.editableValueTooltip;
+ this.editButtonTooltip = aView.editButtonTooltip;
+ this.deleteButtonTooltip = aView.deleteButtonTooltip;
+ this.domNodeValueTooltip = aView.domNodeValueTooltip;
+ this.contextMenuId = aView.contextMenuId;
+ this.separatorStr = aView.separatorStr;
+
+ this._init(l10nId, aFlags);
+}
+
+Scope.prototype = {
+ /**
+ * Whether this Scope should be prefetched when it is remoted.
+ */
+ shouldPrefetch: true,
+
+ /**
+ * Whether this Scope should paginate its contents.
+ */
+ allowPaginate: false,
+
+ /**
+ * The class name applied to this scope's target element.
+ */
+ targetClassName: "variables-view-scope",
+
+ /**
+ * Create a new Variable that is a child of this Scope.
+ *
+ * @param string aName
+ * The name of the new Property.
+ * @param object aDescriptor
+ * The variable's descriptor.
+ * @param object aOptions
+ * Options of the form accepted by addItem.
+ * @return Variable
+ * The newly created child Variable.
+ */
+ _createChild(aName, aDescriptor, aOptions) {
+ return new Variable(this, aName, aDescriptor, aOptions);
+ },
+
+ /**
+ * Adds a child to contain any inspected properties.
+ *
+ * @param string aName
+ * The child's name.
+ * @param object aDescriptor
+ * Specifies the value and/or type & class of the child,
+ * or 'get' & 'set' accessor properties. If the type is implicit,
+ * it will be inferred from the value. If this parameter is omitted,
+ * a property without a value will be added (useful for branch nodes).
+ * e.g. - { value: 42 }
+ * - { value: true }
+ * - { value: "nasu" }
+ * - { value: { type: "undefined" } }
+ * - { value: { type: "null" } }
+ * - { value: { type: "object", class: "Object" } }
+ * - { get: { type: "object", class: "Function" },
+ * set: { type: "undefined" } }
+ * @param object aOptions
+ * Specifies some options affecting the new variable.
+ * Recognized properties are
+ * * boolean relaxed true if name duplicates should be allowed.
+ * You probably shouldn't do it. Use this
+ * with caution.
+ * * boolean internalItem true if the item is internally generated.
+ * This is used for special variables
+ * like <return> or <exception> and distinguishes
+ * them from ordinary properties that happen
+ * to have the same name
+ * @return Variable
+ * The newly created Variable instance, null if it already exists.
+ */
+ addItem(aName, aDescriptor = {}, aOptions = {}) {
+ const { relaxed } = aOptions;
+ if (this._store.has(aName) && !relaxed) {
+ return this._store.get(aName);
+ }
+
+ const child = this._createChild(aName, aDescriptor, aOptions);
+ this._store.set(aName, child);
+ this._variablesView._itemsByElement.set(child._target, child);
+ this._variablesView._currHierarchy.set(child.absoluteName, child);
+ child.header = aName !== undefined;
+
+ return child;
+ },
+
+ /**
+ * Adds items for this variable.
+ *
+ * @param object aItems
+ * An object containing some { name: descriptor } data properties,
+ * specifying the value and/or type & class of the variable,
+ * or 'get' & 'set' accessor properties. If the type is implicit,
+ * it will be inferred from the value.
+ * e.g. - { someProp0: { value: 42 },
+ * someProp1: { value: true },
+ * someProp2: { value: "nasu" },
+ * someProp3: { value: { type: "undefined" } },
+ * someProp4: { value: { type: "null" } },
+ * someProp5: { value: { type: "object", class: "Object" } },
+ * someProp6: { get: { type: "object", class: "Function" },
+ * set: { type: "undefined" } } }
+ * @param object aOptions [optional]
+ * Additional options for adding the properties. Supported options:
+ * - sorted: true to sort all the properties before adding them
+ * - callback: function invoked after each item is added
+ */
+ addItems(aItems, aOptions = {}) {
+ const names = Object.keys(aItems);
+
+ // Sort all of the properties before adding them, if preferred.
+ if (aOptions.sorted) {
+ names.sort(this._naturalSort);
+ }
+
+ // Add the properties to the current scope.
+ for (const name of names) {
+ const descriptor = aItems[name];
+ const item = this.addItem(name, descriptor);
+
+ if (aOptions.callback) {
+ aOptions.callback(item, descriptor && descriptor.value);
+ }
+ }
+ },
+
+ /**
+ * Remove this Scope from its parent and remove all children recursively.
+ */
+ remove() {
+ const view = this._variablesView;
+ view._store.splice(view._store.indexOf(this), 1);
+ view._itemsByElement.delete(this._target);
+ view._currHierarchy.delete(this._nameString);
+
+ this._target.remove();
+
+ for (const variable of this._store.values()) {
+ variable.remove();
+ }
+ },
+
+ /**
+ * Gets the variable in this container having the specified name.
+ *
+ * @param string aName
+ * The name of the variable to get.
+ * @return Variable
+ * The matched variable, or null if nothing is found.
+ */
+ get(aName) {
+ return this._store.get(aName);
+ },
+
+ /**
+ * Recursively searches for the variable or property in this container
+ * displayed by the specified node.
+ *
+ * @param Node aNode
+ * The node to search for.
+ * @return Variable | Property
+ * The matched variable or property, or null if nothing is found.
+ */
+ find(aNode) {
+ for (const [, variable] of this._store) {
+ let match;
+ if (variable._target == aNode) {
+ match = variable;
+ } else {
+ match = variable.find(aNode);
+ }
+ if (match) {
+ return match;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Determines if this scope is a direct child of a parent variables view,
+ * scope, variable or property.
+ *
+ * @param VariablesView | Scope | Variable | Property
+ * The parent to check.
+ * @return boolean
+ * True if the specified item is a direct child, false otherwise.
+ */
+ isChildOf(aParent) {
+ return this.ownerView == aParent;
+ },
+
+ /**
+ * Determines if this scope is a descendant of a parent variables view,
+ * scope, variable or property.
+ *
+ * @param VariablesView | Scope | Variable | Property
+ * The parent to check.
+ * @return boolean
+ * True if the specified item is a descendant, false otherwise.
+ */
+ isDescendantOf(aParent) {
+ if (this.isChildOf(aParent)) {
+ return true;
+ }
+
+ // Recurse to parent if it is a Scope, Variable, or Property.
+ if (this.ownerView instanceof Scope) {
+ return this.ownerView.isDescendantOf(aParent);
+ }
+
+ return false;
+ },
+
+ /**
+ * Shows the scope.
+ */
+ show() {
+ this._target.hidden = false;
+ this._isContentVisible = true;
+
+ if (this.onshow) {
+ this.onshow(this);
+ }
+ },
+
+ /**
+ * Hides the scope.
+ */
+ hide() {
+ this._target.hidden = true;
+ this._isContentVisible = false;
+
+ if (this.onhide) {
+ this.onhide(this);
+ }
+ },
+
+ /**
+ * Expands the scope, showing all the added details.
+ */
+ async expand() {
+ if (this._isExpanded || this._isLocked) {
+ return;
+ }
+ if (this._variablesView._enumVisible) {
+ this._openEnum();
+ }
+ if (this._variablesView._nonEnumVisible) {
+ Services.tm.dispatchToMainThread({ run: this._openNonEnum });
+ }
+ this._isExpanded = true;
+
+ if (this.onexpand) {
+ // We return onexpand as it sometimes returns a promise
+ // (up to the user of VariableView to do it)
+ // that can indicate when the view is done expanding
+ // and attributes are available. (Mostly used for tests)
+ await this.onexpand(this);
+ }
+ },
+
+ /**
+ * Collapses the scope, hiding all the added details.
+ */
+ collapse() {
+ if (!this._isExpanded || this._isLocked) {
+ return;
+ }
+ this._arrow.removeAttribute("open");
+ this._enum.removeAttribute("open");
+ this._nonenum.removeAttribute("open");
+ this._isExpanded = false;
+
+ if (this.oncollapse) {
+ this.oncollapse(this);
+ }
+ },
+
+ /**
+ * Toggles between the scope's collapsed and expanded state.
+ */
+ toggle(e) {
+ if (e && e.button != 0) {
+ // Only allow left-click to trigger this event.
+ return;
+ }
+ this.expanded ^= 1;
+
+ // Make sure the scope and its contents are visibile.
+ for (const [, variable] of this._store) {
+ variable.header = true;
+ variable._matched = true;
+ }
+ if (this.ontoggle) {
+ this.ontoggle(this);
+ }
+ },
+
+ /**
+ * Shows the scope's title header.
+ */
+ showHeader() {
+ if (this._isHeaderVisible || !this._nameString) {
+ return;
+ }
+ this._target.removeAttribute("untitled");
+ this._isHeaderVisible = true;
+ },
+
+ /**
+ * Hides the scope's title header.
+ * This action will automatically expand the scope.
+ */
+ hideHeader() {
+ if (!this._isHeaderVisible) {
+ return;
+ }
+ this.expand();
+ this._target.setAttribute("untitled", "");
+ this._isHeaderVisible = false;
+ },
+
+ /**
+ * Sort in ascending order
+ * This only needs to compare non-numbers since it is dealing with an array
+ * which numeric-based indices are placed in order.
+ *
+ * @param string a
+ * @param string b
+ * @return number
+ * -1 if a is less than b, 0 if no change in order, +1 if a is greater than 0
+ */
+ _naturalSort(a, b) {
+ if (isNaN(parseFloat(a)) && isNaN(parseFloat(b))) {
+ return a < b ? -1 : 1;
+ }
+ return 0;
+ },
+
+ /**
+ * Shows the scope's expand/collapse arrow.
+ */
+ showArrow() {
+ if (this._isArrowVisible) {
+ return;
+ }
+ this._arrow.removeAttribute("invisible");
+ this._isArrowVisible = true;
+ },
+
+ /**
+ * Hides the scope's expand/collapse arrow.
+ */
+ hideArrow() {
+ if (!this._isArrowVisible) {
+ return;
+ }
+ this._arrow.setAttribute("invisible", "");
+ this._isArrowVisible = false;
+ },
+
+ /**
+ * Gets the visibility state.
+ * @return boolean
+ */
+ get visible() {
+ return this._isContentVisible;
+ },
+
+ /**
+ * Gets the expanded state.
+ * @return boolean
+ */
+ get expanded() {
+ return this._isExpanded;
+ },
+
+ /**
+ * Gets the header visibility state.
+ * @return boolean
+ */
+ get header() {
+ return this._isHeaderVisible;
+ },
+
+ /**
+ * Gets the twisty visibility state.
+ * @return boolean
+ */
+ get twisty() {
+ return this._isArrowVisible;
+ },
+
+ /**
+ * Gets the expand lock state.
+ * @return boolean
+ */
+ get locked() {
+ return this._isLocked;
+ },
+
+ /**
+ * Sets the visibility state.
+ * @param boolean aFlag
+ */
+ set visible(aFlag) {
+ aFlag ? this.show() : this.hide();
+ },
+
+ /**
+ * Sets the expanded state.
+ * @param boolean aFlag
+ */
+ set expanded(aFlag) {
+ aFlag ? this.expand() : this.collapse();
+ },
+
+ /**
+ * Sets the header visibility state.
+ * @param boolean aFlag
+ */
+ set header(aFlag) {
+ aFlag ? this.showHeader() : this.hideHeader();
+ },
+
+ /**
+ * Sets the twisty visibility state.
+ * @param boolean aFlag
+ */
+ set twisty(aFlag) {
+ aFlag ? this.showArrow() : this.hideArrow();
+ },
+
+ /**
+ * Sets the expand lock state.
+ * @param boolean aFlag
+ */
+ set locked(aFlag) {
+ this._isLocked = aFlag;
+ },
+
+ /**
+ * Specifies if this target node may be focused.
+ * @return boolean
+ */
+ get focusable() {
+ // Check if this target node is actually visibile.
+ if (
+ !this._nameString ||
+ !this._isContentVisible ||
+ !this._isHeaderVisible ||
+ !this._isMatch
+ ) {
+ return false;
+ }
+ // Check if all parent objects are expanded.
+ let item = this;
+
+ // Recurse while parent is a Scope, Variable, or Property
+ while ((item = item.ownerView) && item instanceof Scope) {
+ if (!item._isExpanded) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ /**
+ * Focus this scope.
+ */
+ focus() {
+ this._variablesView._focusItem(this);
+ },
+
+ /**
+ * Adds an event listener for a certain event on this scope's title.
+ * @param string aName
+ * @param function aCallback
+ * @param boolean aCapture
+ */
+ addEventListener(aName, aCallback, aCapture) {
+ this._title.addEventListener(aName, aCallback, aCapture);
+ },
+
+ /**
+ * Removes an event listener for a certain event on this scope's title.
+ * @param string aName
+ * @param function aCallback
+ * @param boolean aCapture
+ */
+ removeEventListener(aName, aCallback, aCapture) {
+ this._title.removeEventListener(aName, aCallback, aCapture);
+ },
+
+ /**
+ * Gets the id associated with this item.
+ * @return string
+ */
+ get id() {
+ return this._idString;
+ },
+
+ /**
+ * Gets the name associated with this item.
+ * @return string
+ */
+ get name() {
+ return this._nameString;
+ },
+
+ /**
+ * Gets the displayed value for this item.
+ * @return string
+ */
+ get displayValue() {
+ return this._valueString;
+ },
+
+ /**
+ * Gets the class names used for the displayed value.
+ * @return string
+ */
+ get displayValueClassName() {
+ return this._valueClassName;
+ },
+
+ /**
+ * Gets the element associated with this item.
+ * @return Node
+ */
+ get target() {
+ return this._target;
+ },
+
+ /**
+ * Initializes this scope's id, view and binds event listeners.
+ *
+ * @param string l10nId
+ * The scope localized string id.
+ * @param object aFlags [optional]
+ * Additional options or flags for this scope.
+ */
+ _init(l10nId, aFlags) {
+ this._idString = generateId((this._nameString = l10nId));
+ this._displayScope({
+ l10nId,
+ targetClassName: `${this.targetClassName} ${aFlags.customClass}`,
+ titleClassName: "devtools-toolbar",
+ });
+ this._addEventListeners();
+ this.parentNode.appendChild(this._target);
+ },
+
+ /**
+ * Creates the necessary nodes for this scope.
+ *
+ * @param Object options
+ * @param string options.l10nId [optional]
+ * The scope localized string id.
+ * @param string options.value [optional]
+ * The scope's name. Either this or l10nId need to be passed
+ * @param string options.targetClassName
+ * A custom class name for this scope's target element.
+ * @param string options.titleClassName [optional]
+ * A custom class name for this scope's title element.
+ */
+ _displayScope({ l10nId, value, targetClassName, titleClassName = "" }) {
+ const document = this.document;
+
+ const element = (this._target = document.createXULElement("vbox"));
+ element.id = this._idString;
+ element.className = targetClassName;
+
+ const arrow = (this._arrow = document.createXULElement("hbox"));
+ arrow.className = "arrow theme-twisty";
+
+ const name = (this._name = document.createXULElement("label"));
+ name.className = "plain name";
+ if (l10nId) {
+ name.setAttribute("data-l10n-id", l10nId);
+ } else {
+ name.setAttribute("value", value);
+ }
+ name.setAttribute("crop", "end");
+
+ const title = (this._title = document.createXULElement("hbox"));
+ title.className = "title " + titleClassName;
+ title.setAttribute("align", "center");
+
+ const enumerable = (this._enum = document.createXULElement("vbox"));
+ const nonenum = (this._nonenum = document.createXULElement("vbox"));
+ enumerable.className = "variables-view-element-details enum";
+ nonenum.className = "variables-view-element-details nonenum";
+
+ title.appendChild(arrow);
+ title.appendChild(name);
+
+ element.appendChild(title);
+ element.appendChild(enumerable);
+ element.appendChild(nonenum);
+ },
+
+ /**
+ * Adds the necessary event listeners for this scope.
+ */
+ _addEventListeners() {
+ this._title.addEventListener("mousedown", this._onClick);
+ },
+
+ /**
+ * The click listener for this scope's title.
+ */
+ _onClick(e) {
+ if (
+ this.editing ||
+ e.button != 0 ||
+ e.target == this._editNode ||
+ e.target == this._deleteNode ||
+ e.target == this._addPropertyNode
+ ) {
+ return;
+ }
+ this.toggle();
+ this.focus();
+ },
+
+ /**
+ * Opens the enumerable items container.
+ */
+ _openEnum() {
+ this._arrow.setAttribute("open", "");
+ this._enum.setAttribute("open", "");
+ },
+
+ /**
+ * Opens the non-enumerable items container.
+ */
+ _openNonEnum() {
+ this._nonenum.setAttribute("open", "");
+ },
+
+ /**
+ * Specifies if enumerable properties and variables should be displayed.
+ * @param boolean aFlag
+ */
+ set _enumVisible(aFlag) {
+ for (const [, variable] of this._store) {
+ variable._enumVisible = aFlag;
+
+ if (!this._isExpanded) {
+ continue;
+ }
+ if (aFlag) {
+ this._enum.setAttribute("open", "");
+ } else {
+ this._enum.removeAttribute("open");
+ }
+ }
+ },
+
+ /**
+ * Specifies if non-enumerable properties and variables should be displayed.
+ * @param boolean aFlag
+ */
+ set _nonEnumVisible(aFlag) {
+ for (const [, variable] of this._store) {
+ variable._nonEnumVisible = aFlag;
+
+ if (!this._isExpanded) {
+ continue;
+ }
+ if (aFlag) {
+ this._nonenum.setAttribute("open", "");
+ } else {
+ this._nonenum.removeAttribute("open");
+ }
+ }
+ },
+
+ /**
+ * Performs a case insensitive search for variables or properties matching
+ * the query, and hides non-matched items.
+ *
+ * @param string aLowerCaseQuery
+ * The lowercased name of the variable or property to search for.
+ */
+ _performSearch(aLowerCaseQuery) {
+ for (let [, variable] of this._store) {
+ const currentObject = variable;
+ const lowerCaseName = variable._nameString.toLowerCase();
+ const lowerCaseValue = variable._valueString.toLowerCase();
+
+ // Non-matched variables or properties require a corresponding attribute.
+ if (
+ !lowerCaseName.includes(aLowerCaseQuery) &&
+ !lowerCaseValue.includes(aLowerCaseQuery)
+ ) {
+ variable._matched = false;
+ } else {
+ // Variable or property is matched.
+ variable._matched = true;
+
+ // If the variable was ever expanded, there's a possibility it may
+ // contain some matched properties, so make sure they're visible
+ // ("expand downwards").
+ if (variable._store.size) {
+ variable.expand();
+ }
+
+ // If the variable is contained in another Scope, Variable, or Property,
+ // the parent may not be a match, thus hidden. It should be visible
+ // ("expand upwards").
+ while ((variable = variable.ownerView) && variable instanceof Scope) {
+ variable._matched = true;
+ variable.expand();
+ }
+ }
+
+ // Proceed with the search recursively inside this variable or property.
+ if (
+ currentObject._store.size ||
+ currentObject.getter ||
+ currentObject.setter
+ ) {
+ currentObject._performSearch(aLowerCaseQuery);
+ }
+ }
+ },
+
+ /**
+ * Sets if this object instance is a matched or non-matched item.
+ * @param boolean aStatus
+ */
+ set _matched(aStatus) {
+ if (this._isMatch == aStatus) {
+ return;
+ }
+ if (aStatus) {
+ this._isMatch = true;
+ this.target.removeAttribute("unmatched");
+ } else {
+ this._isMatch = false;
+ this.target.setAttribute("unmatched", "");
+ }
+ },
+
+ /**
+ * Find the first item in the tree of visible items in this item that matches
+ * the predicate. Searches in visual order (the order seen by the user).
+ * Tests itself, then descends into first the enumerable children and then
+ * the non-enumerable children (since they are presented in separate groups).
+ *
+ * @param function aPredicate
+ * A function that returns true when a match is found.
+ * @return Scope | Variable | Property
+ * The first visible scope, variable or property, or null if nothing
+ * is found.
+ */
+ _findInVisibleItems(aPredicate) {
+ if (aPredicate(this)) {
+ return this;
+ }
+
+ if (this._isExpanded) {
+ if (this._variablesView._enumVisible) {
+ for (const item of this._enumItems) {
+ const result = item._findInVisibleItems(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ }
+
+ if (this._variablesView._nonEnumVisible) {
+ for (const item of this._nonEnumItems) {
+ const result = item._findInVisibleItems(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Find the last item in the tree of visible items in this item that matches
+ * the predicate. Searches in reverse visual order (opposite of the order
+ * seen by the user). Descends into first the non-enumerable children, then
+ * the enumerable children (since they are presented in separate groups), and
+ * finally tests itself.
+ *
+ * @param function aPredicate
+ * A function that returns true when a match is found.
+ * @return Scope | Variable | Property
+ * The last visible scope, variable or property, or null if nothing
+ * is found.
+ */
+ _findInVisibleItemsReverse(aPredicate) {
+ if (this._isExpanded) {
+ if (this._variablesView._nonEnumVisible) {
+ for (let i = this._nonEnumItems.length - 1; i >= 0; i--) {
+ const item = this._nonEnumItems[i];
+ const result = item._findInVisibleItemsReverse(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ }
+
+ if (this._variablesView._enumVisible) {
+ for (let i = this._enumItems.length - 1; i >= 0; i--) {
+ const item = this._enumItems[i];
+ const result = item._findInVisibleItemsReverse(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ }
+ }
+
+ if (aPredicate(this)) {
+ return this;
+ }
+
+ return null;
+ },
+
+ /**
+ * Gets top level variables view instance.
+ * @return VariablesView
+ */
+ get _variablesView() {
+ return (
+ this._topView ||
+ (this._topView = (() => {
+ let parentView = this.ownerView;
+ let topView;
+
+ while ((topView = parentView.ownerView)) {
+ parentView = topView;
+ }
+ return parentView;
+ })())
+ );
+ },
+
+ /**
+ * Gets the parent node holding this scope.
+ * @return Node
+ */
+ get parentNode() {
+ return this.ownerView._list;
+ },
+
+ /**
+ * Gets the owner document holding this scope.
+ * @return HTMLDocument
+ */
+ get document() {
+ return this._document || (this._document = this.ownerView.document);
+ },
+
+ /**
+ * Gets the default window holding this scope.
+ * @return nsIDOMWindow
+ */
+ get window() {
+ return this._window || (this._window = this.ownerView.window);
+ },
+
+ _topView: null,
+ _document: null,
+ _window: null,
+
+ ownerView: null,
+ eval: null,
+ switch: null,
+ delete: null,
+ new: null,
+ preventDisableOnChange: false,
+ preventDescriptorModifiers: false,
+ editing: false,
+ editableNameTooltip: "",
+ editableValueTooltip: "",
+ editButtonTooltip: "",
+ deleteButtonTooltip: "",
+ domNodeValueTooltip: "",
+ contextMenuId: "",
+ separatorStr: "",
+
+ _store: null,
+ _enumItems: null,
+ _nonEnumItems: null,
+ _fetched: false,
+ _committed: false,
+ _isLocked: false,
+ _isExpanded: false,
+ _isContentVisible: true,
+ _isHeaderVisible: true,
+ _isArrowVisible: true,
+ _isMatch: true,
+ _idString: "",
+ _nameString: "",
+ _target: null,
+ _arrow: null,
+ _name: null,
+ _title: null,
+ _enum: null,
+ _nonenum: null,
+};
+
+// Creating maps and arrays thousands of times for variables or properties
+// with a large number of children fills up a lot of memory. Make sure
+// these are instantiated only if needed.
+DevToolsUtils.defineLazyPrototypeGetter(
+ Scope.prototype,
+ "_store",
+ () => new Map()
+);
+DevToolsUtils.defineLazyPrototypeGetter(Scope.prototype, "_enumItems", Array);
+DevToolsUtils.defineLazyPrototypeGetter(
+ Scope.prototype,
+ "_nonEnumItems",
+ Array
+);
+
+/**
+ * A Variable is a Scope holding Property instances.
+ * Iterable via "for (let [name, property] of instance) { }".
+ *
+ * @param Scope aScope
+ * The scope to contain this variable.
+ * @param string aName
+ * The variable's name.
+ * @param object aDescriptor
+ * The variable's descriptor.
+ * @param object aOptions
+ * Options of the form accepted by Scope.addItem
+ */
+function Variable(aScope, aName, aDescriptor, aOptions) {
+ this._setTooltips = this._setTooltips.bind(this);
+ this._activateNameInput = this._activateNameInput.bind(this);
+ this._activateValueInput = this._activateValueInput.bind(this);
+ this.openNodeInInspector = this.openNodeInInspector.bind(this);
+ this.highlightDomNode = this.highlightDomNode.bind(this);
+ this.unhighlightDomNode = this.unhighlightDomNode.bind(this);
+ this._internalItem = aOptions.internalItem;
+
+ // Treat safe getter descriptors as descriptors with a value.
+ if ("getterValue" in aDescriptor) {
+ aDescriptor.value = aDescriptor.getterValue;
+ delete aDescriptor.get;
+ delete aDescriptor.set;
+ }
+
+ Scope.call(this, aScope, aName, (this._initialDescriptor = aDescriptor));
+ this.setGrip(aDescriptor.value);
+}
+
+Variable.prototype = extend(Scope.prototype, {
+ /**
+ * Whether this Variable should be prefetched when it is remoted.
+ */
+ get shouldPrefetch() {
+ return this.name == "window" || this.name == "this";
+ },
+
+ /**
+ * Whether this Variable should paginate its contents.
+ */
+ get allowPaginate() {
+ return this.name != "window" && this.name != "this";
+ },
+
+ /**
+ * The class name applied to this variable's target element.
+ */
+ targetClassName: "variables-view-variable variable-or-property",
+
+ /**
+ * Create a new Property that is a child of Variable.
+ *
+ * @param string aName
+ * The name of the new Property.
+ * @param object aDescriptor
+ * The property's descriptor.
+ * @param object aOptions
+ * Options of the form accepted by Scope.addItem
+ * @return Property
+ * The newly created child Property.
+ */
+ _createChild(aName, aDescriptor, aOptions) {
+ return new Property(this, aName, aDescriptor, aOptions);
+ },
+
+ /**
+ * Remove this Variable from its parent and remove all children recursively.
+ */
+ remove() {
+ if (this._linkedToInspector) {
+ this.unhighlightDomNode();
+ this._valueLabel.removeEventListener("mouseover", this.highlightDomNode);
+ this._valueLabel.removeEventListener("mouseout", this.unhighlightDomNode);
+ this._openInspectorNode.removeEventListener(
+ "mousedown",
+ this.openNodeInInspector
+ );
+ }
+
+ this.ownerView._store.delete(this._nameString);
+ this._variablesView._itemsByElement.delete(this._target);
+ this._variablesView._currHierarchy.delete(this.absoluteName);
+
+ this._target.remove();
+
+ for (const property of this._store.values()) {
+ property.remove();
+ }
+ },
+
+ /**
+ * Populates this variable to contain all the properties of an object.
+ *
+ * @param object aObject
+ * The raw object you want to display.
+ * @param object aOptions [optional]
+ * Additional options for adding the properties. Supported options:
+ * - sorted: true to sort all the properties before adding them
+ * - expanded: true to expand all the properties after adding them
+ */
+ populate(aObject, aOptions = {}) {
+ // Retrieve the properties only once.
+ if (this._fetched) {
+ return;
+ }
+ this._fetched = true;
+
+ const propertyNames = Object.getOwnPropertyNames(aObject);
+ const prototype = Object.getPrototypeOf(aObject);
+
+ // Sort all of the properties before adding them, if preferred.
+ if (aOptions.sorted) {
+ propertyNames.sort(this._naturalSort);
+ }
+
+ // Add all the variable properties.
+ for (const name of propertyNames) {
+ const descriptor = Object.getOwnPropertyDescriptor(aObject, name);
+ if (descriptor.get || descriptor.set) {
+ const prop = this._addRawNonValueProperty(name, descriptor);
+ if (aOptions.expanded) {
+ prop.expanded = true;
+ }
+ } else {
+ const prop = this._addRawValueProperty(name, descriptor, aObject[name]);
+ if (aOptions.expanded) {
+ prop.expanded = true;
+ }
+ }
+ }
+ // Add the variable's __proto__.
+ if (prototype) {
+ this._addRawValueProperty("__proto__", {}, prototype);
+ }
+ },
+
+ /**
+ * Populates a specific variable or property instance to contain all the
+ * properties of an object
+ *
+ * @param Variable | Property aVar
+ * The target variable to populate.
+ * @param object aObject [optional]
+ * The raw object you want to display. If unspecified, the object is
+ * assumed to be defined in a _sourceValue property on the target.
+ */
+ _populateTarget(aVar, aObject = aVar._sourceValue) {
+ aVar.populate(aObject);
+ },
+
+ /**
+ * Adds a property for this variable based on a raw value descriptor.
+ *
+ * @param string aName
+ * The property's name.
+ * @param object aDescriptor
+ * Specifies the exact property descriptor as returned by a call to
+ * Object.getOwnPropertyDescriptor.
+ * @param object aValue
+ * The raw property value you want to display.
+ * @return Property
+ * The newly added property instance.
+ */
+ _addRawValueProperty(aName, aDescriptor, aValue) {
+ const descriptor = Object.create(aDescriptor);
+ descriptor.value = VariablesView.getGrip(aValue);
+
+ const propertyItem = this.addItem(aName, descriptor);
+ propertyItem._sourceValue = aValue;
+
+ // Add an 'onexpand' callback for the property, lazily handling
+ // the addition of new child properties.
+ if (!VariablesView.isPrimitive(descriptor)) {
+ propertyItem.onexpand = this._populateTarget;
+ }
+ return propertyItem;
+ },
+
+ /**
+ * Adds a property for this variable based on a getter/setter descriptor.
+ *
+ * @param string aName
+ * The property's name.
+ * @param object aDescriptor
+ * Specifies the exact property descriptor as returned by a call to
+ * Object.getOwnPropertyDescriptor.
+ * @return Property
+ * The newly added property instance.
+ */
+ _addRawNonValueProperty(aName, aDescriptor) {
+ const descriptor = Object.create(aDescriptor);
+ descriptor.get = VariablesView.getGrip(aDescriptor.get);
+ descriptor.set = VariablesView.getGrip(aDescriptor.set);
+
+ return this.addItem(aName, descriptor);
+ },
+
+ /**
+ * Gets this variable's path to the topmost scope in the form of a string
+ * meant for use via eval() or a similar approach.
+ * For example, a symbolic name may look like "arguments['0']['foo']['bar']".
+ * @return string
+ */
+ get symbolicName() {
+ return this._nameString || "";
+ },
+
+ /**
+ * Gets full path to this variable, including name of the scope.
+ * @return string
+ */
+ get absoluteName() {
+ if (this._absoluteName) {
+ return this._absoluteName;
+ }
+
+ this._absoluteName =
+ this.ownerView._nameString + "[" + escapeString(this._nameString) + "]";
+ return this._absoluteName;
+ },
+
+ /**
+ * Gets this variable's symbolic path to the topmost scope.
+ * @return array
+ * @see Variable._buildSymbolicPath
+ */
+ get symbolicPath() {
+ if (this._symbolicPath) {
+ return this._symbolicPath;
+ }
+ this._symbolicPath = this._buildSymbolicPath();
+ return this._symbolicPath;
+ },
+
+ /**
+ * Build this variable's path to the topmost scope in form of an array of
+ * strings, one for each segment of the path.
+ * For example, a symbolic path may look like ["0", "foo", "bar"].
+ * @return array
+ */
+ _buildSymbolicPath(path = []) {
+ if (this.name) {
+ path.unshift(this.name);
+ if (this.ownerView instanceof Variable) {
+ return this.ownerView._buildSymbolicPath(path);
+ }
+ }
+ return path;
+ },
+
+ /**
+ * Returns this variable's value from the descriptor if available.
+ * @return any
+ */
+ get value() {
+ return this._initialDescriptor.value;
+ },
+
+ /**
+ * Returns this variable's getter from the descriptor if available.
+ * @return object
+ */
+ get getter() {
+ return this._initialDescriptor.get;
+ },
+
+ /**
+ * Returns this variable's getter from the descriptor if available.
+ * @return object
+ */
+ get setter() {
+ return this._initialDescriptor.set;
+ },
+
+ /**
+ * Sets the specific grip for this variable (applies the text content and
+ * class name to the value label).
+ *
+ * The grip should contain the value or the type & class, as defined in the
+ * remote debugger protocol. For convenience, undefined and null are
+ * both considered types.
+ *
+ * @param any aGrip
+ * Specifies the value and/or type & class of the variable.
+ * e.g. - 42
+ * - true
+ * - "nasu"
+ * - { type: "undefined" }
+ * - { type: "null" }
+ * - { type: "object", class: "Object" }
+ */
+ setGrip(aGrip) {
+ // Don't allow displaying grip information if there's no name available
+ // or the grip is malformed.
+ if (
+ this._nameString === undefined ||
+ aGrip === undefined ||
+ aGrip === null
+ ) {
+ return;
+ }
+ // Getters and setters should display grip information in sub-properties.
+ if (this.getter || this.setter) {
+ return;
+ }
+
+ const prevGrip = this._valueGrip;
+ if (prevGrip) {
+ this._valueLabel.classList.remove(VariablesView.getClass(prevGrip));
+ }
+ this._valueGrip = aGrip;
+
+ if (
+ aGrip &&
+ (aGrip.optimizedOut || aGrip.uninitialized || aGrip.missingArguments)
+ ) {
+ if (aGrip.optimizedOut) {
+ this._valueString = L10N.getStr("variablesViewOptimizedOut");
+ } else if (aGrip.uninitialized) {
+ this._valueString = L10N.getStr("variablesViewUninitialized");
+ } else if (aGrip.missingArguments) {
+ this._valueString = L10N.getStr("variablesViewMissingArgs");
+ }
+ this.eval = null;
+ } else {
+ this._valueString = VariablesView.getString(aGrip, {
+ concise: true,
+ noEllipsis: true,
+ });
+ this.eval = this.ownerView.eval;
+ }
+
+ this._valueClassName = VariablesView.getClass(aGrip);
+
+ this._valueLabel.classList.add(this._valueClassName);
+ this._valueLabel.setAttribute("value", this._valueString);
+ this._separatorLabel.hidden = false;
+
+ // DOMNodes get special treatment since they can be linked to the inspector
+ if (this._valueGrip.preview && this._valueGrip.preview.kind === "DOMNode") {
+ this._linkToInspector();
+ }
+ },
+
+ /**
+ * Marks this variable as overridden.
+ *
+ * @param boolean aFlag
+ * Whether this variable is overridden or not.
+ */
+ setOverridden(aFlag) {
+ if (aFlag) {
+ this._target.setAttribute("overridden", "");
+ } else {
+ this._target.removeAttribute("overridden");
+ }
+ },
+
+ /**
+ * Briefly flashes this variable.
+ *
+ * @param number aDuration [optional]
+ * An optional flash animation duration.
+ */
+ flash(aDuration = ITEM_FLASH_DURATION) {
+ const fadeInDelay = this._variablesView.lazyEmptyDelay + 1;
+ const fadeOutDelay = fadeInDelay + aDuration;
+
+ setNamedTimeout("vview-flash-in" + this.absoluteName, fadeInDelay, () =>
+ this._target.setAttribute("changed", "")
+ );
+
+ setNamedTimeout("vview-flash-out" + this.absoluteName, fadeOutDelay, () =>
+ this._target.removeAttribute("changed")
+ );
+ },
+
+ /**
+ * Initializes this variable's id, view and binds event listeners.
+ *
+ * @param string aName
+ * The variable's name.
+ * @param object aDescriptor
+ * The variable's descriptor.
+ */
+ _init(aName, aDescriptor) {
+ this._idString = generateId((this._nameString = aName));
+ this._displayScope({ value: aName, targetClassName: this.targetClassName });
+ this._displayVariable();
+ this._customizeVariable();
+ this._prepareTooltips();
+ this._setAttributes();
+ this._addEventListeners();
+
+ if (
+ this._initialDescriptor.enumerable ||
+ this._nameString == "this" ||
+ this._internalItem
+ ) {
+ this.ownerView._enum.appendChild(this._target);
+ this.ownerView._enumItems.push(this);
+ } else {
+ this.ownerView._nonenum.appendChild(this._target);
+ this.ownerView._nonEnumItems.push(this);
+ }
+ },
+
+ /**
+ * Creates the necessary nodes for this variable.
+ */
+ _displayVariable() {
+ const document = this.document;
+ const descriptor = this._initialDescriptor;
+
+ const separatorLabel = (this._separatorLabel =
+ document.createXULElement("label"));
+ separatorLabel.className = "plain separator";
+ separatorLabel.setAttribute("value", this.separatorStr + " ");
+
+ const valueLabel = (this._valueLabel = document.createXULElement("label"));
+ valueLabel.className = "plain value";
+ valueLabel.setAttribute("flex", "1");
+ valueLabel.setAttribute("crop", "center");
+
+ this._title.appendChild(separatorLabel);
+ this._title.appendChild(valueLabel);
+
+ if (VariablesView.isPrimitive(descriptor)) {
+ this.hideArrow();
+ }
+
+ // If no value will be displayed, we don't need the separator.
+ if (!descriptor.get && !descriptor.set && !("value" in descriptor)) {
+ separatorLabel.hidden = true;
+ }
+
+ // If this is a getter/setter property, create two child pseudo-properties
+ // called "get" and "set" that display the corresponding functions.
+ if (descriptor.get || descriptor.set) {
+ separatorLabel.hidden = true;
+ valueLabel.hidden = true;
+
+ // Changing getter/setter names is never allowed.
+ this.switch = null;
+
+ // Getter/setter properties require special handling when it comes to
+ // evaluation and deletion.
+ if (this.ownerView.eval) {
+ this.delete = VariablesView.getterOrSetterDeleteCallback;
+ this.evaluationMacro = VariablesView.overrideValueEvalMacro;
+ } else {
+ // Deleting getters and setters individually is not allowed if no
+ // evaluation method is provided.
+ this.delete = null;
+ this.evaluationMacro = null;
+ }
+
+ const getter = this.addItem("get", { value: descriptor.get });
+ const setter = this.addItem("set", { value: descriptor.set });
+ getter.evaluationMacro = VariablesView.getterOrSetterEvalMacro;
+ setter.evaluationMacro = VariablesView.getterOrSetterEvalMacro;
+
+ getter.hideArrow();
+ setter.hideArrow();
+ this.expand();
+ }
+ },
+
+ /**
+ * Adds specific nodes for this variable based on custom flags.
+ */
+ _customizeVariable() {
+ const ownerView = this.ownerView;
+ const descriptor = this._initialDescriptor;
+
+ if ((ownerView.eval && this.getter) || this.setter) {
+ const editNode = (this._editNode =
+ this.document.createXULElement("toolbarbutton"));
+ editNode.className = "plain variables-view-edit";
+ editNode.addEventListener("mousedown", this._onEdit.bind(this));
+ this._title.insertBefore(editNode, this._spacer);
+ }
+
+ if (ownerView.delete) {
+ const deleteNode = (this._deleteNode =
+ this.document.createXULElement("toolbarbutton"));
+ deleteNode.className = "plain variables-view-delete";
+ deleteNode.addEventListener("click", this._onDelete.bind(this));
+ this._title.appendChild(deleteNode);
+ }
+
+ if (ownerView.new) {
+ const addPropertyNode = (this._addPropertyNode =
+ this.document.createXULElement("toolbarbutton"));
+ addPropertyNode.className = "plain variables-view-add-property";
+ addPropertyNode.addEventListener(
+ "mousedown",
+ this._onAddProperty.bind(this)
+ );
+ this._title.appendChild(addPropertyNode);
+
+ // Can't add properties to primitive values, hide the node in those cases.
+ if (VariablesView.isPrimitive(descriptor)) {
+ addPropertyNode.setAttribute("invisible", "");
+ }
+ }
+
+ if (ownerView.contextMenuId) {
+ this._title.setAttribute("context", ownerView.contextMenuId);
+ }
+
+ if (ownerView.preventDescriptorModifiers) {
+ return;
+ }
+
+ if (!descriptor.writable && !ownerView.getter && !ownerView.setter) {
+ const nonWritableIcon = this.document.createXULElement("hbox");
+ nonWritableIcon.className =
+ "plain variable-or-property-non-writable-icon";
+ nonWritableIcon.setAttribute("optional-visibility", "");
+ this._title.appendChild(nonWritableIcon);
+ }
+ if (descriptor.value && typeof descriptor.value == "object") {
+ if (descriptor.value.frozen) {
+ const frozenLabel = this.document.createXULElement("label");
+ frozenLabel.className = "plain variable-or-property-frozen-label";
+ frozenLabel.setAttribute("optional-visibility", "");
+ frozenLabel.setAttribute("value", "F");
+ this._title.appendChild(frozenLabel);
+ }
+ if (descriptor.value.sealed) {
+ const sealedLabel = this.document.createXULElement("label");
+ sealedLabel.className = "plain variable-or-property-sealed-label";
+ sealedLabel.setAttribute("optional-visibility", "");
+ sealedLabel.setAttribute("value", "S");
+ this._title.appendChild(sealedLabel);
+ }
+ if (!descriptor.value.extensible) {
+ const nonExtensibleLabel = this.document.createXULElement("label");
+ nonExtensibleLabel.className =
+ "plain variable-or-property-non-extensible-label";
+ nonExtensibleLabel.setAttribute("optional-visibility", "");
+ nonExtensibleLabel.setAttribute("value", "N");
+ this._title.appendChild(nonExtensibleLabel);
+ }
+ }
+ },
+
+ /**
+ * Prepares all tooltips for this variable.
+ */
+ _prepareTooltips() {
+ this._target.addEventListener("mouseover", this._setTooltips);
+ },
+
+ /**
+ * Sets all tooltips for this variable.
+ */
+ _setTooltips() {
+ this._target.removeEventListener("mouseover", this._setTooltips);
+
+ const ownerView = this.ownerView;
+ if (ownerView.preventDescriptorModifiers) {
+ return;
+ }
+
+ const tooltip = this.document.createXULElement("tooltip");
+ tooltip.id = "tooltip-" + this._idString;
+ tooltip.setAttribute("orient", "horizontal");
+
+ const labels = [
+ "configurable",
+ "enumerable",
+ "writable",
+ "frozen",
+ "sealed",
+ "extensible",
+ "overridden",
+ "WebIDL",
+ ];
+
+ for (const type of labels) {
+ const labelElement = this.document.createXULElement("label");
+ labelElement.className = type;
+ labelElement.setAttribute("value", L10N.getStr(type + "Tooltip"));
+ tooltip.appendChild(labelElement);
+ }
+
+ this._target.appendChild(tooltip);
+ this._target.setAttribute("tooltip", tooltip.id);
+
+ if (this._editNode && ownerView.eval) {
+ this._editNode.setAttribute("tooltiptext", ownerView.editButtonTooltip);
+ }
+ if (this._openInspectorNode && this._linkedToInspector) {
+ this._openInspectorNode.setAttribute(
+ "tooltiptext",
+ this.ownerView.domNodeValueTooltip
+ );
+ }
+ if (this._valueLabel && ownerView.eval) {
+ this._valueLabel.setAttribute(
+ "tooltiptext",
+ ownerView.editableValueTooltip
+ );
+ }
+ if (this._name && ownerView.switch) {
+ this._name.setAttribute("tooltiptext", ownerView.editableNameTooltip);
+ }
+ if (this._deleteNode && ownerView.delete) {
+ this._deleteNode.setAttribute(
+ "tooltiptext",
+ ownerView.deleteButtonTooltip
+ );
+ }
+ },
+
+ /**
+ * Get the parent variablesview toolbox, if any.
+ */
+ get toolbox() {
+ return this._variablesView.toolbox;
+ },
+
+ /**
+ * Checks if this variable is a DOMNode and is part of a variablesview that
+ * has been linked to the toolbox, so that highlighting and jumping to the
+ * inspector can be done.
+ */
+ _isLinkableToInspector() {
+ const isDomNode =
+ this._valueGrip && this._valueGrip.preview.kind === "DOMNode";
+ const hasBeenLinked = this._linkedToInspector;
+ const hasToolbox = !!this.toolbox;
+
+ return isDomNode && !hasBeenLinked && hasToolbox;
+ },
+
+ /**
+ * If the variable is a DOMNode, and if a toolbox is set, then link it to the
+ * inspector (highlight on hover, and jump to markup-view on click)
+ */
+ _linkToInspector() {
+ if (!this._isLinkableToInspector()) {
+ return;
+ }
+
+ // Listen to value mouseover/click events to highlight and jump
+ this._valueLabel.addEventListener("mouseover", this.highlightDomNode);
+ this._valueLabel.addEventListener("mouseout", this.unhighlightDomNode);
+
+ // Add a button to open the node in the inspector
+ this._openInspectorNode = this.document.createXULElement("toolbarbutton");
+ this._openInspectorNode.className = "plain variables-view-open-inspector";
+ this._openInspectorNode.addEventListener(
+ "mousedown",
+ this.openNodeInInspector
+ );
+ this._title.appendChild(this._openInspectorNode);
+
+ this._linkedToInspector = true;
+ },
+
+ /**
+ * In case this variable is a DOMNode and part of a variablesview that has been
+ * linked to the toolbox's inspector, then select the corresponding node in
+ * the inspector, and switch the inspector tool in the toolbox
+ * @return a promise that resolves when the node is selected and the inspector
+ * has been switched to and is ready
+ */
+ openNodeInInspector(event) {
+ if (!this.toolbox) {
+ return Promise.reject(new Error("Toolbox not available"));
+ }
+
+ event && event.stopPropagation();
+
+ return async function () {
+ let nodeFront = this._nodeFront;
+ if (!nodeFront) {
+ const inspectorFront = await this.toolbox.target.getFront("inspector");
+ nodeFront = await inspectorFront.getNodeFrontFromNodeGrip(
+ this._valueGrip
+ );
+ }
+
+ if (nodeFront) {
+ await this.toolbox.selectTool("inspector");
+
+ const inspectorReady = new Promise(resolve => {
+ this.toolbox.getPanel("inspector").once("inspector-updated", resolve);
+ });
+
+ await this.toolbox.selection.setNodeFront(nodeFront, {
+ reason: "variables-view",
+ });
+ await inspectorReady;
+ }
+ }.bind(this)();
+ },
+
+ /**
+ * In case this variable is a DOMNode and part of a variablesview that has been
+ * linked to the toolbox's inspector, then highlight the corresponding node
+ */
+ async highlightDomNode() {
+ if (!this.toolbox) {
+ return;
+ }
+
+ if (!this._nodeFront) {
+ const inspectorFront = await this.toolbox.target.getFront("inspector");
+ this._nodeFront = await inspectorFront.getNodeFrontFromNodeGrip(
+ this._valueGrip
+ );
+ }
+
+ await this.toolbox.getHighlighter().highlight(this._nodeFront);
+ },
+
+ /**
+ * Unhighlight a previously highlit node
+ * @see highlightDomNode
+ */
+ unhighlightDomNode() {
+ if (!this.toolbox) {
+ return;
+ }
+
+ this.toolbox.getHighlighter().unhighlight();
+ },
+
+ /**
+ * Sets a variable's configurable, enumerable and writable attributes,
+ * and specifies if it's a 'this', '<exception>', '<return>' or '__proto__'
+ * reference.
+ */
+ // eslint-disable-next-line complexity
+ _setAttributes() {
+ const ownerView = this.ownerView;
+ if (ownerView.preventDescriptorModifiers) {
+ return;
+ }
+
+ const descriptor = this._initialDescriptor;
+ const target = this._target;
+ const name = this._nameString;
+
+ if (ownerView.eval) {
+ target.setAttribute("editable", "");
+ }
+
+ if (!descriptor.configurable) {
+ target.setAttribute("non-configurable", "");
+ }
+ if (!descriptor.enumerable) {
+ target.setAttribute("non-enumerable", "");
+ }
+ if (!descriptor.writable && !ownerView.getter && !ownerView.setter) {
+ target.setAttribute("non-writable", "");
+ }
+
+ if (descriptor.value && typeof descriptor.value == "object") {
+ if (descriptor.value.frozen) {
+ target.setAttribute("frozen", "");
+ }
+ if (descriptor.value.sealed) {
+ target.setAttribute("sealed", "");
+ }
+ if (!descriptor.value.extensible) {
+ target.setAttribute("non-extensible", "");
+ }
+ }
+
+ if (descriptor && "getterValue" in descriptor) {
+ target.setAttribute("safe-getter", "");
+ }
+
+ if (name == "this") {
+ target.setAttribute("self", "");
+ } else if (this._internalItem && name == "<exception>") {
+ target.setAttribute("exception", "");
+ target.setAttribute("pseudo-item", "");
+ } else if (this._internalItem && name == "<return>") {
+ target.setAttribute("return", "");
+ target.setAttribute("pseudo-item", "");
+ } else if (name == "__proto__") {
+ target.setAttribute("proto", "");
+ target.setAttribute("pseudo-item", "");
+ }
+
+ if (!Object.keys(descriptor).length) {
+ target.setAttribute("pseudo-item", "");
+ }
+ },
+
+ /**
+ * Adds the necessary event listeners for this variable.
+ */
+ _addEventListeners() {
+ this._name.addEventListener("dblclick", this._activateNameInput);
+ this._valueLabel.addEventListener("mousedown", this._activateValueInput);
+ this._title.addEventListener("mousedown", this._onClick);
+ },
+
+ /**
+ * Makes this variable's name editable.
+ */
+ _activateNameInput(e) {
+ if (!this._variablesView.alignedValues) {
+ this._separatorLabel.hidden = true;
+ this._valueLabel.hidden = true;
+ }
+
+ EditableName.create(
+ this,
+ {
+ onSave: aKey => {
+ if (!this._variablesView.preventDisableOnChange) {
+ this._disable();
+ }
+ this.ownerView.switch(this, aKey);
+ },
+ onCleanup: () => {
+ if (!this._variablesView.alignedValues) {
+ this._separatorLabel.hidden = false;
+ this._valueLabel.hidden = false;
+ }
+ },
+ },
+ e
+ );
+ },
+
+ /**
+ * Makes this variable's value editable.
+ */
+ _activateValueInput(e) {
+ EditableValue.create(
+ this,
+ {
+ onSave: aString => {
+ if (this._linkedToInspector) {
+ this.unhighlightDomNode();
+ }
+ if (!this._variablesView.preventDisableOnChange) {
+ this._disable();
+ }
+ this.ownerView.eval(this, aString);
+ },
+ },
+ e
+ );
+ },
+
+ /**
+ * Disables this variable prior to a new name switch or value evaluation.
+ */
+ _disable() {
+ // Prevent the variable from being collapsed or expanded.
+ this.hideArrow();
+
+ // Hide any nodes that may offer information about the variable.
+ for (const node of this._title.childNodes) {
+ node.hidden = node != this._arrow && node != this._name;
+ }
+ this._enum.hidden = true;
+ this._nonenum.hidden = true;
+ },
+
+ /**
+ * The current macro used to generate the string evaluated when performing
+ * a variable or property value change.
+ */
+ evaluationMacro: VariablesView.simpleValueEvalMacro,
+
+ /**
+ * The click listener for the edit button.
+ */
+ _onEdit(e) {
+ if (e.button != 0) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+ this._activateValueInput();
+ },
+
+ /**
+ * The click listener for the delete button.
+ */
+ _onDelete(e) {
+ if ("button" in e && e.button != 0) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (this.ownerView.delete) {
+ if (!this.ownerView.delete(this)) {
+ this.hide();
+ }
+ }
+ },
+
+ /**
+ * The click listener for the add property button.
+ */
+ _onAddProperty(e) {
+ if ("button" in e && e.button != 0) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.expanded = true;
+
+ const item = this.addItem(
+ " ",
+ {
+ value: undefined,
+ configurable: true,
+ enumerable: true,
+ writable: true,
+ },
+ { relaxed: true }
+ );
+
+ // Force showing the separator.
+ item._separatorLabel.hidden = false;
+
+ EditableNameAndValue.create(
+ item,
+ {
+ onSave: ([aKey, aValue]) => {
+ if (!this._variablesView.preventDisableOnChange) {
+ this._disable();
+ }
+ this.ownerView.new(this, aKey, aValue);
+ },
+ },
+ e
+ );
+ },
+
+ _symbolicName: null,
+ _symbolicPath: null,
+ _absoluteName: null,
+ _initialDescriptor: null,
+ _separatorLabel: null,
+ _valueLabel: null,
+ _spacer: null,
+ _editNode: null,
+ _deleteNode: null,
+ _addPropertyNode: null,
+ _tooltip: null,
+ _valueGrip: null,
+ _valueString: "",
+ _valueClassName: "",
+ _prevExpandable: false,
+ _prevExpanded: false,
+});
+
+/**
+ * A Property is a Variable holding additional child Property instances.
+ * Iterable via "for (let [name, property] of instance) { }".
+ *
+ * @param Variable aVar
+ * The variable to contain this property.
+ * @param string aName
+ * The property's name.
+ * @param object aDescriptor
+ * The property's descriptor.
+ * @param object aOptions
+ * Options of the form accepted by Scope.addItem
+ */
+function Property(aVar, aName, aDescriptor, aOptions) {
+ Variable.call(this, aVar, aName, aDescriptor, aOptions);
+}
+
+Property.prototype = extend(Variable.prototype, {
+ /**
+ * The class name applied to this property's target element.
+ */
+ targetClassName: "variables-view-property variable-or-property",
+
+ /**
+ * @see Variable.symbolicName
+ * @return string
+ */
+ get symbolicName() {
+ if (this._symbolicName) {
+ return this._symbolicName;
+ }
+
+ this._symbolicName =
+ this.ownerView.symbolicName + "[" + escapeString(this._nameString) + "]";
+ return this._symbolicName;
+ },
+
+ /**
+ * @see Variable.absoluteName
+ * @return string
+ */
+ get absoluteName() {
+ if (this._absoluteName) {
+ return this._absoluteName;
+ }
+
+ this._absoluteName =
+ this.ownerView.absoluteName + "[" + escapeString(this._nameString) + "]";
+ return this._absoluteName;
+ },
+});
+
+/**
+ * A generator-iterator over the VariablesView, Scopes, Variables and Properties.
+ */
+VariablesView.prototype[Symbol.iterator] =
+ Scope.prototype[Symbol.iterator] =
+ Variable.prototype[Symbol.iterator] =
+ Property.prototype[Symbol.iterator] =
+ function* () {
+ yield* this._store;
+ };
+
+/**
+ * Forget everything recorded about added scopes, variables or properties.
+ * @see VariablesView.commitHierarchy
+ */
+VariablesView.prototype.clearHierarchy = function () {
+ this._prevHierarchy.clear();
+ this._currHierarchy.clear();
+};
+
+/**
+ * Perform operations on all the VariablesView Scopes, Variables and Properties
+ * after you've added all the items you wanted.
+ *
+ * Calling this method is optional, and does the following:
+ * - styles the items overridden by other items in parent scopes
+ * - reopens the items which were previously expanded
+ * - flashes the items whose values changed
+ */
+VariablesView.prototype.commitHierarchy = function () {
+ for (const [, currItem] of this._currHierarchy) {
+ // Avoid performing expensive operations.
+ if (this.commitHierarchyIgnoredItems[currItem._nameString]) {
+ continue;
+ }
+ const overridden = this.isOverridden(currItem);
+ if (overridden) {
+ currItem.setOverridden(true);
+ }
+ const expanded = !currItem._committed && this.wasExpanded(currItem);
+ if (expanded) {
+ currItem.expand();
+ }
+ const changed = !currItem._committed && this.hasChanged(currItem);
+ if (changed) {
+ currItem.flash();
+ }
+ currItem._committed = true;
+ }
+ if (this.oncommit) {
+ this.oncommit(this);
+ }
+};
+
+// Some variables are likely to contain a very large number of properties.
+// It would be a bad idea to re-expand them or perform expensive operations.
+VariablesView.prototype.commitHierarchyIgnoredItems = extend(null, {
+ window: true,
+ this: true,
+});
+
+/**
+ * Checks if the an item was previously expanded, if it existed in a
+ * previous hierarchy.
+ *
+ * @param Scope | Variable | Property aItem
+ * The item to verify.
+ * @return boolean
+ * Whether the item was expanded.
+ */
+VariablesView.prototype.wasExpanded = function (aItem) {
+ if (!(aItem instanceof Scope)) {
+ return false;
+ }
+ const prevItem = this._prevHierarchy.get(
+ aItem.absoluteName || aItem._nameString
+ );
+ return prevItem ? prevItem._isExpanded : false;
+};
+
+/**
+ * Checks if the an item's displayed value (a representation of the grip)
+ * has changed, if it existed in a previous hierarchy.
+ *
+ * @param Variable | Property aItem
+ * The item to verify.
+ * @return boolean
+ * Whether the item has changed.
+ */
+VariablesView.prototype.hasChanged = function (aItem) {
+ // Only analyze Variables and Properties for displayed value changes.
+ // Scopes are just collections of Variables and Properties and
+ // don't have a "value", so they can't change.
+ if (!(aItem instanceof Variable)) {
+ return false;
+ }
+ const prevItem = this._prevHierarchy.get(aItem.absoluteName);
+ return prevItem ? prevItem._valueString != aItem._valueString : false;
+};
+
+/**
+ * Checks if the an item was previously expanded, if it existed in a
+ * previous hierarchy.
+ *
+ * @param Scope | Variable | Property aItem
+ * The item to verify.
+ * @return boolean
+ * Whether the item was expanded.
+ */
+VariablesView.prototype.isOverridden = function (aItem) {
+ // Only analyze Variables for being overridden in different Scopes.
+ if (!(aItem instanceof Variable) || aItem instanceof Property) {
+ return false;
+ }
+ const currVariableName = aItem._nameString;
+ const parentScopes = this.getParentScopesForVariableOrProperty(aItem);
+
+ for (const otherScope of parentScopes) {
+ for (const [otherVariableName] of otherScope) {
+ if (otherVariableName == currVariableName) {
+ return true;
+ }
+ }
+ }
+ return false;
+};
+
+/**
+ * Returns true if the descriptor represents an undefined, null or
+ * primitive value.
+ *
+ * @param object aDescriptor
+ * The variable's descriptor.
+ */
+VariablesView.isPrimitive = function (aDescriptor) {
+ // For accessor property descriptors, the getter and setter need to be
+ // contained in 'get' and 'set' properties.
+ const getter = aDescriptor.get;
+ const setter = aDescriptor.set;
+ if (getter || setter) {
+ return false;
+ }
+
+ // As described in the remote debugger protocol, the value grip
+ // must be contained in a 'value' property.
+ const grip = aDescriptor.value;
+ if (typeof grip != "object") {
+ return true;
+ }
+
+ // For convenience, undefined, null, Infinity, -Infinity, NaN, -0, and long
+ // strings are considered types.
+ const type = grip.type;
+ if (
+ type == "undefined" ||
+ type == "null" ||
+ type == "Infinity" ||
+ type == "-Infinity" ||
+ type == "NaN" ||
+ type == "-0" ||
+ type == "symbol" ||
+ type == "longString"
+ ) {
+ return true;
+ }
+
+ return false;
+};
+
+/**
+ * Returns true if the descriptor represents an undefined value.
+ *
+ * @param object aDescriptor
+ * The variable's descriptor.
+ */
+VariablesView.isUndefined = function (aDescriptor) {
+ // For accessor property descriptors, the getter and setter need to be
+ // contained in 'get' and 'set' properties.
+ const getter = aDescriptor.get;
+ const setter = aDescriptor.set;
+ if (
+ typeof getter == "object" &&
+ getter.type == "undefined" &&
+ typeof setter == "object" &&
+ setter.type == "undefined"
+ ) {
+ return true;
+ }
+
+ // As described in the remote debugger protocol, the value grip
+ // must be contained in a 'value' property.
+ const grip = aDescriptor.value;
+ if (typeof grip == "object" && grip.type == "undefined") {
+ return true;
+ }
+
+ return false;
+};
+
+/**
+ * Returns true if the descriptor represents a falsy value.
+ *
+ * @param object aDescriptor
+ * The variable's descriptor.
+ */
+VariablesView.isFalsy = function (aDescriptor) {
+ // As described in the remote debugger protocol, the value grip
+ // must be contained in a 'value' property.
+ const grip = aDescriptor.value;
+ if (typeof grip != "object") {
+ return !grip;
+ }
+
+ // For convenience, undefined, null, NaN, and -0 are all considered types.
+ const type = grip.type;
+ if (type == "undefined" || type == "null" || type == "NaN" || type == "-0") {
+ return true;
+ }
+
+ return false;
+};
+
+/**
+ * Returns true if the value is an instance of Variable or Property.
+ *
+ * @param any aValue
+ * The value to test.
+ */
+VariablesView.isVariable = function (aValue) {
+ return aValue instanceof Variable;
+};
+
+/**
+ * Returns a standard grip for a value.
+ *
+ * @param any aValue
+ * The raw value to get a grip for.
+ * @return any
+ * The value's grip.
+ */
+VariablesView.getGrip = function (aValue) {
+ switch (typeof aValue) {
+ case "boolean":
+ case "string":
+ return aValue;
+ case "number":
+ if (aValue === Infinity) {
+ return { type: "Infinity" };
+ } else if (aValue === -Infinity) {
+ return { type: "-Infinity" };
+ } else if (Number.isNaN(aValue)) {
+ return { type: "NaN" };
+ } else if (1 / aValue === -Infinity) {
+ return { type: "-0" };
+ }
+ return aValue;
+ case "undefined":
+ // document.all is also "undefined"
+ if (aValue === undefined) {
+ return { type: "undefined" };
+ }
+ // fall through
+ case "object":
+ if (aValue === null) {
+ return { type: "null" };
+ }
+ // fall through
+ case "function":
+ return { type: "object", class: getObjectClassName(aValue) };
+ default:
+ console.error(
+ "Failed to provide a grip for value of " + typeof value + ": " + aValue
+ );
+ return null;
+ }
+};
+
+// Match the function name from the result of toString() or toSource().
+//
+// Examples:
+// (function foobar(a, b) { ...
+// function foobar2(a) { ...
+// function() { ...
+const REGEX_MATCH_FUNCTION_NAME = /^\(?function\s+([^(\s]+)\s*\(/;
+
+/**
+ * Helper function to deduce the name of the provided function.
+ *
+ * @param function function
+ * The function whose name will be returned.
+ * @return string
+ * Function name.
+ */
+function getFunctionName(func) {
+ let name = null;
+ if (func.name) {
+ name = func.name;
+ } else {
+ let desc;
+ try {
+ desc = func.getOwnPropertyDescriptor("displayName");
+ } catch (ex) {
+ // Ignore.
+ }
+ if (desc && typeof desc.value == "string") {
+ name = desc.value;
+ }
+ }
+ if (!name) {
+ try {
+ const str = (func.toString() || func.toSource()) + "";
+ name = (str.match(REGEX_MATCH_FUNCTION_NAME) || [])[1];
+ } catch (ex) {
+ // Ignore.
+ }
+ }
+ return name;
+}
+
+/**
+ * Get the object class name. For example, the |window| object has the Window
+ * class name (based on [object Window]).
+ *
+ * @param object object
+ * The object you want to get the class name for.
+ * @return string
+ * The object class name.
+ */
+function getObjectClassName(object) {
+ if (object === null) {
+ return "null";
+ }
+ if (object === undefined) {
+ return "undefined";
+ }
+
+ const type = typeof object;
+ if (type != "object") {
+ // Grip class names should start with an uppercase letter.
+ return type.charAt(0).toUpperCase() + type.substr(1);
+ }
+
+ let className;
+
+ try {
+ className = ((object + "").match(/^\[object (\S+)\]$/) || [])[1];
+ if (!className) {
+ className = ((object.constructor + "").match(/^\[object (\S+)\]$/) ||
+ [])[1];
+ }
+ if (!className && typeof object.constructor == "function") {
+ className = getFunctionName(object.constructor);
+ }
+ } catch (ex) {
+ // Ignore.
+ }
+
+ return className;
+}
+
+/**
+ * Returns a custom formatted property string for a grip.
+ *
+ * @param any aGrip
+ * @see Variable.setGrip
+ * @param object aOptions
+ * Options:
+ * - concise: boolean that tells you want a concisely formatted string.
+ * - noStringQuotes: boolean that tells to not quote strings.
+ * - noEllipsis: boolean that tells to not add an ellipsis after the
+ * initial text of a longString.
+ * @return string
+ * The formatted property string.
+ */
+VariablesView.getString = function (aGrip, aOptions = {}) {
+ if (aGrip && typeof aGrip == "object") {
+ switch (aGrip.type) {
+ case "undefined":
+ case "null":
+ case "NaN":
+ case "Infinity":
+ case "-Infinity":
+ case "-0":
+ return aGrip.type;
+ default:
+ const stringifier = VariablesView.stringifiers.byType[aGrip.type];
+ if (stringifier) {
+ const result = stringifier(aGrip, aOptions);
+ if (result != null) {
+ return result;
+ }
+ }
+
+ if (aGrip.displayString) {
+ return VariablesView.getString(aGrip.displayString, aOptions);
+ }
+
+ if (aGrip.type == "object" && aOptions.concise) {
+ return aGrip.class;
+ }
+
+ return "[" + aGrip.type + " " + aGrip.class + "]";
+ }
+ }
+
+ switch (typeof aGrip) {
+ case "string":
+ return VariablesView.stringifiers.byType.string(aGrip, aOptions);
+ case "boolean":
+ return aGrip ? "true" : "false";
+ case "number":
+ if (!aGrip && 1 / aGrip === -Infinity) {
+ return "-0";
+ }
+ // fall through
+ default:
+ return aGrip + "";
+ }
+};
+
+/**
+ * The VariablesView stringifiers are used by VariablesView.getString(). These
+ * are organized by object type, object class and by object actor preview kind.
+ * Some objects share identical ways for previews, for example Arrays, Sets and
+ * NodeLists.
+ *
+ * Any stringifier function must return a string. If null is returned, * then
+ * the default stringifier will be used. When invoked, the stringifier is
+ * given the same two arguments as those given to VariablesView.getString().
+ */
+VariablesView.stringifiers = {};
+
+VariablesView.stringifiers.byType = {
+ string(aGrip, { noStringQuotes }) {
+ if (noStringQuotes) {
+ return aGrip;
+ }
+ return '"' + aGrip + '"';
+ },
+
+ longString({ initial }, { noStringQuotes, noEllipsis }) {
+ const ellipsis = noEllipsis ? "" : ELLIPSIS;
+ if (noStringQuotes) {
+ return initial + ellipsis;
+ }
+ const result = '"' + initial + '"';
+ if (!ellipsis) {
+ return result;
+ }
+ return result.substr(0, result.length - 1) + ellipsis + '"';
+ },
+
+ object(aGrip, aOptions) {
+ const { preview } = aGrip;
+ let stringifier;
+ if (aGrip.class) {
+ stringifier = VariablesView.stringifiers.byObjectClass[aGrip.class];
+ }
+ if (!stringifier && preview && preview.kind) {
+ stringifier = VariablesView.stringifiers.byObjectKind[preview.kind];
+ }
+ if (stringifier) {
+ return stringifier(aGrip, aOptions);
+ }
+ return null;
+ },
+
+ symbol(aGrip, aOptions) {
+ const name = aGrip.name || "";
+ return "Symbol(" + name + ")";
+ },
+
+ mapEntry(aGrip, { concise }) {
+ const {
+ preview: { key, value },
+ } = aGrip;
+
+ const keyString = VariablesView.getString(key, {
+ concise: true,
+ noStringQuotes: true,
+ });
+ const valueString = VariablesView.getString(value, { concise: true });
+
+ return keyString + " \u2192 " + valueString;
+ },
+}; // VariablesView.stringifiers.byType
+
+VariablesView.stringifiers.byObjectClass = {
+ Function(aGrip, { concise }) {
+ // TODO: Bug 948484 - support arrow functions and ES6 generators
+
+ let name = aGrip.userDisplayName || aGrip.displayName || aGrip.name || "";
+ name = VariablesView.getString(name, { noStringQuotes: true });
+
+ // TODO: Bug 948489 - Support functions with destructured parameters and
+ // rest parameters
+ const params = aGrip.parameterNames || "";
+ if (!concise) {
+ return "function " + name + "(" + params + ")";
+ }
+ return (name || "function ") + "(" + params + ")";
+ },
+
+ RegExp({ displayString }) {
+ return VariablesView.getString(displayString, { noStringQuotes: true });
+ },
+
+ Date({ preview }) {
+ if (!preview || !("timestamp" in preview)) {
+ return null;
+ }
+
+ if (typeof preview.timestamp != "number") {
+ return new Date(preview.timestamp).toString(); // invalid date
+ }
+
+ return "Date " + new Date(preview.timestamp).toISOString();
+ },
+
+ Number(aGrip) {
+ const { preview } = aGrip;
+ if (preview === undefined) {
+ return null;
+ }
+ return (
+ aGrip.class + " { " + VariablesView.getString(preview.wrappedValue) + " }"
+ );
+ },
+}; // VariablesView.stringifiers.byObjectClass
+
+VariablesView.stringifiers.byObjectClass.Boolean =
+ VariablesView.stringifiers.byObjectClass.Number;
+
+VariablesView.stringifiers.byObjectKind = {
+ ArrayLike(aGrip, { concise }) {
+ const { preview } = aGrip;
+ if (concise) {
+ return aGrip.class + "[" + preview.length + "]";
+ }
+
+ if (!preview.items) {
+ return null;
+ }
+
+ let shown = 0,
+ lastHole = null;
+ const result = [];
+ for (const item of preview.items) {
+ if (item === null) {
+ if (lastHole !== null) {
+ result[lastHole] += ",";
+ } else {
+ result.push("");
+ }
+ lastHole = result.length - 1;
+ } else {
+ lastHole = null;
+ result.push(VariablesView.getString(item, { concise: true }));
+ }
+ shown++;
+ }
+
+ if (shown < preview.length) {
+ const n = preview.length - shown;
+ result.push(VariablesView.stringifiers._getNMoreString(n));
+ } else if (lastHole !== null) {
+ // make sure we have the right number of commas...
+ result[lastHole] += ",";
+ }
+
+ const prefix = aGrip.class == "Array" ? "" : aGrip.class + " ";
+ return prefix + "[" + result.join(", ") + "]";
+ },
+
+ MapLike(aGrip, { concise }) {
+ const { preview } = aGrip;
+ if (concise || !preview.entries) {
+ const size =
+ typeof preview.size == "number" ? "[" + preview.size + "]" : "";
+ return aGrip.class + size;
+ }
+
+ const entries = [];
+ for (const [key, value] of preview.entries) {
+ const keyString = VariablesView.getString(key, {
+ concise: true,
+ noStringQuotes: true,
+ });
+ const valueString = VariablesView.getString(value, { concise: true });
+ entries.push(keyString + ": " + valueString);
+ }
+
+ if (typeof preview.size == "number" && preview.size > entries.length) {
+ const n = preview.size - entries.length;
+ entries.push(VariablesView.stringifiers._getNMoreString(n));
+ }
+
+ return aGrip.class + " {" + entries.join(", ") + "}";
+ },
+
+ ObjectWithText(aGrip, { concise }) {
+ if (concise) {
+ return aGrip.class;
+ }
+
+ return aGrip.class + " " + VariablesView.getString(aGrip.preview.text);
+ },
+
+ ObjectWithURL(aGrip, { concise }) {
+ let result = aGrip.class;
+ const url = aGrip.preview.url;
+ if (!VariablesView.isFalsy({ value: url })) {
+ result += ` \u2192 ${getSourceNames(url)[concise ? "short" : "long"]}`;
+ }
+ return result;
+ },
+
+ // Stringifier for any kind of object.
+ Object(aGrip, { concise }) {
+ if (concise) {
+ return aGrip.class;
+ }
+
+ const { preview } = aGrip;
+ const props = [];
+
+ if (aGrip.class == "Promise" && aGrip.promiseState) {
+ const { state, value, reason } = aGrip.promiseState;
+ props.push("<state>: " + VariablesView.getString(state));
+ if (state == "fulfilled") {
+ props.push(
+ "<value>: " + VariablesView.getString(value, { concise: true })
+ );
+ } else if (state == "rejected") {
+ props.push(
+ "<reason>: " + VariablesView.getString(reason, { concise: true })
+ );
+ }
+ }
+
+ for (const key of Object.keys(preview.ownProperties || {})) {
+ const value = preview.ownProperties[key];
+ let valueString = "";
+ if (value.get) {
+ valueString = "Getter";
+ } else if (value.set) {
+ valueString = "Setter";
+ } else {
+ valueString = VariablesView.getString(value.value, { concise: true });
+ }
+ props.push(key + ": " + valueString);
+ }
+
+ for (const key of Object.keys(preview.safeGetterValues || {})) {
+ const value = preview.safeGetterValues[key];
+ const valueString = VariablesView.getString(value.getterValue, {
+ concise: true,
+ });
+ props.push(key + ": " + valueString);
+ }
+
+ if (!props.length) {
+ return null;
+ }
+
+ if (preview.ownPropertiesLength) {
+ const previewLength = Object.keys(preview.ownProperties).length;
+ const diff = preview.ownPropertiesLength - previewLength;
+ if (diff > 0) {
+ props.push(VariablesView.stringifiers._getNMoreString(diff));
+ }
+ }
+
+ const prefix = aGrip.class != "Object" ? aGrip.class + " " : "";
+ return prefix + "{" + props.join(", ") + "}";
+ }, // Object
+
+ Error(aGrip, { concise }) {
+ const { preview } = aGrip;
+ const name = VariablesView.getString(preview.name, {
+ noStringQuotes: true,
+ });
+ if (concise) {
+ return name || aGrip.class;
+ }
+
+ let msg =
+ name +
+ ": " +
+ VariablesView.getString(preview.message, { noStringQuotes: true });
+
+ if (!VariablesView.isFalsy({ value: preview.stack })) {
+ msg +=
+ "\n" +
+ L10N.getStr("variablesViewErrorStacktrace") +
+ "\n" +
+ preview.stack;
+ }
+
+ return msg;
+ },
+
+ DOMException(aGrip, { concise }) {
+ const { preview } = aGrip;
+ if (concise) {
+ return preview.name || aGrip.class;
+ }
+
+ let msg =
+ aGrip.class +
+ " [" +
+ preview.name +
+ ": " +
+ VariablesView.getString(preview.message) +
+ "\n" +
+ "code: " +
+ preview.code +
+ "\n" +
+ "nsresult: 0x" +
+ (+preview.result).toString(16);
+
+ if (preview.filename) {
+ msg += "\nlocation: " + preview.filename;
+ if (preview.lineNumber) {
+ msg += ":" + preview.lineNumber;
+ }
+ }
+
+ return msg + "]";
+ },
+
+ DOMEvent(aGrip, { concise }) {
+ const { preview } = aGrip;
+ if (!preview.type) {
+ return null;
+ }
+
+ if (concise) {
+ return aGrip.class + " " + preview.type;
+ }
+
+ let result = preview.type;
+
+ if (
+ preview.eventKind == "key" &&
+ preview.modifiers &&
+ preview.modifiers.length
+ ) {
+ result += " " + preview.modifiers.join("-");
+ }
+
+ const props = [];
+ if (preview.target) {
+ const target = VariablesView.getString(preview.target, { concise: true });
+ props.push("target: " + target);
+ }
+
+ for (const prop in preview.properties) {
+ const value = preview.properties[prop];
+ props.push(
+ prop + ": " + VariablesView.getString(value, { concise: true })
+ );
+ }
+
+ return result + " {" + props.join(", ") + "}";
+ }, // DOMEvent
+
+ DOMNode(aGrip, { concise }) {
+ const { preview } = aGrip;
+
+ switch (preview.nodeType) {
+ case nodeConstants.DOCUMENT_NODE: {
+ let result = aGrip.class;
+ if (preview.location) {
+ result += ` \u2192 ${
+ getSourceNames(preview.location)[concise ? "short" : "long"]
+ }`;
+ }
+
+ return result;
+ }
+
+ case nodeConstants.ATTRIBUTE_NODE: {
+ const value = VariablesView.getString(preview.value, {
+ noStringQuotes: true,
+ });
+ return preview.nodeName + '="' + escapeHTML(value) + '"';
+ }
+
+ case nodeConstants.TEXT_NODE:
+ return (
+ preview.nodeName + " " + VariablesView.getString(preview.textContent)
+ );
+
+ case nodeConstants.COMMENT_NODE: {
+ const comment = VariablesView.getString(preview.textContent, {
+ noStringQuotes: true,
+ });
+ return "<!--" + comment + "-->";
+ }
+
+ case nodeConstants.DOCUMENT_FRAGMENT_NODE: {
+ if (concise || !preview.childNodes) {
+ return aGrip.class + "[" + preview.childNodesLength + "]";
+ }
+ const nodes = [];
+ for (const node of preview.childNodes) {
+ nodes.push(VariablesView.getString(node));
+ }
+ if (nodes.length < preview.childNodesLength) {
+ const n = preview.childNodesLength - nodes.length;
+ nodes.push(VariablesView.stringifiers._getNMoreString(n));
+ }
+ return aGrip.class + " [" + nodes.join(", ") + "]";
+ }
+
+ case nodeConstants.ELEMENT_NODE: {
+ const attrs = preview.attributes;
+ if (!concise) {
+ let n = 0,
+ result = "<" + preview.nodeName;
+ for (const name in attrs) {
+ const value = VariablesView.getString(attrs[name], {
+ noStringQuotes: true,
+ });
+ result += " " + name + '="' + escapeHTML(value) + '"';
+ n++;
+ }
+ if (preview.attributesLength > n) {
+ result += " " + ELLIPSIS;
+ }
+ return result + ">";
+ }
+
+ let result = "<" + preview.nodeName;
+ if (attrs.id) {
+ result += "#" + attrs.id;
+ }
+
+ if (attrs.class) {
+ result += "." + attrs.class.trim().replace(/\s+/, ".");
+ }
+ return result + ">";
+ }
+
+ default:
+ return null;
+ }
+ }, // DOMNode
+}; // VariablesView.stringifiers.byObjectKind
+
+/**
+ * Get the "N more…" formatted string, given an N. This is used for displaying
+ * how many elements are not displayed in an object preview (eg. an array).
+ *
+ * @private
+ * @param number aNumber
+ * @return string
+ */
+VariablesView.stringifiers._getNMoreString = function (aNumber) {
+ const str = L10N.getStr("variablesViewMoreObjects");
+ return PluralForm.get(aNumber, str).replace("#1", aNumber);
+};
+
+/**
+ * Returns a custom class style for a grip.
+ *
+ * @param any aGrip
+ * @see Variable.setGrip
+ * @return string
+ * The custom class style.
+ */
+VariablesView.getClass = function (aGrip) {
+ if (aGrip && typeof aGrip == "object") {
+ if (aGrip.preview) {
+ switch (aGrip.preview.kind) {
+ case "DOMNode":
+ return "token-domnode";
+ }
+ }
+
+ switch (aGrip.type) {
+ case "undefined":
+ return "token-undefined";
+ case "null":
+ return "token-null";
+ case "Infinity":
+ case "-Infinity":
+ case "NaN":
+ case "-0":
+ return "token-number";
+ case "longString":
+ return "token-string";
+ }
+ }
+ switch (typeof aGrip) {
+ case "string":
+ return "token-string";
+ case "boolean":
+ return "token-boolean";
+ case "number":
+ return "token-number";
+ default:
+ return "token-other";
+ }
+};
+
+/**
+ * A monotonically-increasing counter, that guarantees the uniqueness of scope,
+ * variables and properties ids.
+ *
+ * @param string aName
+ * An optional string to prefix the id with.
+ * @return number
+ * A unique id.
+ */
+var generateId = (function () {
+ let count = 0;
+ return function (aName = "") {
+ return aName.toLowerCase().trim().replace(/\s+/g, "-") + ++count;
+ };
+})();
+
+/**
+ * Quote and escape a string. The result will be another string containing an
+ * ECMAScript StringLiteral which will produce the original one when evaluated
+ * by `eval` or similar.
+ *
+ * @param string aString
+ * An optional string to be escaped. If no string is passed, the function
+ * returns an empty string.
+ * @return string
+ */
+function escapeString(aString) {
+ if (typeof aString !== "string") {
+ return "";
+ }
+ // U+2028 and U+2029 are allowed in JSON but not in ECMAScript string literals.
+ return JSON.stringify(aString)
+ .replace(/\u2028/g, "\\u2028")
+ .replace(/\u2029/g, "\\u2029");
+}
+
+/**
+ * Escape some HTML special characters. We do not need full HTML serialization
+ * here, we just want to make strings safe to display in HTML attributes, for
+ * the stringifiers.
+ *
+ * @param string aString
+ * @return string
+ */
+export function escapeHTML(aString) {
+ return aString
+ .replace(/&/g, "&amp;")
+ .replace(/"/g, "&quot;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;");
+}
+
+/**
+ * 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..a850c889bc
--- /dev/null
+++ b/devtools/client/storage/index.xhtml
@@ -0,0 +1,106 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://devtools/content/shared/widgets/widgets.css" type="text/css"?>
+<?xml-stylesheet href="chrome://devtools/skin/widgets.css" type="text/css"?>
+<?xml-stylesheet href="chrome://devtools/skin/storage.css" type="text/css"?>
+<?xml-stylesheet href="chrome://devtools/content/shared/components/SidebarToggle.css" type="text/css"?>
+
+<!DOCTYPE window>
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+>
+ <script src="chrome://devtools/content/shared/theme-switching.js" />
+ <script src="chrome://global/content/globalOverlay.js" />
+
+ <html:link rel="localization" href="devtools/client/storage.ftl" />
+
+ <popupset id="storagePopupSet">
+ <menupopup id="storage-tree-popup">
+ <menuitem
+ id="storage-tree-popup-delete-all"
+ data-l10n-id="storage-context-menu-delete-all"
+ />
+ <menuitem
+ id="storage-tree-popup-delete-all-session-cookies"
+ data-l10n-id="storage-context-menu-delete-all-session-cookies"
+ />
+ <menuitem
+ id="storage-tree-popup-delete"
+ data-l10n-id="storage-context-menu-delete"
+ data-l10n-args='{"itemName": ""}'
+ />
+ </menupopup>
+ <menupopup id="variable-view-popup">
+ <menuitem
+ id="variable-view-popup-copy"
+ data-l10n-id="storage-context-menu-copy"
+ />
+ </menupopup>
+ <menupopup id="storage-table-popup">
+ <menuitem
+ id="storage-table-popup-add"
+ data-l10n-id="storage-context-menu-add-item"
+ />
+ <menuitem
+ id="storage-table-popup-delete"
+ data-l10n-id="storage-context-menu-delete"
+ data-l10n-args='{"itemName": ""}'
+ />
+ <menuitem
+ id="storage-table-popup-delete-all-from"
+ data-l10n-id="storage-context-menu-delete-all-from"
+ data-l10n-args='{"host": ""}'
+ />
+ <menuitem
+ id="storage-table-popup-delete-all"
+ data-l10n-id="storage-context-menu-delete-all"
+ />
+ <menuitem
+ id="storage-table-popup-delete-all-session-cookies"
+ data-l10n-id="storage-context-menu-delete-all-session-cookies"
+ />
+ </menupopup>
+ </popupset>
+
+ <box flex="1" class="devtools-responsive-container theme-body">
+ <vbox id="storage-tree" />
+ <splitter class="devtools-side-splitter" />
+ <vbox flex="1">
+ <hbox
+ id="storage-toolbar"
+ class="devtools-toolbar devtools-input-toolbar"
+ >
+ <html:input
+ id="storage-searchbox"
+ class="devtools-filterinput"
+ data-l10n-id="storage-search-box"
+ />
+ <hbox class="devtools-separator" />
+ <html:button
+ id="add-button"
+ class="devtools-button add-button"
+ data-l10n-id="storage-add-button"
+ ></html:button>
+ <html:button
+ id="refresh-button"
+ class="devtools-button refresh-button"
+ data-l10n-id="storage-refresh-button"
+ ></html:button>
+ <html:button
+ class="devtools-button sidebar-toggle"
+ hidden=""
+ ></html:button>
+ </hbox>
+ <vbox id="storage-table" class="theme-sidebar" flex="1" />
+ </vbox>
+ <splitter class="devtools-side-splitter" />
+ <vbox id="storage-sidebar" class="devtools-sidebar-tabs" hidden="true">
+ <vbox flex="1" />
+ </vbox>
+ </box>
+</window>
diff --git a/devtools/client/storage/moz.build b/devtools/client/storage/moz.build
new file mode 100644
index 0000000000..eaf58ad619
--- /dev/null
+++ b/devtools/client/storage/moz.build
@@ -0,0 +1,20 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+BROWSER_CHROME_MANIFESTS += ["test/browser.ini"]
+
+DIRS += [
+ "utils",
+]
+
+DevToolsModules(
+ "panel.js",
+ "ui.js",
+ "VariablesView.sys.mjs",
+)
+
+with Files("**"):
+ BUG_COMPONENT = ("DevTools", "Storage Inspector")
diff --git a/devtools/client/storage/panel.js b/devtools/client/storage/panel.js
new file mode 100644
index 0000000000..79b579b2bc
--- /dev/null
+++ b/devtools/client/storage/panel.js
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+loader.lazyRequireGetter(
+ this,
+ "StorageUI",
+ "resource://devtools/client/storage/ui.js",
+ true
+);
+
+class StoragePanel {
+ constructor(panelWin, toolbox, commands) {
+ EventEmitter.decorate(this);
+
+ this._toolbox = toolbox;
+ this._commands = commands;
+ this._panelWin = panelWin;
+ }
+
+ get panelWindow() {
+ return this._panelWin;
+ }
+
+ /**
+ * open is effectively an asynchronous constructor
+ */
+ async open() {
+ this.UI = new StorageUI(this._panelWin, this._toolbox, this._commands);
+
+ await this.UI.init();
+
+ return this;
+ }
+
+ /**
+ * Destroy the storage inspector.
+ */
+ destroy() {
+ if (this._destroyed) {
+ return;
+ }
+ this._destroyed = true;
+
+ this.UI.destroy();
+ this.UI = null;
+
+ this._toolbox = null;
+ this._panelWin = null;
+ }
+}
+
+exports.StoragePanel = StoragePanel;
diff --git a/devtools/client/storage/test/browser.ini b/devtools/client/storage/test/browser.ini
new file mode 100644
index 0000000000..03572fd63b
--- /dev/null
+++ b/devtools/client/storage/test/browser.ini
@@ -0,0 +1,122 @@
+[DEFAULT]
+tags = devtools
+subsuite = devtools
+skip-if = http3 # Many tests relying on test1/test2.example.org
+support-files =
+ storage-blank.html
+ storage-cache-basic-iframe.html
+ storage-cache-basic.html
+ storage-cache-error.html
+ storage-cache-overflow.html
+ storage-complex-keys.html
+ storage-complex-values.html
+ storage-cookies.html
+ storage-cookies-samesite.html
+ storage-cookies-sort.html
+ storage-dfpi.html
+ storage-empty-objectstores.html
+ storage-file-url.html
+ storage-idb-delete-blocked.html
+ storage-indexeddb-duplicate-names.html
+ storage-indexeddb-iframe.html
+ storage-indexeddb-simple.html
+ storage-indexeddb-simple-alt.html
+ storage-listings.html
+ storage-listings-usercontextid.html
+ storage-listings-with-fragment.html
+ storage-localstorage.html
+ storage-overflow-indexeddb.html
+ storage-overflow.html
+ storage-search.html
+ storage-secured-iframe.html
+ storage-secured-iframe-usercontextid.html
+ storage-sessionstorage.html
+ storage-sidebar-parsetree.html
+ storage-unsecured-iframe.html
+ storage-unsecured-iframe-usercontextid.html
+ storage-updates.html
+ head.js
+ !/devtools/client/shared/test/shared-head.js
+ !/devtools/client/shared/test/telemetry-test-helpers.js
+
+[browser_storage_basic.js]
+[browser_storage_basic_usercontextid_1.js]
+[browser_storage_basic_usercontextid_2.js]
+tags = usercontextid
+[browser_storage_basic_with_fragment.js]
+[browser_storage_cache_delete.js]
+[browser_storage_cache_error.js]
+[browser_storage_cache_navigation.js]
+skip-if =
+ !fission # Bug 1685474
+ win10_2004 # Bug 1723573
+ win11_2009 # Bug 1797751
+[browser_storage_cache_overflow.js]
+[browser_storage_cookies_add.js]
+[browser_storage_cookies_delete_all.js]
+[browser_storage_cookies_domain.js]
+[browser_storage_cookies_domain_port.js]
+[browser_storage_cookies_edit.js]
+[browser_storage_cookies_edit_keyboard.js]
+[browser_storage_cookies_hostOnly.js]
+[browser_storage_cookies_navigation.js]
+skip-if =
+ win10_2004 && asan && fission # high frequency intermittent
+ os == "linux" && debug && fission && socketprocess_networking # high frequency intermittent
+[browser_storage_cookies_samesite.js]
+skip-if = true # Bug 1448484 - sameSite1 is "Unset" - Got undefined, expected Unset
+[browser_storage_cookies_sort.js]
+[browser_storage_cookies_tab_navigation.js]
+[browser_storage_delete.js]
+[browser_storage_delete_all.js]
+[browser_storage_delete_tree.js]
+[browser_storage_delete_usercontextid.js]
+tags = usercontextid
+[browser_storage_dfpi.js]
+[browser_storage_dfpi_always_partition_storage.js]
+[browser_storage_dom_cache_disabled.js]
+[browser_storage_dynamic_updates_cookies.js]
+[browser_storage_dynamic_updates_localStorage.js]
+[browser_storage_dynamic_updates_sessionStorage.js]
+[browser_storage_empty_objectstores.js]
+[browser_storage_file_url.js]
+[browser_storage_fission_cache.js]
+[browser_storage_fission_cookies.js]
+[browser_storage_fission_hide_aboutblank.js]
+[browser_storage_fission_indexeddb.js]
+[browser_storage_fission_local_storage.js]
+[browser_storage_fission_session_storage.js]
+[browser_storage_indexeddb_add_button_hidden.js]
+[browser_storage_indexeddb_delete.js]
+[browser_storage_indexeddb_delete_blocked.js]
+[browser_storage_indexeddb_duplicate_names.js]
+skip-if =
+ win10_2004 # Bug 1723573
+ win11_2009 # Bug 1797751
+[browser_storage_indexeddb_hide_internal_dbs.js]
+skip-if =
+ asan # Bug 1591064
+[browser_storage_indexeddb_navigation.js]
+skip-if =
+ os == "win" && os_version == "10.0" && bits == 64 # Bug 1694274
+ os == 'linux' && bits == 64 # Bug 1694274
+[browser_storage_indexeddb_overflow.js]
+[browser_storage_keys.js]
+[browser_storage_localstorage_add.js]
+[browser_storage_localstorage_edit.js]
+[browser_storage_localstorage_error.js]
+[browser_storage_localstorage_navigation.js]
+[browser_storage_localstorage_rapid_add_remove.js]
+[browser_storage_overflow.js]
+[browser_storage_search.js]
+[browser_storage_search_keyboard_trap.js]
+[browser_storage_sessionstorage_add.js]
+[browser_storage_sessionstorage_edit.js]
+[browser_storage_sessionstorage_navigation.js]
+[browser_storage_sidebar.js]
+[browser_storage_sidebar_parsetree.js]
+[browser_storage_sidebar_toggle.js]
+[browser_storage_sidebar_update.js]
+[browser_storage_type_descriptions.js]
+[browser_storage_values.js]
+[browser_storage_webext_storage_local.js]
diff --git a/devtools/client/storage/test/browser_storage_basic.js b/devtools/client/storage/test/browser_storage_basic.js
new file mode 100644
index 0000000000..4a160f641b
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_basic.js
@@ -0,0 +1,172 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Basic test to assert that the storage tree and table corresponding to each
+// item in the storage tree is correctly displayed
+
+// Entries that should be present in the tree for this test
+// Format for each entry in the array :
+// [
+// ["path", "to", "tree", "item"], - The path to the tree item to click formed
+// by id of each item
+// ["key_value1", "key_value2", ...] - The value of the first (unique) column
+// for each row in the table corresponding
+// to the tree item selected.
+// ]
+// These entries are formed by the cookies, local storage, session storage and
+// indexedDB entries created in storage-listings.html,
+// storage-secured-iframe.html and storage-unsecured-iframe.html
+
+"use strict";
+
+const testCases = [
+ [
+ ["cookies", "http://test1.example.org"],
+ [
+ getCookieId("c1", "test1.example.org", "/browser"),
+ getCookieId("cs2", ".example.org", "/"),
+ getCookieId("c3", "test1.example.org", "/"),
+ getCookieId("c4", ".example.org", "/"),
+ getCookieId("uc1", ".example.org", "/"),
+ getCookieId("uc2", ".example.org", "/"),
+ ],
+ ],
+ [
+ ["cookies", "https://sectest1.example.org"],
+ [
+ getCookieId("uc1", ".example.org", "/"),
+ getCookieId("uc2", ".example.org", "/"),
+ getCookieId("cs2", ".example.org", "/"),
+ getCookieId("c4", ".example.org", "/"),
+ getCookieId(
+ "sc1",
+ "sectest1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ getCookieId(
+ "sc2",
+ "sectest1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ ],
+ ],
+ [
+ ["localStorage", "http://test1.example.org"],
+ ["key", "ls1", "ls2"],
+ ],
+ [["localStorage", "http://sectest1.example.org"], ["iframe-u-ls1"]],
+ [["localStorage", "https://sectest1.example.org"], ["iframe-s-ls1"]],
+ [
+ ["sessionStorage", "http://test1.example.org"],
+ ["key", "ss1"],
+ ],
+ [
+ ["sessionStorage", "http://sectest1.example.org"],
+ ["iframe-u-ss1", "iframe-u-ss2"],
+ ],
+ [["sessionStorage", "https://sectest1.example.org"], ["iframe-s-ss1"]],
+ [
+ ["indexedDB", "http://test1.example.org"],
+ ["idb1 (default)", "idb2 (default)"],
+ ],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1 (default)"],
+ ["obj1", "obj2"],
+ ],
+ [["indexedDB", "http://test1.example.org", "idb2 (default)"], ["obj3"]],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"],
+ [1, 2, 3],
+ ],
+ [["indexedDB", "http://test1.example.org", "idb1 (default)", "obj2"], [1]],
+ [["indexedDB", "http://test1.example.org", "idb2 (default)", "obj3"], []],
+ [["indexedDB", "http://sectest1.example.org"], []],
+ [
+ ["indexedDB", "https://sectest1.example.org"],
+ ["idb-s1 (default)", "idb-s2 (default)"],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)"],
+ ["obj-s1"],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)"],
+ ["obj-s2"],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)", "obj-s1"],
+ [6, 7],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)", "obj-s2"],
+ [16],
+ ],
+ [
+ ["Cache", "http://test1.example.org", "plop"],
+ [
+ MAIN_DOMAIN + "404_cached_file.js",
+ MAIN_DOMAIN + "browser_storage_basic.js",
+ ],
+ ],
+];
+
+/**
+ * Test that the desired number of tree items are present
+ */
+function testTree() {
+ const doc = gPanelWindow.document;
+ for (const [item] of testCases) {
+ ok(
+ doc.querySelector("[data-id='" + JSON.stringify(item) + "']"),
+ `Tree item ${item.toSource()} should be present in the storage tree`
+ );
+ }
+}
+
+/**
+ * Test that correct table entries are shown for each of the tree item
+ */
+async function testTables() {
+ const doc = gPanelWindow.document;
+ // Expand all nodes so that the synthesized click event actually works
+ gUI.tree.expandAll();
+
+ // First tree item is already selected so no clicking and waiting for update
+ for (const id of testCases[0][1]) {
+ ok(
+ doc.querySelector(".table-widget-cell[data-id='" + id + "']"),
+ "Table item " + id + " should be present"
+ );
+ }
+
+ // Click rest of the tree items and wait for the table to be updated
+ for (const [treeItem, items] of testCases.slice(1)) {
+ await selectTreeItem(treeItem);
+
+ // Check whether correct number of items are present in the table
+ is(
+ doc.querySelectorAll(
+ ".table-widget-column:first-of-type .table-widget-cell"
+ ).length,
+ items.length,
+ "Number of items in table is correct"
+ );
+
+ // Check if all the desired items are present in the table
+ for (const id of items) {
+ ok(
+ doc.querySelector(".table-widget-cell[data-id='" + id + "']"),
+ "Table item " + id + " should be present"
+ );
+ }
+ }
+}
+
+add_task(async function () {
+ await pushPref("dom.security.https_first", false);
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html");
+
+ testTree();
+ await testTables();
+});
diff --git a/devtools/client/storage/test/browser_storage_basic_usercontextid_1.js b/devtools/client/storage/test/browser_storage_basic_usercontextid_1.js
new file mode 100644
index 0000000000..fbd477b61d
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_basic_usercontextid_1.js
@@ -0,0 +1,162 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// A test to check that the storage inspector is working correctly without
+// userContextId.
+
+"use strict";
+
+const testCases = [
+ [
+ ["cookies", "http://test1.example.org"],
+ [
+ getCookieId("c1", "test1.example.org", "/browser"),
+ getCookieId("cs2", ".example.org", "/"),
+ getCookieId("c3", "test1.example.org", "/"),
+ getCookieId("c4", ".example.org", "/"),
+ getCookieId("uc1", ".example.org", "/"),
+ getCookieId("uc2", ".example.org", "/"),
+ ],
+ ],
+ [
+ ["cookies", "https://sectest1.example.org"],
+ [
+ getCookieId("uc1", ".example.org", "/"),
+ getCookieId("uc2", ".example.org", "/"),
+ getCookieId("cs2", ".example.org", "/"),
+ getCookieId("c4", ".example.org", "/"),
+ getCookieId(
+ "sc1",
+ "sectest1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ getCookieId(
+ "sc2",
+ "sectest1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ ],
+ ],
+ [
+ ["localStorage", "http://test1.example.org"],
+ ["key", "ls1", "ls2"],
+ ],
+ [["localStorage", "http://sectest1.example.org"], ["iframe-u-ls1"]],
+ [["localStorage", "https://sectest1.example.org"], ["iframe-s-ls1"]],
+ [
+ ["sessionStorage", "http://test1.example.org"],
+ ["key", "ss1"],
+ ],
+ [
+ ["sessionStorage", "http://sectest1.example.org"],
+ ["iframe-u-ss1", "iframe-u-ss2"],
+ ],
+ [["sessionStorage", "https://sectest1.example.org"], ["iframe-s-ss1"]],
+ [
+ ["indexedDB", "http://test1.example.org"],
+ ["idb1 (default)", "idb2 (default)"],
+ ],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1 (default)"],
+ ["obj1", "obj2"],
+ ],
+ [["indexedDB", "http://test1.example.org", "idb2 (default)"], ["obj3"]],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"],
+ [1, 2, 3],
+ ],
+ [["indexedDB", "http://test1.example.org", "idb1 (default)", "obj2"], [1]],
+ [["indexedDB", "http://test1.example.org", "idb2 (default)", "obj3"], []],
+ [["indexedDB", "http://sectest1.example.org"], []],
+ [
+ ["indexedDB", "https://sectest1.example.org"],
+ ["idb-s1 (default)", "idb-s2 (default)"],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)"],
+ ["obj-s1"],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)"],
+ ["obj-s2"],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)", "obj-s1"],
+ [6, 7],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)", "obj-s2"],
+ [16],
+ ],
+ [
+ ["Cache", "http://test1.example.org", "plop"],
+ [
+ MAIN_DOMAIN + "404_cached_file.js",
+ MAIN_DOMAIN + "browser_storage_basic.js",
+ ],
+ ],
+];
+
+/**
+ * Test that the desired number of tree items are present
+ */
+function testTree(tests) {
+ const doc = gPanelWindow.document;
+ for (const [item] of tests) {
+ ok(
+ doc.querySelector("[data-id='" + JSON.stringify(item) + "']"),
+ `Tree item ${item.toSource()} should be present in the storage tree`
+ );
+ }
+}
+
+/**
+ * Test that correct table entries are shown for each of the tree item
+ */
+async function testTables(tests) {
+ const doc = gPanelWindow.document;
+ // Expand all nodes so that the synthesized click event actually works
+ gUI.tree.expandAll();
+
+ // First tree item is already selected so no clicking and waiting for update
+ for (const id of tests[0][1]) {
+ ok(
+ doc.querySelector(".table-widget-cell[data-id='" + id + "']"),
+ "Table item " + id + " should be present"
+ );
+ }
+
+ // Click rest of the tree items and wait for the table to be updated
+ for (const [treeItem, items] of tests.slice(1)) {
+ await selectTreeItem(treeItem);
+
+ // Check whether correct number of items are present in the table
+ is(
+ doc.querySelectorAll(
+ ".table-widget-column:first-of-type .table-widget-cell"
+ ).length,
+ items.length,
+ "Number of items in table is correct"
+ );
+
+ // Check if all the desired items are present in the table
+ for (const id of items) {
+ ok(
+ doc.querySelector(".table-widget-cell[data-id='" + id + "']"),
+ "Table item " + id + " should be present"
+ );
+ }
+ }
+}
+
+add_task(async function () {
+ // storage-listings.html explicitly mixes secure and insecure frames.
+ // We should not enforce https for tests using this page.
+ await pushPref("dom.security.https_first", false);
+
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html");
+
+ testTree(testCases);
+ await testTables(testCases);
+});
diff --git a/devtools/client/storage/test/browser_storage_basic_usercontextid_2.js b/devtools/client/storage/test/browser_storage_basic_usercontextid_2.js
new file mode 100644
index 0000000000..e6c3151cd3
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_basic_usercontextid_2.js
@@ -0,0 +1,169 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// A test to check that the storage inspector is working correctly with
+// userContextId.
+
+"use strict";
+
+const testCasesUserContextId = [
+ [
+ ["cookies", "http://test1.example.org"],
+ [
+ getCookieId("c1uc1", "test1.example.org", "/browser"),
+ getCookieId("cs2uc1", ".example.org", "/"),
+ getCookieId("c3uc1", "test1.example.org", "/"),
+ getCookieId("uc1uc1", ".example.org", "/"),
+ ],
+ ],
+ [
+ ["cookies", "https://sectest1.example.org"],
+ [
+ getCookieId("uc1uc1", ".example.org", "/"),
+ getCookieId("cs2uc1", ".example.org", "/"),
+ getCookieId(
+ "sc1uc1",
+ "sectest1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ ],
+ ],
+ [
+ ["localStorage", "http://test1.example.org"],
+ ["ls1uc1", "ls2uc1"],
+ ],
+ [["localStorage", "http://sectest1.example.org"], ["iframe-u-ls1uc1"]],
+ [["localStorage", "https://sectest1.example.org"], ["iframe-s-ls1uc1"]],
+ [["sessionStorage", "http://test1.example.org"], ["ss1uc1"]],
+ [
+ ["sessionStorage", "http://sectest1.example.org"],
+ ["iframe-u-ss1uc1", "iframe-u-ss2uc1"],
+ ],
+ [["sessionStorage", "https://sectest1.example.org"], ["iframe-s-ss1uc1"]],
+ [
+ ["indexedDB", "http://test1.example.org"],
+ ["idb1uc1 (default)", "idb2uc1 (default)"],
+ ],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1uc1 (default)"],
+ ["obj1uc1", "obj2uc1"],
+ ],
+ [["indexedDB", "http://test1.example.org", "idb2uc1 (default)"], ["obj3uc1"]],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1uc1 (default)", "obj1uc1"],
+ [1, 2, 3],
+ ],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1uc1 (default)", "obj2uc1"],
+ [1],
+ ],
+ [
+ ["indexedDB", "http://test1.example.org", "idb2uc1 (default)", "obj3uc1"],
+ [],
+ ],
+ [["indexedDB", "http://sectest1.example.org"], []],
+ [
+ ["indexedDB", "https://sectest1.example.org"],
+ ["idb-s1uc1 (default)", "idb-s2uc1 (default)"],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s1uc1 (default)"],
+ ["obj-s1uc1"],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s2uc1 (default)"],
+ ["obj-s2uc1"],
+ ],
+ [
+ [
+ "indexedDB",
+ "https://sectest1.example.org",
+ "idb-s1uc1 (default)",
+ "obj-s1uc1",
+ ],
+ [6, 7],
+ ],
+ [
+ [
+ "indexedDB",
+ "https://sectest1.example.org",
+ "idb-s2uc1 (default)",
+ "obj-s2uc1",
+ ],
+ [16],
+ ],
+ [
+ ["Cache", "http://test1.example.org", "plopuc1"],
+ [
+ MAIN_DOMAIN + "404_cached_file.js",
+ MAIN_DOMAIN + "browser_storage_basic.js",
+ ],
+ ],
+];
+
+/**
+ * Test that the desired number of tree items are present
+ */
+function testTree(tests) {
+ const doc = gPanelWindow.document;
+ for (const [item] of tests) {
+ ok(
+ doc.querySelector("[data-id='" + JSON.stringify(item) + "']"),
+ `Tree item ${item.toSource()} should be present in the storage tree`
+ );
+ }
+}
+
+/**
+ * Test that correct table entries are shown for each of the tree item
+ */
+async function testTables(tests) {
+ const doc = gPanelWindow.document;
+ // Expand all nodes so that the synthesized click event actually works
+ gUI.tree.expandAll();
+
+ // First tree item is already selected so no clicking and waiting for update
+ for (const id of tests[0][1]) {
+ ok(
+ doc.querySelector(".table-widget-cell[data-id='" + id + "']"),
+ "Table item " + id + " should be present"
+ );
+ }
+
+ // Click rest of the tree items and wait for the table to be updated
+ for (const [treeItem, items] of tests.slice(1)) {
+ await selectTreeItem(treeItem);
+
+ // Check whether correct number of items are present in the table
+ is(
+ doc.querySelectorAll(
+ ".table-widget-column:first-of-type .table-widget-cell"
+ ).length,
+ items.length,
+ "Number of items in table is correct"
+ );
+
+ // Check if all the desired items are present in the table
+ for (const id of items) {
+ ok(
+ doc.querySelector(".table-widget-cell[data-id='" + id + "']"),
+ "Table item " + id + " should be present"
+ );
+ }
+ }
+}
+
+add_task(async function () {
+ // storage-listings.html explicitly mixes secure and insecure frames.
+ // We should not enforce https for tests using this page.
+ await pushPref("dom.security.https_first", false);
+
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN + "storage-listings-usercontextid.html",
+ { userContextId: 1 }
+ );
+
+ testTree(testCasesUserContextId);
+ await testTables(testCasesUserContextId);
+});
diff --git a/devtools/client/storage/test/browser_storage_basic_with_fragment.js b/devtools/client/storage/test/browser_storage_basic_with_fragment.js
new file mode 100644
index 0000000000..dcb18c7752
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_basic_with_fragment.js
@@ -0,0 +1,177 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// A second basic test to assert that the storage tree and table corresponding
+// to each item in the storage tree is correctly displayed.
+
+// This test differs from browser_storage_basic.js because the URLs we load
+// include fragments e.g. http://example.com/test.js#abcdefg
+// ^^^^^^^^
+// fragment
+
+// Entries that should be present in the tree for this test
+// Format for each entry in the array :
+// [
+// ["path", "to", "tree", "item"], - The path to the tree item to click formed
+// by id of each item
+// ["key_value1", "key_value2", ...] - The value of the first (unique) column
+// for each row in the table corresponding
+// to the tree item selected.
+// ]
+// These entries are formed by the cookies, local storage, session storage and
+// indexedDB entries created in storage-listings.html,
+// storage-secured-iframe.html and storage-unsecured-iframe.html
+
+"use strict";
+
+const testCases = [
+ [
+ ["cookies", "http://test1.example.org"],
+ [
+ getCookieId("c1", "test1.example.org", "/browser"),
+ getCookieId("cs2", ".example.org", "/"),
+ getCookieId("c3", "test1.example.org", "/"),
+ getCookieId("uc1", ".example.org", "/"),
+ getCookieId("uc2", ".example.org", "/"),
+ ],
+ ],
+ [
+ ["cookies", "https://sectest1.example.org"],
+ [
+ getCookieId("uc1", ".example.org", "/"),
+ getCookieId("uc2", ".example.org", "/"),
+ getCookieId("cs2", ".example.org", "/"),
+ getCookieId(
+ "sc1",
+ "sectest1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ getCookieId(
+ "sc2",
+ "sectest1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ ],
+ ],
+ [
+ ["localStorage", "http://test1.example.org"],
+ ["ls1", "ls2"],
+ ],
+ [["localStorage", "http://sectest1.example.org"], ["iframe-u-ls1"]],
+ [["localStorage", "https://sectest1.example.org"], ["iframe-s-ls1"]],
+ [["sessionStorage", "http://test1.example.org"], ["ss1"]],
+ [
+ ["sessionStorage", "http://sectest1.example.org"],
+ ["iframe-u-ss1", "iframe-u-ss2"],
+ ],
+ [["sessionStorage", "https://sectest1.example.org"], ["iframe-s-ss1"]],
+ [
+ ["indexedDB", "http://test1.example.org"],
+ ["idb1 (default)", "idb2 (default)"],
+ ],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1 (default)"],
+ ["obj1", "obj2"],
+ ],
+ [["indexedDB", "http://test1.example.org", "idb2 (default)"], ["obj3"]],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"],
+ [1, 2, 3],
+ ],
+ [["indexedDB", "http://test1.example.org", "idb1 (default)", "obj2"], [1]],
+ [["indexedDB", "http://test1.example.org", "idb2 (default)", "obj3"], []],
+ [["indexedDB", "http://sectest1.example.org"], []],
+ [
+ ["indexedDB", "https://sectest1.example.org"],
+ ["idb-s1 (default)", "idb-s2 (default)"],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)"],
+ ["obj-s1"],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)"],
+ ["obj-s2"],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)", "obj-s1"],
+ [6, 7],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)", "obj-s2"],
+ [16],
+ ],
+ [
+ ["Cache", "http://test1.example.org", "plop"],
+ [
+ MAIN_DOMAIN + "404_cached_file.js",
+ MAIN_DOMAIN + "browser_storage_basic.js",
+ ],
+ ],
+];
+
+/**
+ * Test that the desired number of tree items are present
+ */
+function testTree() {
+ const doc = gPanelWindow.document;
+ for (const [item] of testCases) {
+ ok(
+ doc.querySelector("[data-id='" + JSON.stringify(item) + "']"),
+ `Tree item ${item.toSource()} should be present in the storage tree`
+ );
+ }
+}
+
+/**
+ * Test that correct table entries are shown for each of the tree item
+ */
+async function testTables() {
+ const doc = gPanelWindow.document;
+ // Expand all nodes so that the synthesized click event actually works
+ gUI.tree.expandAll();
+
+ // First tree item is already selected so no clicking and waiting for update
+ for (const id of testCases[0][1]) {
+ ok(
+ doc.querySelector(".table-widget-cell[data-id='" + id + "']"),
+ "Table item " + id + " should be present"
+ );
+ }
+
+ // Click rest of the tree items and wait for the table to be updated
+ for (const [treeItem, items] of testCases.slice(1)) {
+ await selectTreeItem(treeItem);
+
+ // Check whether correct number of items are present in the table
+ is(
+ doc.querySelectorAll(
+ ".table-widget-column:first-of-type .table-widget-cell"
+ ).length,
+ items.length,
+ "Number of items in table is correct"
+ );
+
+ // Check if all the desired items are present in the table
+ for (const id of items) {
+ ok(
+ doc.querySelector(".table-widget-cell[data-id='" + id + "']"),
+ "Table item " + id + " should be present"
+ );
+ }
+ }
+}
+
+add_task(async function () {
+ // storage-listings.html explicitly mixes secure and insecure frames.
+ // We should not enforce https for tests using this page.
+ await pushPref("dom.security.https_first", false);
+
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN + "storage-listings-with-fragment.html#abc"
+ );
+
+ testTree();
+ await testTables();
+});
diff --git a/devtools/client/storage/test/browser_storage_cache_delete.js b/devtools/client/storage/test/browser_storage_cache_delete.js
new file mode 100644
index 0000000000..991ce22891
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cache_delete.js
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test deleting a Cache object from the tree using context menu
+
+add_task(async function () {
+ await pushPref("dom.security.https_first", false);
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html");
+
+ const contextMenu =
+ gPanelWindow.document.getElementById("storage-tree-popup");
+ const menuDeleteItem = contextMenu.querySelector(
+ "#storage-tree-popup-delete"
+ );
+
+ const cacheToDelete = ["Cache", "http://test1.example.org", "plop"];
+
+ info("test state before delete");
+ await selectTreeItem(cacheToDelete);
+ ok(gUI.tree.isSelected(cacheToDelete), "Cache item is present in the tree");
+
+ info("do the delete");
+ const eventWait = gUI.once("store-objects-updated");
+
+ const selector = `[data-id='${JSON.stringify(
+ cacheToDelete
+ )}'] > .tree-widget-item`;
+ const target = gPanelWindow.document.querySelector(selector);
+ ok(target, "Cache item's tree element is present");
+
+ await waitForContextMenu(contextMenu, target, () => {
+ info("Opened tree context menu");
+ menuDeleteItem.click();
+
+ const cacheName = cacheToDelete[2];
+ ok(
+ menuDeleteItem.getAttribute("label").includes(cacheName),
+ `Context menu item label contains '${cacheName}')`
+ );
+ });
+
+ await eventWait;
+
+ info("test state after delete");
+ await selectTreeItem(cacheToDelete);
+ ok(
+ !gUI.tree.isSelected(cacheToDelete),
+ "Cache item is no longer present in the tree"
+ );
+});
diff --git a/devtools/client/storage/test/browser_storage_cache_error.js b/devtools/client/storage/test/browser_storage_cache_error.js
new file mode 100644
index 0000000000..4aef96d0a8
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cache_error.js
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test handling errors in CacheStorage
+
+add_task(async function () {
+ // Open the URL in a private browsing window.
+ const win = await BrowserTestUtils.openNewBrowserWindow({ private: true });
+ const tab = win.gBrowser.selectedBrowser;
+ const triggeringPrincipal =
+ Services.scriptSecurityManager.getSystemPrincipal();
+ tab.loadURI(
+ Services.io.newURI(ALT_DOMAIN_SECURED + "storage-cache-error.html"),
+ { triggeringPrincipal }
+ );
+ await BrowserTestUtils.browserLoaded(tab);
+
+ // On enumerating cache storages, CacheStorage::Keys would throw a
+ // DOM security exception. We'd like to verify storage panel still work in
+ // this case.
+ await openStoragePanel({ tab: win.gBrowser.selectedTab });
+
+ const cacheItemId = ["Cache", "https://test2.example.org"];
+
+ await selectTreeItem(cacheItemId);
+ ok(
+ gUI.tree.isSelected(cacheItemId),
+ `The item ${cacheItemId.join(" > ")} is present in the tree`
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/devtools/client/storage/test/browser_storage_cache_navigation.js b/devtools/client/storage/test/browser_storage_cache_navigation.js
new file mode 100644
index 0000000000..5421b85028
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cache_navigation.js
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+add_task(async function () {
+ const URL1 = buildURLWithContent(
+ "example.com",
+ `<h1>example.com</h1>` +
+ `<script>
+ caches.open("lorem").then(cache => {
+ cache.add("${URL_ROOT_COM_SSL}storage-blank.html");
+ });
+ function clear() {
+ caches.delete("lorem");
+ }
+ </script>`
+ );
+ const URL2 = buildURLWithContent(
+ "example.net",
+ `<h1>example.net</h1>` +
+ `<script>
+ caches.open("foo").then(cache => {
+ cache.add("${URL_ROOT_NET_SSL}storage-blank.html");
+ });
+ function clear() {
+ caches.delete("foo");
+ }
+ </script>`
+ );
+
+ // open tab
+ await openTabAndSetupStorage(URL1);
+ const doc = gPanelWindow.document;
+
+ // Check first domain
+ // check that host appears in the storage tree
+ checkTree(doc, ["Cache", "https://example.com", "lorem"]);
+ // check the table for values
+ await selectTreeItem(["Cache", "https://example.com", "lorem"]);
+ checkCacheData(URL_ROOT_COM_SSL + "storage-blank.html", "OK");
+
+ // clear up the cache before navigating
+ info("Cleaning up cache…");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ const win = content.wrappedJSObject;
+ await win.clear();
+ });
+
+ // Check second domain
+ await navigateTo(URL2);
+
+ // Select the Cache view in order to force updating it
+ await selectTreeItem(["Cache", "https://example.net"]);
+
+ // wait for storage tree refresh, and check host
+ info("Waiting for storage tree to update…");
+ await waitUntil(() => isInTree(doc, ["Cache", "https://example.net", "foo"]));
+
+ ok(
+ !isInTree(doc, ["Cache", "https://example.com"]),
+ "example.com item is not in the tree anymore"
+ );
+
+ // check the table for values
+ await selectTreeItem(["Cache", "https://example.net", "foo"]);
+ checkCacheData(URL_ROOT_NET_SSL + "storage-blank.html", "OK");
+
+ info("Check that the Cache node still has the expected label");
+ is(
+ getTreeNodeLabel(doc, ["Cache"]),
+ "Cache Storage",
+ "Cache item is properly displayed"
+ );
+});
+
+function checkCacheData(url, status) {
+ is(
+ gUI.table.items.get(url)?.status,
+ status,
+ `Table row has an entry for: ${url} with status: ${status}`
+ );
+}
diff --git a/devtools/client/storage/test/browser_storage_cache_overflow.js b/devtools/client/storage/test/browser_storage_cache_overflow.js
new file mode 100644
index 0000000000..86e761b77b
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cache_overflow.js
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test endless scrolling when a lot of items are present in the storage
+// inspector table for Cache storage.
+"use strict";
+
+const ITEMS_PER_PAGE = 50;
+
+add_task(async function () {
+ await openTabAndSetupStorage(
+ URL_ROOT_COM_SSL + "storage-cache-overflow.html"
+ );
+
+ gUI.tree.expandAll();
+
+ await selectTreeItem(["Cache", "https://example.com", "lorem"]);
+ await waitFor(
+ () => getCellLength() == ITEMS_PER_PAGE,
+ "Wait until the first 50 messages have been rendered"
+ );
+
+ await scroll();
+ await waitFor(
+ () => getCellLength() == ITEMS_PER_PAGE * 2,
+ "Wait until 100 messages have been rendered"
+ );
+
+ info("Close Toolbox");
+ await gDevTools.closeToolboxForTab(gBrowser.selectedTab);
+});
diff --git a/devtools/client/storage/test/browser_storage_cookies_add.js b/devtools/client/storage/test/browser_storage_cookies_add.js
new file mode 100644
index 0000000000..8369b8c74b
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cookies_add.js
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Basic test to check the adding of cookies.
+
+"use strict";
+
+add_task(async function () {
+ const TEST_URL = MAIN_DOMAIN + "storage-cookies.html";
+ await openTabAndSetupStorage(TEST_URL);
+ showAllColumns(true);
+
+ 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"]);
+ 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 });
+ await performAdd(["cookies", "http://test1.example.org"]);
+ await performAdd(["cookies", "http://test1.example.org"]);
+ privateWindow.close();
+});
diff --git a/devtools/client/storage/test/browser_storage_cookies_delete_all.js b/devtools/client/storage/test/browser_storage_cookies_delete_all.js
new file mode 100644
index 0000000000..a637b8a4ab
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cookies_delete_all.js
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test deleting all cookies
+
+async function performDelete(store, rowName, action) {
+ const contextMenu = gPanelWindow.document.getElementById(
+ "storage-table-popup"
+ );
+ const menuDeleteAllItem = contextMenu.querySelector(
+ "#storage-table-popup-delete-all"
+ );
+ const menuDeleteAllSessionCookiesItem = contextMenu.querySelector(
+ "#storage-table-popup-delete-all-session-cookies"
+ );
+ const menuDeleteAllFromItem = contextMenu.querySelector(
+ "#storage-table-popup-delete-all-from"
+ );
+
+ const storeName = store.join(" > ");
+
+ await selectTreeItem(store);
+
+ const eventWait = gUI.once("store-objects-edit");
+ const cells = getRowCells(rowName, true);
+
+ await waitForContextMenu(contextMenu, cells.name, () => {
+ info(`Opened context menu in ${storeName}, row '${rowName}'`);
+ switch (action) {
+ case "deleteAll":
+ menuDeleteAllItem.click();
+ break;
+ case "deleteAllSessionCookies":
+ menuDeleteAllSessionCookiesItem.click();
+ break;
+ case "deleteAllFrom":
+ menuDeleteAllFromItem.click();
+ const hostName = cells.host.value;
+ ok(
+ menuDeleteAllFromItem.getAttribute("label").includes(hostName),
+ `Context menu item label contains '${hostName}'`
+ );
+ break;
+ }
+ });
+
+ await eventWait;
+}
+
+add_task(async function () {
+ // storage-listings.html explicitly mixes secure and insecure frames.
+ // We should not enforce https for tests using this page.
+ await pushPref("dom.security.https_first", false);
+
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html");
+
+ info("test state before delete");
+ await checkState([
+ [
+ ["cookies", "http://test1.example.org"],
+ [
+ getCookieId("c1", "test1.example.org", "/browser"),
+ getCookieId("c3", "test1.example.org", "/"),
+ getCookieId("cs2", ".example.org", "/"),
+ getCookieId("c4", ".example.org", "/"),
+ getCookieId("uc1", ".example.org", "/"),
+ getCookieId("uc2", ".example.org", "/"),
+ ],
+ ],
+ [
+ ["cookies", "https://sectest1.example.org"],
+ [
+ getCookieId("cs2", ".example.org", "/"),
+ getCookieId("c4", ".example.org", "/"),
+ getCookieId(
+ "sc1",
+ "sectest1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ getCookieId(
+ "sc2",
+ "sectest1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ getCookieId("uc1", ".example.org", "/"),
+ getCookieId("uc2", ".example.org", "/"),
+ ],
+ ],
+ ]);
+
+ info("delete all from domain");
+ // delete only cookies that match the host exactly
+ let id = getCookieId("c1", "test1.example.org", "/browser");
+ await performDelete(
+ ["cookies", "http://test1.example.org"],
+ id,
+ "deleteAllFrom"
+ );
+
+ info("test state after delete all from domain");
+ await checkState([
+ // Domain cookies (.example.org) must not be deleted.
+ [
+ ["cookies", "http://test1.example.org"],
+ [
+ getCookieId("cs2", ".example.org", "/"),
+ getCookieId("c4", ".example.org", "/"),
+ getCookieId("uc1", ".example.org", "/"),
+ getCookieId("uc2", ".example.org", "/"),
+ ],
+ ],
+ [
+ ["cookies", "https://sectest1.example.org"],
+ [
+ getCookieId("cs2", ".example.org", "/"),
+ getCookieId("c4", ".example.org", "/"),
+ getCookieId("uc1", ".example.org", "/"),
+ getCookieId("uc2", ".example.org", "/"),
+ getCookieId(
+ "sc1",
+ "sectest1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ getCookieId(
+ "sc2",
+ "sectest1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ ],
+ ],
+ ]);
+
+ info("delete all session cookies");
+ // delete only session cookies
+ id = getCookieId("cs2", ".example.org", "/");
+ await performDelete(
+ ["cookies", "http://sectest1.example.org"],
+ id,
+ "deleteAllSessionCookies"
+ );
+
+ info("test state after delete all session cookies");
+ await checkState([
+ // Cookies with expiry date must not be deleted.
+ [
+ ["cookies", "http://test1.example.org"],
+ [
+ getCookieId("c4", ".example.org", "/"),
+ getCookieId("uc2", ".example.org", "/"),
+ ],
+ ],
+ [
+ ["cookies", "https://sectest1.example.org"],
+ [
+ getCookieId("c4", ".example.org", "/"),
+ getCookieId("uc2", ".example.org", "/"),
+ getCookieId(
+ "sc2",
+ "sectest1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ ],
+ ],
+ ]);
+
+ info("delete all");
+ // delete all cookies for host, including domain cookies
+ id = getCookieId("uc2", ".example.org", "/");
+ await performDelete(
+ ["cookies", "http://sectest1.example.org"],
+ id,
+ "deleteAll"
+ );
+
+ info("test state after delete all");
+ await checkState([
+ // Domain cookies (.example.org) are deleted too, so deleting in sectest1
+ // also removes stuff from test1.
+ [["cookies", "http://test1.example.org"], []],
+ [["cookies", "https://sectest1.example.org"], []],
+ ]);
+});
diff --git a/devtools/client/storage/test/browser_storage_cookies_domain.js b/devtools/client/storage/test/browser_storage_cookies_domain.js
new file mode 100644
index 0000000000..5e4510196a
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cookies_domain.js
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test that cookies with domain equal to full host name are listed.
+// E.g., ".example.org" vs. example.org). Bug 1149497.
+
+add_task(async function () {
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-cookies.html");
+
+ await checkState([
+ [
+ ["cookies", "http://test1.example.org"],
+ [
+ getCookieId("test1", ".test1.example.org", "/browser"),
+ getCookieId("test2", "test1.example.org", "/browser"),
+ getCookieId("test3", ".test1.example.org", "/browser"),
+ getCookieId("test4", "test1.example.org", "/browser"),
+ getCookieId("test5", ".test1.example.org", "/browser"),
+ ],
+ ],
+ ]);
+});
diff --git a/devtools/client/storage/test/browser_storage_cookies_domain_port.js b/devtools/client/storage/test/browser_storage_cookies_domain_port.js
new file mode 100644
index 0000000000..8dd8f25d0a
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cookies_domain_port.js
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test that cookies with domain equal to full host name and port are listed.
+// E.g., ".example.org:8000" vs. example.org:8000).
+
+add_task(async function () {
+ await openTabAndSetupStorage(MAIN_DOMAIN_WITH_PORT + "storage-cookies.html");
+
+ await checkState([
+ [
+ ["cookies", "http://test1.example.org:8000"],
+ [
+ getCookieId("test1", ".test1.example.org", "/browser"),
+ getCookieId("test2", "test1.example.org", "/browser"),
+ getCookieId("test3", ".test1.example.org", "/browser"),
+ getCookieId("test4", "test1.example.org", "/browser"),
+ getCookieId("test5", ".test1.example.org", "/browser"),
+ ],
+ ],
+ ]);
+});
diff --git a/devtools/client/storage/test/browser_storage_cookies_edit.js b/devtools/client/storage/test/browser_storage_cookies_edit.js
new file mode 100644
index 0000000000..f49635750f
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cookies_edit.js
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Basic test to check the editing of cookies.
+
+"use strict";
+
+add_task(async function () {
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-cookies.html");
+ showAllColumns(true);
+
+ let id = getCookieId("test3", ".test1.example.org", "/browser");
+ await editCell(id, "name", "newTest3");
+
+ id = getCookieId("newTest3", ".test1.example.org", "/browser");
+ await editCell(id, "host", "test1.example.org");
+
+ id = getCookieId("newTest3", "test1.example.org", "/browser");
+ await editCell(id, "path", "/");
+
+ id = getCookieId("newTest3", "test1.example.org", "/");
+ await editCell(id, "expires", "Tue, 14 Feb 2040 17:41:14 GMT");
+ await editCell(id, "value", "newValue3");
+ await editCell(id, "isSecure", "true");
+ await editCell(id, "isHttpOnly", "true");
+});
diff --git a/devtools/client/storage/test/browser_storage_cookies_edit_keyboard.js b/devtools/client/storage/test/browser_storage_cookies_edit_keyboard.js
new file mode 100644
index 0000000000..c7f7857cf5
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cookies_edit_keyboard.js
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Basic test to check the editing of cookies with the keyboard.
+
+"use strict";
+
+add_task(async function () {
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-cookies.html");
+ showAllColumns(true);
+ showColumn("uniqueKey", false);
+
+ const id = getCookieId("test4", "test1.example.org", "/browser");
+ await startCellEdit(id, "name");
+ await typeWithTerminator("test6", "KEY_Tab");
+ await typeWithTerminator("test6value", "KEY_Tab");
+ await typeWithTerminator(".example.org", "KEY_Tab");
+ await typeWithTerminator("/", "KEY_Tab");
+ await typeWithTerminator("Tue, 25 Dec 2040 12:00:00 GMT", "KEY_Tab");
+ await typeWithTerminator("false", "KEY_Tab");
+ await typeWithTerminator("false", "KEY_Tab");
+});
diff --git a/devtools/client/storage/test/browser_storage_cookies_hostOnly.js b/devtools/client/storage/test/browser_storage_cookies_hostOnly.js
new file mode 100644
index 0000000000..99280cff9f
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cookies_hostOnly.js
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test that the HostOnly values displayed in the table are correct.
+
+SpecialPowers.pushPrefEnv({
+ set: [["security.allow_eval_with_system_principal", true]],
+});
+
+add_task(async function () {
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-complex-values.html");
+
+ gUI.tree.expandAll();
+
+ showColumn("hostOnly", true);
+
+ const c1id = getCookieId("c1", "test1.example.org", "/browser");
+ await selectTableItem(c1id);
+ checkCell(c1id, "hostOnly", "true");
+
+ const c2id = getCookieId("cs2", ".example.org", "/");
+ await selectTableItem(c2id);
+ checkCell(c2id, "hostOnly", "false");
+});
diff --git a/devtools/client/storage/test/browser_storage_cookies_navigation.js b/devtools/client/storage/test/browser_storage_cookies_navigation.js
new file mode 100644
index 0000000000..3dc1406451
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cookies_navigation.js
@@ -0,0 +1,139 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+add_task(async function () {
+ // Bug 1617611: Fix all the tests broken by "cookies SameSite=lax by default"
+ await SpecialPowers.pushPrefEnv({
+ set: [["network.cookie.sameSite.laxByDefault", false]],
+ });
+
+ const URL1 = buildURLWithContent(
+ "example.com",
+ `<h1>example.com</h1>` + `<script>document.cookie = "lorem=ipsum";</script>`
+ );
+ const URL2 = buildURLWithContent(
+ "example.net",
+ `<h1>example.net</h1>` +
+ `<iframe></iframe>` +
+ `<script>document.cookie = "foo=bar";</script>`
+ );
+ const URL_IFRAME = buildURLWithContent(
+ "example.org",
+ `<h1>example.org</h1>` + `<script>document.cookie = "hello=world";</script>`
+ );
+
+ // open tab
+ await openTabAndSetupStorage(URL1);
+ const doc = gPanelWindow.document;
+
+ // Check first domain
+ // check that both host appear in the storage tree
+ checkTree(doc, ["cookies", "https://example.com"]);
+ // check the table for values
+ await selectTreeItem(["cookies", "https://example.com"]);
+ checkCookieData("lorem", "ipsum");
+
+ // NOTE: No need to clean up cookies since Services.cookies.removeAll() from
+ // the registered clean up function will remove all of them.
+
+ // Check second domain
+ await navigateTo(URL2);
+ // wait for storage tree refresh, and check host
+ info("Waiting for storage tree to refresh and show correct host…");
+ await waitUntil(
+ () =>
+ isInTree(doc, ["cookies", "https://example.net"]) &&
+ !isInTree(doc, ["cookies", "https://example.com"])
+ );
+
+ ok(
+ !isInTree(doc, ["cookies", "https://example.com"]),
+ "example.com item is not in the tree anymore"
+ );
+
+ // check the table for values
+ // NOTE: there's an issue with the TreeWidget in which `selectedItem` is set
+ // but we have nothing selected in the UI. See Bug 1712706.
+ // Here we are forcing selecting a different item first.
+ await selectTreeItem(["cookies"]);
+ await selectTreeItem(["cookies", "https://example.net"]);
+ info("Waiting for table data to update and show correct values");
+ await waitUntil(() => hasCookieData("foo", "bar"));
+
+ // reload the current page, and check again
+ await reloadBrowser();
+ // wait for storage tree refresh, and check host
+ info("Waiting for storage tree to refresh and show correct host…");
+ await waitUntil(() => isInTree(doc, ["cookies", "https://example.net"]));
+ // check the table for values
+ // NOTE: there's an issue with the TreeWidget in which `selectedItem` is set
+ // but we have nothing selected in the UI. See Bug 1712706.
+ // Here we are forcing selecting a different item first.
+ await selectTreeItem(["cookies"]);
+ await selectTreeItem(["cookies", "https://example.net"]);
+ info("Waiting for table data to update and show correct values");
+ await waitUntil(() => hasCookieData("foo", "bar"));
+
+ // make the iframe navigate to a different domain
+ const onStorageTreeUpdated = gUI.once("store-objects-edit");
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [URL_IFRAME],
+ async function (url) {
+ const iframe = content.document.querySelector("iframe");
+ const onIframeLoaded = new Promise(loaded =>
+ iframe.addEventListener("load", loaded, { once: true })
+ );
+ iframe.src = url;
+ await onIframeLoaded;
+ }
+ );
+ info("Waiting for storage tree to update");
+ await onStorageTreeUpdated;
+
+ info("Waiting for storage tree to refresh and show correct host…");
+ await waitUntil(() => isInTree(doc, ["cookies", "https://example.org"]));
+ info("Checking cookie data");
+ await selectTreeItem(["cookies", "https://example.org"]);
+ checkCookieData("hello", "world");
+
+ info(
+ "Navigate to the first URL to check that the multiple hosts in the current document are all removed"
+ );
+ await navigateTo(URL1);
+ ok(true, "navigated");
+ await waitUntil(() => isInTree(doc, ["cookies", "https://example.com"]));
+ ok(
+ !isInTree(doc, ["cookies", "https://example.net"]),
+ "host of previous document (example.net) is not in the tree anymore"
+ );
+ ok(
+ !isInTree(doc, ["cookies", "https://example.org"]),
+ "host of iframe in previous document (example.org) is not in the tree anymore"
+ );
+
+ info("Navigate backward to test bfcache navigation");
+ gBrowser.goBack();
+ await waitUntil(
+ () =>
+ isInTree(doc, ["cookies", "https://example.net"]) &&
+ isInTree(doc, ["cookies", "https://example.org"])
+ );
+
+ ok(
+ !isInTree(doc, ["cookies", "https://example.com"]),
+ "host of previous document (example.com) is not in the tree anymore"
+ );
+
+ info("Check that the Cookies node still has the expected label");
+ is(
+ getTreeNodeLabel(doc, ["cookies"]),
+ "Cookies",
+ "Cookies item is properly displayed"
+ );
+
+ SpecialPowers.clearUserPref("network.cookie.sameSite.laxByDefault");
+});
diff --git a/devtools/client/storage/test/browser_storage_cookies_samesite.js b/devtools/client/storage/test/browser_storage_cookies_samesite.js
new file mode 100644
index 0000000000..dc58aaa9f6
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cookies_samesite.js
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test that the samesite cookie attribute is displayed correctly.
+
+add_task(async function () {
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-cookies-samesite.html");
+
+ const id1 = getCookieId(
+ "test1",
+ "test1.example.org",
+ "/browser/devtools/client/storage/test"
+ );
+ const id2 = getCookieId(
+ "test2",
+ "test1.example.org",
+ "/browser/devtools/client/storage/test"
+ );
+ const id3 = getCookieId(
+ "test3",
+ "test1.example.org",
+ "/browser/devtools/client/storage/test"
+ );
+
+ await checkState([
+ [
+ ["cookies", "http://test1.example.org"],
+ [id1, id2, id3],
+ ],
+ ]);
+
+ const sameSite1 = getRowValues(id1).sameSite;
+ const sameSite2 = getRowValues(id2).sameSite;
+ const sameSite3 = getRowValues(id3).sameSite;
+
+ is(sameSite1, "None", `sameSite1 is "None"`);
+ is(sameSite2, "Lax", `sameSite2 is "Lax"`);
+ is(sameSite3, "Strict", `sameSite3 is "Strict"`);
+});
diff --git a/devtools/client/storage/test/browser_storage_cookies_sort.js b/devtools/client/storage/test/browser_storage_cookies_sort.js
new file mode 100644
index 0000000000..2b4316af53
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cookies_sort.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test column sorting works and sorts dates correctly (including "session"
+// cookies).
+
+"use strict";
+
+add_task(async function () {
+ const TEST_URL = MAIN_DOMAIN + "storage-cookies-sort.html";
+ await openTabAndSetupStorage(TEST_URL);
+ showAllColumns(true);
+
+ info("Sort on the expires column, ascending order");
+ clickColumnHeader("expires");
+
+ // Note that here we only specify `test_session` for `test_session1` and
+ // `test_session2`. Since we sort on the "expires" column, there is no point
+ // in asserting the order between those 2 items.
+ checkCells([
+ "test_session",
+ "test_session",
+ "test_hour",
+ "test_day",
+ "test_year",
+ ]);
+
+ info("Sort on the expires column, descending order");
+ clickColumnHeader("expires");
+
+ // Again, only assert `test_session` for `test_session1` and `test_session2`.
+ checkCells([
+ "test_year",
+ "test_day",
+ "test_hour",
+ "test_session",
+ "test_session",
+ ]);
+
+ info("Sort on the name column, ascending order");
+ clickColumnHeader("name");
+ checkCells([
+ "test_day",
+ "test_hour",
+ "test_session1",
+ "test_session2",
+ "test_year",
+ ]);
+});
+
+function checkCells(expected) {
+ const cells = [
+ ...gPanelWindow.document.querySelectorAll("#name .table-widget-cell"),
+ ];
+ cells.forEach(function (cell, i, arr) {
+ // We use startsWith in order to avoid asserting the relative order of
+ // "session" cookies when sorting on the "expires" column.
+ ok(
+ cell.value.startsWith(expected[i]),
+ `Cell value starts with "${expected[i]}".`
+ );
+ });
+}
diff --git a/devtools/client/storage/test/browser_storage_cookies_tab_navigation.js b/devtools/client/storage/test/browser_storage_cookies_tab_navigation.js
new file mode 100644
index 0000000000..b7f1aaf159
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cookies_tab_navigation.js
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Basic test to check cookie table tab navigation.
+
+"use strict";
+
+add_task(async function () {
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-cookies.html");
+ showAllColumns(true);
+
+ const id = getCookieId("test1", ".test1.example.org", "/browser");
+ await startCellEdit(id, "name");
+
+ PressKeyXTimes("VK_TAB", 15);
+ is(getCurrentEditorValue(), "value3", "We have tabbed to the correct cell.");
+
+ PressKeyXTimes("VK_TAB", 15, { shiftKey: true });
+ is(
+ getCurrentEditorValue(),
+ "test1",
+ "We have shift-tabbed to the correct cell."
+ );
+});
diff --git a/devtools/client/storage/test/browser_storage_delete.js b/devtools/client/storage/test/browser_storage_delete.js
new file mode 100644
index 0000000000..96af0ca15d
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_delete.js
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test deleting storage items
+
+const TEST_CASES = [
+ [["localStorage", "http://test1.example.org"], "ls1", "name"],
+ [["sessionStorage", "http://test1.example.org"], "ss1", "name"],
+ [
+ ["cookies", "http://test1.example.org"],
+ getCookieId("c1", "test1.example.org", "/browser"),
+ "name",
+ ],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"],
+ 1,
+ "name",
+ ],
+ [
+ ["Cache", "http://test1.example.org", "plop"],
+ MAIN_DOMAIN + "404_cached_file.js",
+ "url",
+ ],
+];
+
+add_task(async function () {
+ // storage-listings.html explicitly mixes secure and insecure frames.
+ // We should not enforce https for tests using this page.
+ await pushPref("dom.security.https_first", false);
+
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html");
+
+ const contextMenu = gPanelWindow.document.getElementById(
+ "storage-table-popup"
+ );
+ const menuDeleteItem = contextMenu.querySelector(
+ "#storage-table-popup-delete"
+ );
+
+ for (const [treeItem, rowName, cellToClick] of TEST_CASES) {
+ const treeItemName = treeItem.join(" > ");
+
+ info(`Selecting tree item ${treeItemName}`);
+ await selectTreeItem(treeItem);
+
+ const row = getRowCells(rowName);
+ ok(
+ gUI.table.items.has(rowName),
+ `There is a row '${rowName}' in ${treeItemName}`
+ );
+
+ const eventWait = gUI.once("store-objects-edit");
+
+ await waitForContextMenu(contextMenu, row[cellToClick], () => {
+ info(`Opened context menu in ${treeItemName}, row '${rowName}'`);
+ contextMenu.activateItem(menuDeleteItem);
+ const truncatedRowName = String(rowName)
+ .replace(SEPARATOR_GUID, "-")
+ .substr(0, 16);
+ ok(
+ JSON.parse(
+ menuDeleteItem.getAttribute("data-l10n-args")
+ ).itemName.includes(truncatedRowName),
+ `Context menu item label contains '${rowName}' (maybe truncated)`
+ );
+ });
+
+ info("Awaiting for store-objects-edit event");
+ await eventWait;
+
+ ok(
+ !gUI.table.items.has(rowName),
+ `There is no row '${rowName}' in ${treeItemName} after deletion`
+ );
+ }
+});
diff --git a/devtools/client/storage/test/browser_storage_delete_all.js b/devtools/client/storage/test/browser_storage_delete_all.js
new file mode 100644
index 0000000000..4585821cee
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_delete_all.js
@@ -0,0 +1,115 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test deleting all storage items
+
+add_task(async function () {
+ // storage-listings.html explicitly mixes secure and insecure frames.
+ // We should not enforce https for tests using this page.
+ await pushPref("dom.security.https_first", false);
+
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html");
+
+ const contextMenu = gPanelWindow.document.getElementById(
+ "storage-table-popup"
+ );
+ const menuDeleteAllItem = contextMenu.querySelector(
+ "#storage-table-popup-delete-all"
+ );
+
+ info("test state before delete");
+ const beforeState = [
+ [
+ ["localStorage", "http://test1.example.org"],
+ ["key", "ls1", "ls2"],
+ ],
+ [["localStorage", "http://sectest1.example.org"], ["iframe-u-ls1"]],
+ [["localStorage", "https://sectest1.example.org"], ["iframe-s-ls1"]],
+ [
+ ["sessionStorage", "http://test1.example.org"],
+ ["key", "ss1"],
+ ],
+ [
+ ["sessionStorage", "http://sectest1.example.org"],
+ ["iframe-u-ss1", "iframe-u-ss2"],
+ ],
+ [["sessionStorage", "https://sectest1.example.org"], ["iframe-s-ss1"]],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"],
+ [1, 2, 3],
+ ],
+ [
+ ["Cache", "http://test1.example.org", "plop"],
+ [
+ MAIN_DOMAIN + "404_cached_file.js",
+ MAIN_DOMAIN + "browser_storage_basic.js",
+ ],
+ ],
+ ];
+
+ await checkState(beforeState);
+
+ info("do the delete");
+ const deleteHosts = [
+ [["localStorage", "https://sectest1.example.org"], "iframe-s-ls1", "name"],
+ [
+ ["sessionStorage", "https://sectest1.example.org"],
+ "iframe-s-ss1",
+ "name",
+ ],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"],
+ 1,
+ "name",
+ ],
+ [
+ ["Cache", "http://test1.example.org", "plop"],
+ MAIN_DOMAIN + "404_cached_file.js",
+ "url",
+ ],
+ ];
+
+ for (const [store, rowName, cellToClick] of deleteHosts) {
+ const storeName = store.join(" > ");
+
+ await selectTreeItem(store);
+
+ const eventWait = gUI.once("store-objects-cleared");
+
+ const cell = getRowCells(rowName)[cellToClick];
+ await waitForContextMenu(contextMenu, cell, () => {
+ info(`Opened context menu in ${storeName}, row '${rowName}'`);
+ contextMenu.activateItem(menuDeleteAllItem);
+ });
+
+ await eventWait;
+ }
+
+ info("test state after delete");
+ const afterState = [
+ // iframes from the same host, one secure, one unsecure, are independent
+ // from each other. Delete all in one doesn't touch the other one.
+ [
+ ["localStorage", "http://test1.example.org"],
+ ["key", "ls1", "ls2"],
+ ],
+ [["localStorage", "http://sectest1.example.org"], ["iframe-u-ls1"]],
+ [["localStorage", "https://sectest1.example.org"], []],
+ [
+ ["sessionStorage", "http://test1.example.org"],
+ ["key", "ss1"],
+ ],
+ [
+ ["sessionStorage", "http://sectest1.example.org"],
+ ["iframe-u-ss1", "iframe-u-ss2"],
+ ],
+ [["sessionStorage", "https://sectest1.example.org"], []],
+ [["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"], []],
+ [["Cache", "http://test1.example.org", "plop"], []],
+ ];
+
+ await checkState(afterState);
+});
diff --git a/devtools/client/storage/test/browser_storage_delete_tree.js b/devtools/client/storage/test/browser_storage_delete_tree.js
new file mode 100644
index 0000000000..047536acd7
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_delete_tree.js
@@ -0,0 +1,93 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test deleting all storage items from the tree.
+
+add_task(async function () {
+ // storage-listings.html explicitly mixes secure and insecure frames.
+ // We should not enforce https for tests using this page.
+ await pushPref("dom.security.https_first", false);
+
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html");
+
+ const contextMenu =
+ gPanelWindow.document.getElementById("storage-tree-popup");
+ const menuDeleteAllItem = contextMenu.querySelector(
+ "#storage-tree-popup-delete-all"
+ );
+
+ info("test state before delete");
+ await checkState([
+ [
+ ["cookies", "http://test1.example.org"],
+ [
+ getCookieId("c1", "test1.example.org", "/browser"),
+ getCookieId("cs2", ".example.org", "/"),
+ getCookieId("c3", "test1.example.org", "/"),
+ getCookieId("c4", ".example.org", "/"),
+ getCookieId("uc1", ".example.org", "/"),
+ getCookieId("uc2", ".example.org", "/"),
+ ],
+ ],
+ [
+ ["localStorage", "http://test1.example.org"],
+ ["key", "ls1", "ls2"],
+ ],
+ [
+ ["sessionStorage", "http://test1.example.org"],
+ ["key", "ss1"],
+ ],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"],
+ [1, 2, 3],
+ ],
+ [
+ ["Cache", "http://test1.example.org", "plop"],
+ [
+ MAIN_DOMAIN + "404_cached_file.js",
+ MAIN_DOMAIN + "browser_storage_basic.js",
+ ],
+ ],
+ ]);
+
+ info("do the delete");
+ const deleteHosts = [
+ ["cookies", "http://test1.example.org"],
+ ["localStorage", "http://test1.example.org"],
+ ["sessionStorage", "http://test1.example.org"],
+ ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"],
+ ["Cache", "http://test1.example.org", "plop"],
+ ];
+
+ for (const store of deleteHosts) {
+ const storeName = store.join(" > ");
+
+ await selectTreeItem(store);
+
+ const eventName =
+ "store-objects-" + (store[0] == "cookies" ? "edit" : "cleared");
+ const eventWait = gUI.once(eventName);
+
+ const selector = `[data-id='${JSON.stringify(store)}'] > .tree-widget-item`;
+ const target = gPanelWindow.document.querySelector(selector);
+ ok(target, `tree item found in ${storeName}`);
+ await waitForContextMenu(contextMenu, target, () => {
+ info(`Opened tree context menu in ${storeName}`);
+ contextMenu.activateItem(menuDeleteAllItem);
+ });
+
+ await eventWait;
+ }
+
+ info("test state after delete");
+ await checkState([
+ [["cookies", "http://test1.example.org"], []],
+ [["localStorage", "http://test1.example.org"], []],
+ [["sessionStorage", "http://test1.example.org"], []],
+ [["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"], []],
+ [["Cache", "http://test1.example.org", "plop"], []],
+ ]);
+});
diff --git a/devtools/client/storage/test/browser_storage_delete_usercontextid.js b/devtools/client/storage/test/browser_storage_delete_usercontextid.js
new file mode 100644
index 0000000000..5e89028f9d
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_delete_usercontextid.js
@@ -0,0 +1,238 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test deleting storage items with userContextId.
+
+// The items that will be deleted.
+const TEST_CASES = [
+ [["localStorage", "http://test1.example.org"], "ls1", "name"],
+ [["sessionStorage", "http://test1.example.org"], "ss1", "name"],
+ [
+ ["cookies", "http://test1.example.org"],
+ getCookieId("c1", "test1.example.org", "/browser"),
+ "name",
+ ],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"],
+ 1,
+ "name",
+ ],
+ [
+ ["Cache", "http://test1.example.org", "plop"],
+ MAIN_DOMAIN + "404_cached_file.js",
+ "url",
+ ],
+];
+
+// The storage items that should exist for default userContextId
+const storageItemsForDefault = [
+ [
+ ["cookies", "http://test1.example.org"],
+ [
+ getCookieId("c1", "test1.example.org", "/browser"),
+ getCookieId("cs2", ".example.org", "/"),
+ getCookieId("c3", "test1.example.org", "/"),
+ getCookieId("c4", ".example.org", "/"),
+ getCookieId("uc1", ".example.org", "/"),
+ getCookieId("uc2", ".example.org", "/"),
+ ],
+ ],
+ [
+ ["cookies", "https://sectest1.example.org"],
+ [
+ getCookieId("uc1", ".example.org", "/"),
+ getCookieId("uc2", ".example.org", "/"),
+ getCookieId("cs2", ".example.org", "/"),
+ getCookieId("c4", ".example.org", "/"),
+ getCookieId(
+ "sc1",
+ "sectest1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ getCookieId(
+ "sc2",
+ "sectest1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ ],
+ ],
+ [
+ ["localStorage", "http://test1.example.org"],
+ ["key", "ls1", "ls2"],
+ ],
+ [["localStorage", "http://sectest1.example.org"], ["iframe-u-ls1"]],
+ [["localStorage", "https://sectest1.example.org"], ["iframe-s-ls1"]],
+ [
+ ["sessionStorage", "http://test1.example.org"],
+ ["key", "ss1"],
+ ],
+ [
+ ["sessionStorage", "http://sectest1.example.org"],
+ ["iframe-u-ss1", "iframe-u-ss2"],
+ ],
+ [["sessionStorage", "https://sectest1.example.org"], ["iframe-s-ss1"]],
+ [
+ ["indexedDB", "http://test1.example.org"],
+ ["idb1 (default)", "idb2 (default)"],
+ ],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1 (default)"],
+ ["obj1", "obj2"],
+ ],
+ [["indexedDB", "http://test1.example.org", "idb2 (default)"], ["obj3"]],
+ [
+ ["indexedDB", "http://test1.example.org", "idb1 (default)", "obj1"],
+ [1, 2, 3],
+ ],
+ [["indexedDB", "http://test1.example.org", "idb1 (default)", "obj2"], [1]],
+ [["indexedDB", "http://test1.example.org", "idb2 (default)", "obj3"], []],
+ [["indexedDB", "http://sectest1.example.org"], []],
+ [
+ ["indexedDB", "https://sectest1.example.org"],
+ ["idb-s1 (default)", "idb-s2 (default)"],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)"],
+ ["obj-s1"],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)"],
+ ["obj-s2"],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s1 (default)", "obj-s1"],
+ [6, 7],
+ ],
+ [
+ ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)", "obj-s2"],
+ [16],
+ ],
+ [
+ ["Cache", "http://test1.example.org", "plop"],
+ [
+ MAIN_DOMAIN + "404_cached_file.js",
+ MAIN_DOMAIN + "browser_storage_basic.js",
+ ],
+ ],
+];
+
+/**
+ * Test that the desired number of tree items are present
+ */
+function testTree(tests) {
+ const doc = gPanelWindow.document;
+ for (const [item] of tests) {
+ ok(
+ doc.querySelector("[data-id='" + JSON.stringify(item) + "']"),
+ `Tree item ${item.toSource()} should be present in the storage tree`
+ );
+ }
+}
+
+/**
+ * Test that correct table entries are shown for each of the tree item
+ */
+async function testTables(tests) {
+ const doc = gPanelWindow.document;
+ // Expand all nodes so that the synthesized click event actually works
+ gUI.tree.expandAll();
+
+ // First tree item is already selected so no clicking and waiting for update
+ for (const id of tests[0][1]) {
+ ok(
+ doc.querySelector(".table-widget-cell[data-id='" + id + "']"),
+ "Table item " + id + " should be present"
+ );
+ }
+
+ // Click rest of the tree items and wait for the table to be updated
+ for (const [treeItem, items] of tests.slice(1)) {
+ await selectTreeItem(treeItem);
+
+ // Check whether correct number of items are present in the table
+ is(
+ doc.querySelectorAll(
+ ".table-widget-column:first-of-type .table-widget-cell"
+ ).length,
+ items.length,
+ "Number of items in table is correct"
+ );
+
+ // Check if all the desired items are present in the table
+ for (const id of items) {
+ ok(
+ doc.querySelector(".table-widget-cell[data-id='" + id + "']"),
+ "Table item " + id + " should be present"
+ );
+ }
+ }
+}
+
+add_task(async function () {
+ // storage-listings.html explicitly mixes secure and insecure frames.
+ // We should not enforce https for tests using this page.
+ await pushPref("dom.security.https_first", false);
+
+ // First, open a tab with the default userContextId and setup its storages.
+ const tabDefault = await openTab(MAIN_DOMAIN + "storage-listings.html");
+
+ // Second, start testing for userContextId 1.
+ // We use the same item name as the default page has to see deleting items
+ // from userContextId 1 will affect default one or not.
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html", {
+ userContextId: 1,
+ });
+
+ const contextMenu = gPanelWindow.document.getElementById(
+ "storage-table-popup"
+ );
+ const menuDeleteItem = contextMenu.querySelector(
+ "#storage-table-popup-delete"
+ );
+
+ for (const [treeItem, rowName, cellToClick] of TEST_CASES) {
+ const treeItemName = treeItem.join(" > ");
+
+ info(`Selecting tree item ${treeItemName}`);
+ await selectTreeItem(treeItem);
+
+ const row = getRowCells(rowName);
+ ok(
+ gUI.table.items.has(rowName),
+ `There is a row '${rowName}' in ${treeItemName}`
+ );
+
+ const eventWait = gUI.once("store-objects-edit");
+
+ await waitForContextMenu(contextMenu, row[cellToClick], () => {
+ info(`Opened context menu in ${treeItemName}, row '${rowName}'`);
+ contextMenu.activateItem(menuDeleteItem);
+ const truncatedRowName = String(rowName)
+ .replace(SEPARATOR_GUID, "-")
+ .substr(0, 16);
+ ok(
+ JSON.parse(
+ menuDeleteItem.getAttribute("data-l10n-args")
+ ).itemName.includes(truncatedRowName),
+ `Context menu item label contains '${rowName}' (maybe truncated)`
+ );
+ });
+
+ await eventWait;
+
+ ok(
+ !gUI.table.items.has(rowName),
+ `There is no row '${rowName}' in ${treeItemName} after deletion`
+ );
+ }
+
+ // Final, we see that the default tab is intact or not.
+ await BrowserTestUtils.switchTab(gBrowser, tabDefault);
+ await openStoragePanel();
+
+ testTree(storageItemsForDefault);
+ await testTables(storageItemsForDefault);
+});
diff --git a/devtools/client/storage/test/browser_storage_dfpi.js b/devtools/client/storage/test/browser_storage_dfpi.js
new file mode 100644
index 0000000000..14d625910e
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_dfpi.js
@@ -0,0 +1,164 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Basic test to assert that the storage tree and table corresponding to each
+// item in the storage tree is correctly displayed
+
+"use strict";
+
+const { SiteDataTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SiteDataTestUtils.sys.mjs"
+);
+
+// Ensure iframe.src in storage-dfpi.html starts with PREFIX.
+const PREFIX = "https://sub1.test1.example";
+const ORIGIN = `${PREFIX}.org`;
+const ORIGIN_THIRD_PARTY = `${PREFIX}.com`;
+const TEST_URL = `${ORIGIN}/${PATH}storage-dfpi.html`;
+
+function listOrigins() {
+ return new Promise(resolve => {
+ SpecialPowers.Services.qms.listOrigins().callback = req => {
+ resolve(req.result);
+ };
+ });
+}
+
+add_task(async function () {
+ await pushPref(
+ "network.cookie.cookieBehavior",
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN
+ );
+
+ await pushPref(
+ "privacy.partition.always_partition_third_party_non_cookie_storage",
+ false
+ );
+
+ registerCleanupFunction(SiteDataTestUtils.clear);
+
+ // `Services.qms.listOrigins()` may or contain results created by other tests.
+ // And it's unsafe to clear existing origins by `Services.qms.clear()`.
+ // In order to obtain correct results, we need to compare the results before
+ // and after `openTabAndSetupStorage` is called.
+ // To ensure more accurate results, try choosing a uncommon origin for PREFIX.
+ const EXISTING_ORIGINS = await listOrigins();
+ ok(!EXISTING_ORIGINS.includes(ORIGIN), `${ORIGIN} doesn't exist`);
+
+ await openTabAndSetupStorage(TEST_URL);
+
+ const origins = await listOrigins();
+ for (const origin of origins) {
+ ok(
+ EXISTING_ORIGINS.includes(origin) || origin === ORIGIN,
+ `check origin: ${origin}`
+ );
+ }
+ ok(origins.includes(ORIGIN), `${ORIGIN} is added`);
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+async function setPartitionedStorage(browser, type, key) {
+ const handler = async (storageType, storageKey, storageValue) => {
+ if (storageType == "cookie") {
+ content.document.cookie = `${storageKey}=${storageValue}`;
+ return;
+ }
+ content.localStorage.setItem(storageKey, storageValue);
+ };
+
+ // Set first party storage.
+ await SpecialPowers.spawn(browser, [type, key, "first"], handler);
+ // Set third-party (partitioned) storage in the iframe.
+ await SpecialPowers.spawn(
+ browser.browsingContext.children[0],
+ [type, key, "third"],
+ handler
+ );
+}
+
+async function checkData(storageType, key, value) {
+ if (storageType == "cookie") {
+ checkCookieData(key, value);
+ return;
+ }
+ await waitForStorageData(key, value);
+}
+
+async function testPartitionedStorage(
+ storageType,
+ treeItemLabel = storageType
+) {
+ await pushPref(
+ "network.cookie.cookieBehavior",
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN
+ );
+ // Bug 1617611: Fix all the tests broken by "cookies SameSite=lax by default"
+ await pushPref("network.cookie.sameSite.laxByDefault", false);
+
+ info(
+ "Open the test url in a new tab and add storage entries *before* opening the storage panel."
+ );
+ await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
+ await setPartitionedStorage(browser, storageType, "contextA");
+ });
+
+ await openTabAndSetupStorage(TEST_URL);
+
+ const doc = gPanelWindow.document;
+
+ info("check that both hosts appear in the storage tree");
+ checkTree(doc, [treeItemLabel, ORIGIN]);
+ checkTree(doc, [treeItemLabel, ORIGIN_THIRD_PARTY]);
+
+ info(
+ "check that items for both first and third party host have the initial storage entries"
+ );
+
+ await selectTreeItem([treeItemLabel, ORIGIN]);
+ await checkData(storageType, "contextA", "first");
+
+ await selectTreeItem([treeItemLabel, ORIGIN_THIRD_PARTY]);
+ await checkData(storageType, "contextA", "third");
+
+ info("Add more entries while the storage panel is open");
+ const onUpdated = gUI.once("store-objects-edit");
+ await setPartitionedStorage(
+ gBrowser.selectedBrowser,
+ storageType,
+ "contextB"
+ );
+ await onUpdated;
+
+ info("check that both hosts appear in the storage tree");
+ checkTree(doc, [treeItemLabel, ORIGIN]);
+ checkTree(doc, [treeItemLabel, ORIGIN_THIRD_PARTY]);
+
+ info(
+ "check that items for both first and third party host have the updated storage entries"
+ );
+
+ await selectTreeItem([treeItemLabel, ORIGIN]);
+ await checkData(storageType, "contextA", "first");
+ await checkData(storageType, "contextB", "first");
+
+ await selectTreeItem([treeItemLabel, ORIGIN_THIRD_PARTY]);
+ await checkData(storageType, "contextA", "third");
+ await checkData(storageType, "contextB", "third");
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+}
+
+// Tests that partitioned storage is shown in the storage panel.
+
+add_task(async function test_partitioned_cookies() {
+ registerCleanupFunction(SiteDataTestUtils.clear);
+ await testPartitionedStorage("cookie", "cookies");
+});
+
+add_task(async function test_partitioned_localStorage() {
+ registerCleanupFunction(SiteDataTestUtils.clear);
+ await testPartitionedStorage("localStorage");
+});
diff --git a/devtools/client/storage/test/browser_storage_dfpi_always_partition_storage.js b/devtools/client/storage/test/browser_storage_dfpi_always_partition_storage.js
new file mode 100644
index 0000000000..e2615fb951
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_dfpi_always_partition_storage.js
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Basic test to assert that the storage tree and table corresponding to each
+// item in the storage tree is correctly displayed, bearing in mind the origin
+// is partitioned when always_partition_third_party_non_cookie_storage is true.
+
+"use strict";
+
+const { SiteDataTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SiteDataTestUtils.sys.mjs"
+);
+
+// Ensure iframe.src in storage-dfpi.html starts with PREFIX.
+const PREFIX = "https://sub1.test1.example";
+const ORIGIN = `${PREFIX}.org`;
+const ORIGIN_PARTITIONED = `${PREFIX}.com^partitionKey=%28https%2Cexample.org%29`;
+const TEST_URL = `${ORIGIN}/document-builder.sjs?html=
+ <iframe src="${PREFIX}.com/browser/devtools/client/storage/test/storage-blank.html"></iframe>
+`;
+
+function listOrigins() {
+ return new Promise(resolve => {
+ SpecialPowers.Services.qms.listOrigins().callback = req => {
+ resolve(req.result);
+ };
+ });
+}
+
+add_task(async function () {
+ await pushPref(
+ "network.cookie.cookieBehavior",
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN
+ );
+
+ await pushPref(
+ "privacy.partition.always_partition_third_party_non_cookie_storage",
+ true
+ );
+
+ registerCleanupFunction(SiteDataTestUtils.clear);
+
+ const expectedOrigins = [ORIGIN, ORIGIN_PARTITIONED];
+
+ // `Services.qms.listOrigins()` may or contain results created by other tests.
+ // And it's unsafe to clear existing origins by `Services.qms.clear()`.
+ // In order to obtain correct results, we need to compare the results before
+ // and after `openTabAndSetupStorage` is called.
+ // To ensure more accurate results, try choosing a uncommon origin for PREFIX.
+ const EXISTING_ORIGINS = await listOrigins();
+ expectedOrigins.forEach(expected => {
+ ok(!EXISTING_ORIGINS.includes(expected), `${expected} doesn't exist`);
+ });
+
+ await openTabAndSetupStorage(TEST_URL);
+
+ const origins = await listOrigins();
+ for (const origin of origins) {
+ ok(
+ EXISTING_ORIGINS.includes(origin) || expectedOrigins.includes(origin),
+ `check origin: ${origin}`
+ );
+ }
+ expectedOrigins.forEach(expected => {
+ ok(origins.includes(expected), `${expected} is added`);
+ });
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
diff --git a/devtools/client/storage/test/browser_storage_dom_cache_disabled.js b/devtools/client/storage/test/browser_storage_dom_cache_disabled.js
new file mode 100644
index 0000000000..61e8541cbe
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_dom_cache_disabled.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 the storage inspector when dom.caches.enabled=false.
+
+add_task(async function () {
+ // Disable the DOM cache
+ await pushPref(DOM_CACHE, false);
+
+ // 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 state = [
+ [
+ ["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],
+ ],
+ ];
+
+ await checkState(state);
+});
diff --git a/devtools/client/storage/test/browser_storage_dynamic_updates_cookies.js b/devtools/client/storage/test/browser_storage_dynamic_updates_cookies.js
new file mode 100644
index 0000000000..77c8047fc3
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_dynamic_updates_cookies.js
@@ -0,0 +1,239 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+// Test dynamic updates in the storage inspector for cookies.
+
+add_task(async function () {
+ await openTabAndSetupStorage(MAIN_DOMAIN_SECURED + "storage-updates.html");
+
+ gUI.tree.expandAll();
+
+ ok(gUI.sidebar.hidden, "Sidebar is initially hidden");
+ const c1id = getCookieId("c1", "test1.example.org", "/browser");
+ await selectTableItem(c1id);
+
+ // test that value is something initially
+ const initialValue = [
+ [
+ { name: "c1", value: "1.2.3.4.5.6.7" },
+ { name: "c1.Path", value: "/browser" },
+ ],
+ [
+ { name: "c1", value: "Array" },
+ { name: "c1.0", value: "1" },
+ { name: "c1.6", value: "7" },
+ ],
+ ];
+
+ // test that value is something initially
+ const finalValue = [
+ [
+ { name: "c1", value: '{"foo": 4,"bar":6}' },
+ { name: "c1.Path", value: "/browser" },
+ ],
+ [
+ { name: "c1", value: "Object" },
+ { name: "c1.foo", value: "4" },
+ { name: "c1.bar", value: "6" },
+ ],
+ ];
+
+ // Check that sidebar shows correct initial value
+ await findVariableViewProperties(initialValue[0], false);
+
+ await findVariableViewProperties(initialValue[1], true);
+
+ // Check if table shows correct initial value
+ await checkState([
+ [
+ ["cookies", "https://test1.example.org"],
+ [
+ getCookieId("c1", "test1.example.org", "/browser"),
+ getCookieId("c2", "test1.example.org", "/browser"),
+ ],
+ ],
+ ]);
+ checkCell(c1id, "value", "1.2.3.4.5.6.7");
+
+ await addCookie("c1", '{"foo": 4,"bar":6}', "/browser");
+ await gUI.once("store-objects-edit");
+
+ await findVariableViewProperties(finalValue[0], false);
+ await findVariableViewProperties(finalValue[1], true);
+
+ await checkState([
+ [
+ ["cookies", "https://test1.example.org"],
+ [
+ getCookieId("c1", "test1.example.org", "/browser"),
+ getCookieId("c2", "test1.example.org", "/browser"),
+ ],
+ ],
+ ]);
+ checkCell(c1id, "value", '{"foo": 4,"bar":6}');
+
+ // Add a new entry
+ await addCookie("c3", "booyeah");
+
+ await gUI.once("store-objects-edit");
+
+ await checkState([
+ [
+ ["cookies", "https://test1.example.org"],
+ [
+ getCookieId("c1", "test1.example.org", "/browser"),
+ getCookieId("c2", "test1.example.org", "/browser"),
+ getCookieId(
+ "c3",
+ "test1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ ],
+ ],
+ ]);
+ const c3id = getCookieId(
+ "c3",
+ "test1.example.org",
+ "/browser/devtools/client/storage/test"
+ );
+ checkCell(c3id, "value", "booyeah");
+
+ // Add another
+ await addCookie("c4", "booyeah");
+
+ await gUI.once("store-objects-edit");
+
+ await checkState([
+ [
+ ["cookies", "https://test1.example.org"],
+ [
+ getCookieId("c1", "test1.example.org", "/browser"),
+ getCookieId("c2", "test1.example.org", "/browser"),
+ getCookieId(
+ "c3",
+ "test1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ getCookieId(
+ "c4",
+ "test1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ ],
+ ],
+ ]);
+ const c4id = getCookieId(
+ "c4",
+ "test1.example.org",
+ "/browser/devtools/client/storage/test"
+ );
+ checkCell(c4id, "value", "booyeah");
+
+ // Removing cookies
+ await removeCookie("c1", "/browser");
+
+ await gUI.once("store-objects-edit");
+
+ await checkState([
+ [
+ ["cookies", "https://test1.example.org"],
+ [
+ getCookieId("c2", "test1.example.org", "/browser"),
+ getCookieId(
+ "c3",
+ "test1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ getCookieId(
+ "c4",
+ "test1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ ],
+ ],
+ ]);
+
+ ok(!gUI.sidebar.hidden, "Sidebar still visible for next row");
+
+ // Check if next element's value is visible in sidebar
+ await findVariableViewProperties([{ name: "c2", value: "foobar" }]);
+
+ // Keep deleting till no rows
+ await removeCookie("c3");
+
+ await gUI.once("store-objects-edit");
+
+ await checkState([
+ [
+ ["cookies", "https://test1.example.org"],
+ [
+ getCookieId("c2", "test1.example.org", "/browser"),
+ getCookieId(
+ "c4",
+ "test1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ ],
+ ],
+ ]);
+
+ // Check if next element's value is visible in sidebar
+ await findVariableViewProperties([{ name: "c2", value: "foobar" }]);
+
+ await removeCookie("c2", "/browser");
+
+ await gUI.once("store-objects-edit");
+
+ await checkState([
+ [
+ ["cookies", "https://test1.example.org"],
+ [
+ getCookieId(
+ "c4",
+ "test1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ ],
+ ],
+ ]);
+
+ // Check if next element's value is visible in sidebar
+ await findVariableViewProperties([{ name: "c4", value: "booyeah" }]);
+
+ await removeCookie("c4");
+
+ await gUI.once("store-objects-edit");
+
+ await checkState([[["cookies", "https://test1.example.org"], []]]);
+
+ ok(gUI.sidebar.hidden, "Sidebar is hidden when no rows");
+});
+
+async function addCookie(name, value, path) {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[name, value, path]],
+ ([nam, valu, pat]) => {
+ content.wrappedJSObject.addCookie(nam, valu, pat);
+ }
+ );
+}
+
+async function removeCookie(name, path) {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[name, path]],
+ ([nam, pat]) => {
+ content.wrappedJSObject.removeCookie(nam, pat);
+ }
+ );
+}
diff --git a/devtools/client/storage/test/browser_storage_dynamic_updates_localStorage.js b/devtools/client/storage/test/browser_storage_dynamic_updates_localStorage.js
new file mode 100644
index 0000000000..f220d16b0c
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_dynamic_updates_localStorage.js
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test dynamic updates in the storage inspector for localStorage.
+
+add_task(async function () {
+ const TEST_HOST = "https://test1.example.org";
+
+ await openTabAndSetupStorage(MAIN_DOMAIN_SECURED + "storage-updates.html");
+
+ gUI.tree.expandAll();
+
+ ok(gUI.sidebar.hidden, "Sidebar is initially hidden");
+
+ const expectedKeys = ["1", "2", "3", "4", "5", "null", "non-json-parsable"];
+
+ // Test on string keys that JSON.parse can parse without throwing
+ // (to verify the issue fixed by Bug 1578447 doesn't regress).
+ await testRemoveAndChange("null", expectedKeys, TEST_HOST);
+ await testRemoveAndChange("4", expectedKeys, TEST_HOST);
+ // Test on a string that makes JSON.parse to throw.
+ await testRemoveAndChange("non-json-parsable", expectedKeys, TEST_HOST);
+
+ // Clearing items.
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ content.wrappedJSObject.clear();
+ });
+
+ await gUI.once("store-objects-cleared");
+
+ await checkState([[["localStorage", TEST_HOST], []]]);
+});
+
+async function testRemoveAndChange(targetKey, expectedKeys, host) {
+ await checkState([[["localStorage", host], expectedKeys]]);
+
+ await removeLocalStorageItem(targetKey);
+ await gUI.once("store-objects-edit");
+ await checkState([
+ [["localStorage", host], expectedKeys.filter(key => key !== targetKey)],
+ ]);
+
+ await setLocalStorageItem(targetKey, "again");
+ await gUI.once("store-objects-edit");
+ await checkState([[["localStorage", host], expectedKeys]]);
+
+ // Updating a row set to the string "null"
+ await setLocalStorageItem(targetKey, `key-${targetKey}-changed`);
+ await gUI.once("store-objects-edit");
+ checkCell(targetKey, "value", `key-${targetKey}-changed`);
+}
+
+async function setLocalStorageItem(key, value) {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[key, value]],
+ ([innerKey, innerValue]) => {
+ content.wrappedJSObject.localStorage.setItem(innerKey, innerValue);
+ }
+ );
+}
+
+async function removeLocalStorageItem(key) {
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [key], innerKey => {
+ content.wrappedJSObject.localStorage.removeItem(innerKey);
+ });
+}
diff --git a/devtools/client/storage/test/browser_storage_dynamic_updates_sessionStorage.js b/devtools/client/storage/test/browser_storage_dynamic_updates_sessionStorage.js
new file mode 100644
index 0000000000..a6aa6890d5
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_dynamic_updates_sessionStorage.js
@@ -0,0 +1,90 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test dynamic updates in the storage inspector for sessionStorage.
+
+add_task(async function () {
+ await openTabAndSetupStorage(MAIN_DOMAIN_SECURED + "storage-updates.html");
+
+ gUI.tree.expandAll();
+
+ ok(gUI.sidebar.hidden, "Sidebar is initially hidden");
+
+ await checkState([
+ [
+ ["sessionStorage", "https://test1.example.org"],
+ ["ss1", "ss2", "ss3"],
+ ],
+ ]);
+
+ await setSessionStorageItem("ss4", "new-item");
+
+ await gUI.once("store-objects-edit");
+
+ await checkState([
+ [
+ ["sessionStorage", "https://test1.example.org"],
+ ["ss1", "ss2", "ss3", "ss4"],
+ ],
+ ]);
+
+ // deleting item
+
+ await removeSessionStorageItem("ss3");
+
+ await gUI.once("store-objects-edit");
+
+ await removeSessionStorageItem("ss1");
+
+ await gUI.once("store-objects-edit");
+
+ await checkState([
+ [
+ ["sessionStorage", "https://test1.example.org"],
+ ["ss2", "ss4"],
+ ],
+ ]);
+
+ await selectTableItem("ss2");
+
+ ok(!gUI.sidebar.hidden, "sidebar is visible");
+
+ // Checking for correct value in sidebar before update
+ await findVariableViewProperties([{ name: "ss2", value: "foobar" }]);
+
+ await setSessionStorageItem("ss2", "changed=ss2");
+
+ await gUI.once("sidebar-updated");
+
+ checkCell("ss2", "value", "changed=ss2");
+
+ await findVariableViewProperties([{ name: "ss2", value: "changed=ss2" }]);
+
+ // Clearing items.
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ content.wrappedJSObject.clear();
+ });
+
+ await gUI.once("store-objects-cleared");
+
+ await checkState([[["sessionStorage", "https://test1.example.org"], []]]);
+});
+
+async function setSessionStorageItem(key, value) {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[key, value]],
+ ([innerKey, innerValue]) => {
+ content.wrappedJSObject.sessionStorage.setItem(innerKey, innerValue);
+ }
+ );
+}
+
+async function removeSessionStorageItem(key) {
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [key], innerKey => {
+ content.wrappedJSObject.sessionStorage.removeItem(innerKey);
+ });
+}
diff --git a/devtools/client/storage/test/browser_storage_empty_objectstores.js b/devtools/client/storage/test/browser_storage_empty_objectstores.js
new file mode 100644
index 0000000000..647e1b362a
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_empty_objectstores.js
@@ -0,0 +1,90 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Basic test to assert that the storage tree and table corresponding to each
+// item in the storage tree is correctly displayed.
+
+"use strict";
+
+// Entries that should be present in the tree for this test
+// Format for each entry in the array:
+// [
+// ["path", "to", "tree", "item"],
+// - The path to the tree item to click formed by id of each item
+// ["key_value1", "key_value2", ...]
+// - The value of the first (unique) column for each row in the table
+// corresponding to the tree item selected.
+// ]
+// These entries are formed by the cookies, local storage, session storage and
+// indexedDB entries created in storage-listings.html,
+// storage-secured-iframe.html and storage-unsecured-iframe.html
+const storeItems = [
+ [
+ ["indexedDB", "https://test1.example.org"],
+ ["idb1 (default)", "idb2 (default)"],
+ ],
+ [
+ ["indexedDB", "https://test1.example.org", "idb1 (default)"],
+ ["obj1", "obj2"],
+ ],
+ [["indexedDB", "https://test1.example.org", "idb2 (default)"], []],
+ [
+ ["indexedDB", "https://test1.example.org", "idb1 (default)", "obj1"],
+ [1, 2, 3],
+ ],
+ [["indexedDB", "https://test1.example.org", "idb1 (default)", "obj2"], [1]],
+];
+
+/**
+ * Test that the desired number of tree items are present
+ */
+function testTree() {
+ const doc = gPanelWindow.document;
+ for (const [item] of storeItems) {
+ ok(
+ doc.querySelector(`[data-id='${JSON.stringify(item)}']`),
+ `Tree item ${item} should be present in the storage tree`
+ );
+ }
+}
+
+/**
+ * Test that correct table entries are shown for each of the tree item
+ */
+const testTables = async function () {
+ const doc = gPanelWindow.document;
+ // Expand all nodes so that the synthesized click event actually works
+ gUI.tree.expandAll();
+
+ // Click the tree items and wait for the table to be updated
+ for (const [item, ids] of storeItems) {
+ await selectTreeItem(item);
+
+ // Check whether correct number of items are present in the table
+ is(
+ doc.querySelectorAll(
+ ".table-widget-column:first-of-type .table-widget-cell"
+ ).length,
+ ids.length,
+ "Number of items in table is correct"
+ );
+
+ // Check if all the desired items are present in the table
+ for (const id of ids) {
+ ok(
+ doc.querySelector(".table-widget-cell[data-id='" + id + "']"),
+ `Table item ${id} should be present`
+ );
+ }
+ }
+};
+
+add_task(async function () {
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN_SECURED + "storage-empty-objectstores.html"
+ );
+
+ testTree();
+ await testTables();
+});
diff --git a/devtools/client/storage/test/browser_storage_file_url.js b/devtools/client/storage/test/browser_storage_file_url.js
new file mode 100644
index 0000000000..7e0d3c0283
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_file_url.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test to verify that various storage types work when using file:// URLs.
+
+"use strict";
+
+add_task(async function () {
+ const TESTPAGE = "storage-file-url.html";
+
+ // We need to load TESTPAGE using a file:// path so we need to get that from
+ // the current test path.
+ const testPath = getResolvedURI(gTestPath);
+ const dir = getChromeDir(testPath);
+
+ // Then append TESTPAGE to the test path.
+ dir.append(TESTPAGE);
+
+ // Then generate a FileURI to ensure the path is valid.
+ const uriString = Services.io.newFileURI(dir).spec;
+
+ // Now we have a valid file:// URL pointing to TESTPAGE.
+ await openTabAndSetupStorage(uriString);
+
+ // uriString points to the test inside objdir e.g.
+ // `/path/to/fx/objDir/_tests/testing/mochitest/browser/devtools/client/
+ // storage/test/storage-file-url.html`.
+ //
+ // When opened in the browser this may resolve to a different path e.g.
+ // `path/to/fx/repo/devtools/client/storage/test/storage-file-url.html`.
+ //
+ // The easiest way to get the actual path is to request it from the content
+ // process.
+ const browser = gBrowser.selectedBrowser;
+ const actualPath = await SpecialPowers.spawn(browser, [], () => {
+ return content.document.location.href;
+ });
+
+ const cookiePath = actualPath
+ .substr(0, actualPath.lastIndexOf("/"))
+ .replace(/file:\/\//g, "");
+ await checkState([
+ [
+ ["cookies", actualPath],
+ [
+ getCookieId("test1", "", cookiePath),
+ getCookieId("test2", "", cookiePath),
+ ],
+ ],
+ [
+ ["indexedDB", actualPath, "MyDatabase (default)", "MyObjectStore"],
+ [12345, 54321, 67890, 98765],
+ ],
+ [
+ ["localStorage", actualPath],
+ ["test3", "test4"],
+ ],
+ [
+ ["sessionStorage", actualPath],
+ ["test5", "test6"],
+ ],
+ ]);
+});
diff --git a/devtools/client/storage/test/browser_storage_fission_cache.js b/devtools/client/storage/test/browser_storage_fission_cache.js
new file mode 100644
index 0000000000..4b9223198c
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_fission_cache.js
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Bug 1746646: Make mochitests work with TCP enabled (cookieBehavior = 5)
+// All instances of addPermission and removePermission set up 3rd-party storage
+// access in a way that allows the test to proceed with TCP enabled.
+
+add_task(async function () {
+ // open tab
+ const URL = URL_ROOT_COM_SSL + "storage-cache-basic.html";
+ await SpecialPowers.addPermission(
+ "3rdPartyStorage^https://example.net",
+ true,
+ URL
+ );
+ await openTabAndSetupStorage(URL);
+ const doc = gPanelWindow.document;
+
+ // check that host appears in the storage tree
+ checkTree(doc, ["Cache", "https://example.com", "lorem"]);
+ checkTree(doc, ["Cache", "https://example.net", "foo"]);
+ // Check top level page
+ await selectTreeItem(["Cache", "https://example.com", "lorem"]);
+ checkCacheData(URL_ROOT_COM_SSL + "storage-blank.html", "OK");
+ // Check iframe
+ await selectTreeItem(["Cache", "https://example.net", "foo"]);
+ checkCacheData(URL_ROOT_NET_SSL + "storage-blank.html", "OK");
+
+ await SpecialPowers.removePermission(
+ "3rdPartyStorage^http://example.net",
+ URL
+ );
+});
+
+function checkCacheData(url, status) {
+ is(
+ gUI.table.items.get(url)?.status,
+ status,
+ `Table row has an entry for: ${url} with status: ${status}`
+ );
+}
diff --git a/devtools/client/storage/test/browser_storage_fission_cookies.js b/devtools/client/storage/test/browser_storage_fission_cookies.js
new file mode 100644
index 0000000000..4628643188
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_fission_cookies.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ // Bug 1617611: Fix all the tests broken by "cookies SameSite=lax by default"
+ set: [
+ ["network.cookie.sameSite.laxByDefault", false],
+ [
+ "privacy.partition.always_partition_third_party_non_cookie_storage",
+ false,
+ ],
+ ],
+ });
+
+ const URL_IFRAME = buildURLWithContent(
+ "example.net",
+ `<h1>iframe</h1>` + `<script>document.cookie = "lorem=ipsum";</script>`
+ );
+
+ const URL_MAIN = buildURLWithContent(
+ "example.com",
+ `<h1>Main</h1>` +
+ `<script>document.cookie="foo=bar";</script>` +
+ `<iframe src="${URL_IFRAME}">`
+ );
+
+ // open tab
+ await openTabAndSetupStorage(URL_MAIN);
+ const doc = gPanelWindow.document;
+
+ // check that both hosts appear in the storage tree
+ checkTree(doc, ["cookies", "https://example.com"]);
+ checkTree(doc, ["cookies", "https://example.net"]);
+ // check the table for values
+ await selectTreeItem(["cookies", "https://example.com"]);
+ checkCookieData("foo", "bar");
+ await selectTreeItem(["cookies", "https://example.net"]);
+ checkCookieData("lorem", "ipsum");
+
+ info("Add more cookies");
+ const onUpdated = gUI.once("store-objects-edit");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ content.window.document.cookie = "foo2=bar2";
+
+ const iframe = content.document.querySelector("iframe");
+ return SpecialPowers.spawn(iframe, [], () => {
+ content.document.cookie = "lorem2=ipsum2";
+ });
+ });
+ await onUpdated;
+
+ // check that the new data is shown in the table for the iframe document
+ checkCookieData("lorem2", "ipsum2");
+
+ // check that the new data is shown in the table for the top-level document
+ await selectTreeItem(["cookies", "https://example.com"]);
+ checkCookieData("foo2", "bar2");
+
+ SpecialPowers.clearUserPref("network.cookie.sameSite.laxByDefault");
+});
diff --git a/devtools/client/storage/test/browser_storage_fission_hide_aboutblank.js b/devtools/client/storage/test/browser_storage_fission_hide_aboutblank.js
new file mode 100644
index 0000000000..4dab607cc3
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_fission_hide_aboutblank.js
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+add_task(async function () {
+ const html = `<h1>about:blank iframe</h1><iframe src="about:blank"></iframe>`;
+ const url = `https://example.com/document-builder.sjs?html=${encodeURI(
+ html
+ )}`;
+ // open tab
+ await openTabAndSetupStorage(url);
+ const doc = gPanelWindow.document;
+
+ checkTree(doc, ["localStorage", "https://example.com"], true);
+ checkTree(doc, ["localStorage", "about:blank"], false);
+});
+
+add_task(async function () {
+ // open tab with about:blank as top-level page
+ await openTabAndSetupStorage("about:blank");
+ const doc = gPanelWindow.document;
+
+ checkTree(doc, ["localStorage"], true);
+});
diff --git a/devtools/client/storage/test/browser_storage_fission_indexeddb.js b/devtools/client/storage/test/browser_storage_fission_indexeddb.js
new file mode 100644
index 0000000000..ae43a77249
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_fission_indexeddb.js
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+add_task(async function () {
+ const URL = URL_ROOT_COM_SSL + "storage-indexeddb-iframe.html";
+
+ // open tab
+ await openTabAndSetupStorage(URL);
+ const doc = gPanelWindow.document;
+
+ // check that host appears in the storage tree
+ checkTree(doc, ["indexedDB", "https://example.com"]);
+ // check the table for values
+ await selectTreeItem([
+ "indexedDB",
+ "https://example.com",
+ "db (default)",
+ "store",
+ ]);
+ checkStorageData("foo", JSON.stringify({ key: "foo", value: "bar" }));
+
+ // check that host appears in the storage tree
+ checkTree(doc, ["indexedDB", "https://example.net"]);
+ // check the table for values
+ await selectTreeItem([
+ "indexedDB",
+ "https://example.net",
+ "db (default)",
+ "store",
+ ]);
+ checkStorageData("lorem", JSON.stringify({ key: "lorem", value: "ipsum" }));
+
+ info("Add new data to the iframe DB");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ const iframe = content.document.querySelector("iframe");
+ return SpecialPowers.spawn(iframe, [], async function () {
+ return new Promise(resolve => {
+ const request = content.window.indexedDB.open("db", 1);
+ request.onsuccess = event => {
+ const db = event.target.result;
+ const transaction = db.transaction(["store"], "readwrite");
+ const addRequest = transaction
+ .objectStore("store")
+ .add({ key: "hello", value: "world" });
+ addRequest.onsuccess = () => resolve();
+ };
+ });
+ });
+ });
+
+ info("Refreshing table");
+ doc.querySelector("#refresh-button").click();
+
+ info("Check that table has new row");
+ await waitUntil(() =>
+ hasStorageData("hello", JSON.stringify({ key: "hello", value: "world" }))
+ );
+ ok(true, "Table has the new data");
+});
diff --git a/devtools/client/storage/test/browser_storage_fission_local_storage.js b/devtools/client/storage/test/browser_storage_fission_local_storage.js
new file mode 100644
index 0000000000..ae49516031
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_fission_local_storage.js
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+add_task(async function () {
+ const URL_IFRAME = buildURLWithContent(
+ "example.net",
+ `<h1>iframe</h1>` +
+ `<script>localStorage.setItem("lorem", "ipsum");</script>`
+ );
+ const URL_MAIN = buildURLWithContent(
+ "example.com",
+ `<h1>Main</h1>` +
+ `<script>localStorage.setItem("foo", "bar");</script>` +
+ `<iframe src="${URL_IFRAME}">`
+ );
+
+ // open tab
+ await openTabAndSetupStorage(URL_MAIN);
+ const doc = gPanelWindow.document;
+
+ // check that both hosts appear in the storage tree
+ checkTree(doc, ["localStorage", "https://example.com"]);
+ // check the table for values
+ await selectTreeItem(["localStorage", "https://example.com"]);
+ await waitForStorageData("foo", "bar");
+ await selectTreeItem(["localStorage", "https://example.net"]);
+ await waitForStorageData("lorem", "ipsum");
+
+ // add more storage data to the main wrapper
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ content.window.localStorage.setItem("foo2", "bar2");
+ const iframe = content.document.querySelector("iframe");
+ return SpecialPowers.spawn(iframe, [], () => {
+ content.window.localStorage.setItem("lorem2", "ipsum2");
+ });
+ });
+ // check that the new data is shown in the table
+ await selectTreeItem(["localStorage", "https://example.com"]);
+ await waitForStorageData("foo2", "bar2");
+ await selectTreeItem(["localStorage", "https://example.net"]);
+ await waitForStorageData("lorem2", "ipsum2");
+});
diff --git a/devtools/client/storage/test/browser_storage_fission_session_storage.js b/devtools/client/storage/test/browser_storage_fission_session_storage.js
new file mode 100644
index 0000000000..a3aa475868
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_fission_session_storage.js
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+add_task(async function () {
+ const URL_IFRAME = buildURLWithContent(
+ "example.net",
+ `<h1>iframe</h1>` +
+ `<script>sessionStorage.setItem("lorem", "ipsum");</script>`
+ );
+ const URL_MAIN = buildURLWithContent(
+ "example.com",
+ `<h1>Main</h1>` +
+ `<script>sessionStorage.setItem("foo", "bar");</script>` +
+ `<iframe src="${URL_IFRAME}">`
+ );
+
+ // open tab
+ await openTabAndSetupStorage(URL_MAIN);
+ const doc = gPanelWindow.document;
+
+ // check that both hosts appear in the storage tree
+ checkTree(doc, ["sessionStorage", "https://example.com"]);
+ // check the table for values
+ await selectTreeItem(["sessionStorage", "https://example.com"]);
+ await waitForStorageData("foo", "bar");
+ await selectTreeItem(["sessionStorage", "https://example.net"]);
+ await waitForStorageData("lorem", "ipsum");
+
+ // add more storage data to the main wrapper
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ content.window.sessionStorage.setItem("foo2", "bar2");
+ const iframe = content.document.querySelector("iframe");
+ return SpecialPowers.spawn(iframe, [], () => {
+ content.window.sessionStorage.setItem("lorem2", "ipsum2");
+ });
+ });
+ // check that the new data is shown in the table
+ await selectTreeItem(["sessionStorage", "https://example.com"]);
+ await waitForStorageData("foo2", "bar2");
+ await selectTreeItem(["sessionStorage", "https://example.net"]);
+ await waitForStorageData("lorem2", "ipsum2");
+});
diff --git a/devtools/client/storage/test/browser_storage_indexeddb_add_button_hidden.js b/devtools/client/storage/test/browser_storage_indexeddb_add_button_hidden.js
new file mode 100644
index 0000000000..6cee0dc493
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_indexeddb_add_button_hidden.js
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test that the add button is hidden for the indexedDB storage type.
+add_task(async function () {
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN_SECURED + "storage-empty-objectstores.html"
+ );
+
+ info("Select an indexedDB item");
+ const idbItem = ["indexedDB", "https://test1.example.org", "idb1 (default)"];
+ await selectTreeItem(idbItem);
+ checkAddButtonState({ expectHidden: true });
+
+ // Note: test only one of the other stoage types to check that the logic to
+ // find the add button is not outdated. Other storage types have more detailed
+ // tests focused on the add feature.
+ info("Select a cookie item");
+ const cookieItem = ["cookies", "https://test1.example.org"];
+ await selectTreeItem(cookieItem);
+ checkAddButtonState({ expectHidden: false });
+});
+
+function checkAddButtonState({ expectHidden }) {
+ const toolbar = gPanelWindow.document.getElementById("storage-toolbar");
+ const addButton = toolbar.querySelector("#add-button");
+ is(
+ addButton.hidden,
+ expectHidden,
+ `The add button is ${expectHidden ? "hidden" : "displayed"}`
+ );
+}
diff --git a/devtools/client/storage/test/browser_storage_indexeddb_delete.js b/devtools/client/storage/test/browser_storage_indexeddb_delete.js
new file mode 100644
index 0000000000..8c2f6bbf71
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_indexeddb_delete.js
@@ -0,0 +1,54 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test deleting indexedDB database from the tree using context menu
+
+add_task(async function () {
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN_SECURED + "storage-empty-objectstores.html"
+ );
+
+ const contextMenu =
+ gPanelWindow.document.getElementById("storage-tree-popup");
+ const menuDeleteDb = contextMenu.querySelector("#storage-tree-popup-delete");
+
+ info("test state before delete");
+ await checkState([
+ [
+ ["indexedDB", "https://test1.example.org"],
+ ["idb1 (default)", "idb2 (default)"],
+ ],
+ ]);
+
+ info("do the delete");
+ const deletedDb = [
+ "indexedDB",
+ "https://test1.example.org",
+ "idb1 (default)",
+ ];
+
+ await selectTreeItem(deletedDb);
+
+ // Wait once for update and another time for value fetching
+ const eventWait = gUI.once("store-objects-updated");
+
+ const selector = `[data-id='${JSON.stringify(
+ deletedDb
+ )}'] > .tree-widget-item`;
+ const target = gPanelWindow.document.querySelector(selector);
+ ok(target, `tree item found in ${deletedDb.join(" > ")}`);
+ await waitForContextMenu(contextMenu, target, () => {
+ info(`Opened tree context menu in ${deletedDb.join(" > ")}`);
+ menuDeleteDb.click();
+ });
+
+ await eventWait;
+
+ info("test state after delete");
+ await checkState([
+ [["indexedDB", "https://test1.example.org"], ["idb2 (default)"]],
+ ]);
+});
diff --git a/devtools/client/storage/test/browser_storage_indexeddb_delete_blocked.js b/devtools/client/storage/test/browser_storage_indexeddb_delete_blocked.js
new file mode 100644
index 0000000000..5b27816426
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_indexeddb_delete_blocked.js
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test what happens when deleting indexedDB database is blocked
+
+add_task(async function () {
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN_SECURED + "storage-idb-delete-blocked.html"
+ );
+
+ info("test state before delete");
+ await checkState([
+ [["indexedDB", "https://test1.example.org"], ["idb (default)"]],
+ ]);
+
+ info("do the delete");
+ await selectTreeItem(["indexedDB", "https://test1.example.org"]);
+ const front = gUI.getCurrentFront();
+ let result = await front.removeDatabase(
+ "https://test1.example.org",
+ "idb (default)"
+ );
+
+ ok(result.blocked, "removeDatabase attempt is blocked");
+
+ info("test state after blocked delete");
+ await checkState([
+ [["indexedDB", "https://test1.example.org"], ["idb (default)"]],
+ ]);
+
+ const eventWait = gUI.once("store-objects-edit");
+
+ info("telling content to close the db");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ const win = content.wrappedJSObject;
+ await win.closeDb();
+ });
+
+ info("waiting for store edit events");
+ await eventWait;
+
+ info("test state after real delete");
+ await checkState([[["indexedDB", "https://test1.example.org"], []]]);
+
+ info("try to delete database from nonexistent host");
+ let errorThrown = false;
+ try {
+ result = await front.removeDatabase(
+ "https://test2.example.org",
+ "idb (default)"
+ );
+ } catch (ex) {
+ errorThrown = true;
+ }
+
+ ok(errorThrown, "error was reported when trying to delete");
+});
diff --git a/devtools/client/storage/test/browser_storage_indexeddb_duplicate_names.js b/devtools/client/storage/test/browser_storage_indexeddb_duplicate_names.js
new file mode 100644
index 0000000000..cc6ac951fc
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_indexeddb_duplicate_names.js
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test to verify that indexedDBs with duplicate names (different types / paths)
+// work as expected.
+
+"use strict";
+
+add_task(async function () {
+ const TESTPAGE =
+ MAIN_DOMAIN_SECURED + "storage-indexeddb-duplicate-names.html";
+
+ setPermission(TESTPAGE, "indexedDB");
+
+ await openTabAndSetupStorage(TESTPAGE);
+
+ await checkState([
+ [
+ ["indexedDB", "https://test1.example.org"],
+ ["idb1 (default)", "idb2 (default)"],
+ ],
+ ]);
+});
diff --git a/devtools/client/storage/test/browser_storage_indexeddb_hide_internal_dbs.js b/devtools/client/storage/test/browser_storage_indexeddb_hide_internal_dbs.js
new file mode 100644
index 0000000000..35906256b1
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_indexeddb_hide_internal_dbs.js
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/framework/browser-toolbox/test/helpers-browser-toolbox.js",
+ this
+);
+
+// Test that internal DBs are hidden in the regular toolbox,but visible in the
+// Browser Toolbox
+add_task(async function () {
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN_SECURED + "storage-empty-objectstores.html"
+ );
+ const doc = gPanelWindow.document;
+
+ // check regular toolbox
+ info("Check indexedDB tree in toolbox");
+ const hosts = getDBHostsInTree(doc);
+ is(hosts.length, 1, "There is only one host for indexedDB storage");
+ is(hosts[0], "https://test1.example.org", "Host is test1.example.org");
+
+ // check browser toolbox
+ info("awaiting to open browser toolbox");
+ const ToolboxTask = await initBrowserToolboxTask();
+ await ToolboxTask.importFunctions({ getDBHostsInTree });
+
+ await ToolboxTask.spawn(null, async () => {
+ info("Selecting storage panel");
+ await gToolbox.selectTool("storage");
+ info("Check indexedDB tree in browser toolbox");
+ const browserToolboxDoc = gToolbox.getCurrentPanel().panelWindow.document;
+
+ const browserToolboxHosts = getDBHostsInTree(browserToolboxDoc);
+ ok(browserToolboxHosts.length > 1, "There are more than 1 indexedDB hosts");
+ ok(
+ browserToolboxHosts.includes("about:devtools-toolbox"),
+ "about:devtools-toolbox host is present"
+ );
+ ok(browserToolboxHosts.includes("chrome"), "chrome host is present");
+ ok(
+ browserToolboxHosts.includes("indexeddb+++fx-devtools"),
+ "fx-devtools host is present"
+ );
+ });
+
+ info("Destroying browser toolbox");
+ await ToolboxTask.destroy();
+});
+
+function getDBHostsInTree(doc) {
+ const treeId = JSON.stringify(["indexedDB"]);
+ const items = doc.querySelectorAll(
+ `[data-id='${treeId}'] > .tree-widget-children > *`
+ );
+
+ // the host is located at the 2nd element of the array in data-id
+ return [...items].map(x => JSON.parse(x.dataset.id)[1]);
+}
diff --git a/devtools/client/storage/test/browser_storage_indexeddb_navigation.js b/devtools/client/storage/test/browser_storage_indexeddb_navigation.js
new file mode 100644
index 0000000000..3235f10121
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_indexeddb_navigation.js
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+requestLongerTimeout(3);
+
+add_task(async function () {
+ const URL1 = URL_ROOT_COM_SSL + "storage-indexeddb-simple.html";
+ const URL2 = URL_ROOT_NET_SSL + "storage-indexeddb-simple-alt.html";
+
+ // open tab
+ await openTabAndSetupStorage(URL1);
+ const doc = gPanelWindow.document;
+
+ // Check first domain
+ // check that host appears in the storage tree
+ checkTree(doc, ["indexedDB", "https://example.com"]);
+ // check the table for values
+ await selectTreeItem([
+ "indexedDB",
+ "https://example.com",
+ "db (default)",
+ "store",
+ ]);
+ checkStorageData("lorem", JSON.stringify({ key: "lorem", value: "ipsum" }));
+
+ // clear db before navigating to a new domain
+ info("Removing database…");
+ await clearStorage();
+
+ // Check second domain
+ await navigateTo(URL2);
+ info("Creating database in the second domain…");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ const win = content.wrappedJSObject;
+ await win.setup();
+ });
+ // wait for storage tree refresh, and check host
+ info("Checking storage tree…");
+ await waitUntil(() => isInTree(doc, ["indexedDB", "https://example.net"]));
+
+ ok(
+ !isInTree(doc, ["indexedDB", "https://example.com"]),
+ "example.com item is not in the tree anymore"
+ );
+
+ // TODO: select tree and check on storage data.
+ // We cannot do it yet since we do not detect newly created indexed db's when
+ // navigating. See Bug 1273802
+
+ // reload the current tab, and check again
+ await reloadBrowser();
+ // wait for storage tree refresh, and check host
+ info("Checking storage tree…");
+ await waitUntil(() => isInTree(doc, ["indexedDB", "https://example.net"]));
+
+ info("Check that the indexedDB node still has the expected label");
+ is(
+ getTreeNodeLabel(doc, ["indexedDB"]),
+ "Indexed DB",
+ "indexedDB item is properly displayed"
+ );
+});
+
+async function clearStorage() {
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ const win = content.wrappedJSObject;
+ await win.clear();
+ });
+}
diff --git a/devtools/client/storage/test/browser_storage_indexeddb_overflow.js b/devtools/client/storage/test/browser_storage_indexeddb_overflow.js
new file mode 100644
index 0000000000..7ec65ea010
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_indexeddb_overflow.js
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test endless scrolling when a lot of items are present in the storage
+// inspector table for IndexedDB.
+"use strict";
+
+const ITEMS_PER_PAGE = 50;
+
+add_task(async function () {
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN_SECURED + "storage-overflow-indexeddb.html"
+ );
+
+ info("Run the tests with short DevTools");
+ await runTests();
+
+ info("Close Toolbox");
+ await gDevTools.closeToolboxForTab(gBrowser.selectedTab);
+});
+
+async function runTests() {
+ gUI.tree.expandAll();
+
+ await selectTreeItem([
+ "indexedDB",
+ "https://test1.example.org",
+ "database (default)",
+ "store",
+ ]);
+ checkCellLength(ITEMS_PER_PAGE);
+
+ await scroll();
+ checkCellLength(ITEMS_PER_PAGE * 2);
+}
diff --git a/devtools/client/storage/test/browser_storage_keys.js b/devtools/client/storage/test/browser_storage_keys.js
new file mode 100644
index 0000000000..d0c0bcaad0
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_keys.js
@@ -0,0 +1,164 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test to verify that the keys shown in sidebar are correct
+
+// Format of the test cases: {
+// action: Either "selectTreeItem" to select a tree item or
+// "assertTableItem" to select a table item,
+// ids: ID array for tree item to select if `action` is "selectTreeItem",
+// id: ID of the table item if `action` is "assertTableItem",
+// keyValuePairs: Array of key value pair objects which will be asserted
+// to exist in the storage sidebar (optional)
+// }
+
+"use strict";
+
+const LONG_WORD = "a".repeat(1000);
+
+add_task(async function () {
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN_SECURED + "storage-complex-keys.html"
+ );
+
+ gUI.tree.expandAll();
+
+ await testLocalStorage();
+ await testSessionStorage();
+ await testIndexedDB();
+});
+
+async function testLocalStorage() {
+ const tests = [
+ {
+ action: "selectTreeItem",
+ ids: ["localStorage", "https://test1.example.org"],
+ },
+ {
+ action: "assertTableItem",
+ id: "",
+ value: "1",
+ },
+ {
+ action: "assertTableItem",
+ id: "é”®",
+ value: "2",
+ },
+ ];
+
+ await makeTests(tests);
+}
+
+async function testSessionStorage() {
+ const tests = [
+ {
+ action: "selectTreeItem",
+ ids: ["sessionStorage", "https://test1.example.org"],
+ },
+ {
+ action: "assertTableItem",
+ id: "Key with spaces",
+ value: "3",
+ },
+ {
+ action: "assertTableItem",
+ id: "Key#with~special$characters",
+ value: "4",
+ },
+ {
+ action: "assertTableItem",
+ id: LONG_WORD,
+ value: "5",
+ },
+ ];
+
+ await makeTests(tests);
+}
+
+async function testIndexedDB() {
+ const tests = [
+ {
+ action: "selectTreeItem",
+ ids: ["indexedDB", "https://test1.example.org", "idb (default)", "obj"],
+ },
+ {
+ action: "assertTableItem",
+ id: "",
+ value: JSON.stringify({ id: "", name: "foo" }),
+ keyValuePairs: [
+ { name: ".id", value: "" },
+ { name: ".name", value: "foo" },
+ ],
+ },
+ {
+ action: "assertTableItem",
+ id: "é”®",
+ value: JSON.stringify({ id: "é”®", name: "foo2" }),
+ keyValuePairs: [
+ { name: "é”®.id", value: "é”®" },
+ { name: "é”®.name", value: "foo2" },
+ ],
+ },
+ {
+ action: "assertTableItem",
+ id: "Key with spaces",
+ value: JSON.stringify({ id: "Key with spaces", name: "foo3" }),
+ keyValuePairs: [
+ { name: "Key with spaces.id", value: "Key with spaces" },
+ { name: "Key with spaces.name", value: "foo3" },
+ ],
+ },
+ {
+ action: "assertTableItem",
+ id: "Key#with~special$characters",
+ value: JSON.stringify({
+ id: "Key#with~special$characters",
+ name: "foo4",
+ }),
+ keyValuePairs: [
+ {
+ name: "Key#with~special$characters.id",
+ value: "Key#with~special$characters",
+ },
+ { name: "Key#with~special$characters.name", value: "foo4" },
+ ],
+ },
+ {
+ action: "assertTableItem",
+ id: LONG_WORD,
+ value: JSON.stringify({ id: LONG_WORD, name: "foo5" }),
+ keyValuePairs: [
+ { name: `${LONG_WORD}.id`, value: LONG_WORD },
+ { name: `${LONG_WORD}.name`, value: "foo5" },
+ ],
+ },
+ ];
+
+ await makeTests(tests);
+}
+
+async function makeTests(tests) {
+ for (const item of tests) {
+ info(`Selecting item ${JSON.stringify(item)}`);
+
+ switch (item.action) {
+ case "selectTreeItem":
+ await selectTreeItem(item.ids);
+ break;
+
+ case "assertTableItem":
+ await selectTableItem(item.id);
+ // Check the ID and value in the data section
+ await findVariableViewProperties([
+ { name: item.id, value: item.value },
+ ]);
+ // If there are key value pairs defined, check those in the
+ // parsed value section
+ if (item.keyValuePairs) {
+ await findVariableViewProperties(item.keyValuePairs, true);
+ }
+ break;
+ }
+ }
+}
diff --git a/devtools/client/storage/test/browser_storage_localstorage_add.js b/devtools/client/storage/test/browser_storage_localstorage_add.js
new file mode 100644
index 0000000000..a8c6d23585
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_localstorage_add.js
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Basic test to check the adding of localStorage entries.
+
+"use strict";
+
+add_task(async function () {
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN_SECURED + "storage-localstorage.html"
+ );
+ showAllColumns(true);
+
+ await performAdd(["localStorage", "https://test1.example.org"]);
+ await performAdd(["localStorage", "https://test1.example.org"]);
+ await performAdd(["localStorage", "https://test1.example.org"]);
+ await performAdd(["localStorage", "https://test1.example.org"]);
+ await performAdd(["localStorage", "https://test1.example.org"]);
+});
diff --git a/devtools/client/storage/test/browser_storage_localstorage_edit.js b/devtools/client/storage/test/browser_storage_localstorage_edit.js
new file mode 100644
index 0000000000..0fc6cb4d7d
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_localstorage_edit.js
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Basic test to check the editing of localStorage.
+
+"use strict";
+
+add_task(async function () {
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN_SECURED + "storage-localstorage.html"
+ );
+
+ await selectTreeItem(["localStorage", "https://test1.example.org"]);
+
+ await editCell("TestLS1", "name", "newTestLS1");
+ await editCell("newTestLS1", "value", "newValueLS1");
+
+ await editCell("TestLS3", "name", "newTestLS3");
+ await editCell("newTestLS3", "value", "newValueLS3");
+
+ await editCell("TestLS5", "name", "newTestLS5");
+ await editCell("newTestLS5", "value", "newValueLS5");
+});
diff --git a/devtools/client/storage/test/browser_storage_localstorage_error.js b/devtools/client/storage/test/browser_storage_localstorage_error.js
new file mode 100644
index 0000000000..2faa53c213
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_localstorage_error.js
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test that for pages where local/sessionStorage is not available (like about:home),
+// the host still appears in the storage tree and no unhandled exception is thrown.
+
+add_task(async function () {
+ await openTabAndSetupStorage("about:home");
+
+ const itemsToOpen = [
+ ["localStorage", "about:home"],
+ ["sessionStorage", "about:home"],
+ ];
+
+ for (const item of itemsToOpen) {
+ await selectTreeItem(item);
+ ok(
+ gUI.tree.isSelected(item),
+ `Item ${item.join(" > ")} is present in the tree`
+ );
+ }
+});
diff --git a/devtools/client/storage/test/browser_storage_localstorage_navigation.js b/devtools/client/storage/test/browser_storage_localstorage_navigation.js
new file mode 100644
index 0000000000..92a8eab210
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_localstorage_navigation.js
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+add_task(async function () {
+ const URL1 = buildURLWithContent(
+ "example.com",
+ `<h1>example.com</h1>` +
+ `<script>localStorage.setItem("lorem", "ipsum");</script>`
+ );
+ const URL2 = buildURLWithContent(
+ "example.net",
+ `<h1>example.net</h1>` +
+ `<script>localStorage.setItem("foo", "bar");</script>`
+ );
+
+ // open tab
+ await openTabAndSetupStorage(URL1);
+ const doc = gPanelWindow.document;
+
+ // Check first domain
+ // check that both host appear in the storage tree
+ checkTree(doc, ["localStorage", "https://example.com"]);
+ // check the table for values
+ await selectTreeItem(["localStorage", "https://example.com"]);
+ checkStorageData("lorem", "ipsum");
+
+ // clear up local storage data before navigating
+ info("Cleaning up localStorage…");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ const win = content.wrappedJSObject;
+ await win.localStorage.clear();
+ });
+
+ // Check second domain
+ await navigateTo(URL2);
+ // wait for storage tree refresh, and check host
+ info("Waiting for storage tree to refresh and show correct host…");
+ await waitUntil(() => isInTree(doc, ["localStorage", "https://example.net"]));
+ ok(
+ !isInTree(doc, ["localStorage", "https://example.com"]),
+ "example.com item is not in the tree anymore"
+ );
+
+ // reload the current tab and check data
+ await reloadBrowser();
+ // wait for storage tree refresh, and check host
+ info("Waiting for storage tree to refresh and show correct host…");
+ await waitUntil(() => isInTree(doc, ["localStorage", "https://example.net"]));
+
+ // check the table for values
+ await selectTreeItem(["localStorage", "https://example.net"]);
+ checkStorageData("foo", "bar");
+
+ info("Check that the localStorage node still has the expected label");
+ is(
+ getTreeNodeLabel(doc, ["localStorage"]),
+ "Local Storage",
+ "localStorage item is properly displayed"
+ );
+});
diff --git a/devtools/client/storage/test/browser_storage_localstorage_rapid_add_remove.js b/devtools/client/storage/test/browser_storage_localstorage_rapid_add_remove.js
new file mode 100644
index 0000000000..56636903bb
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_localstorage_rapid_add_remove.js
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Basic test to check the rapid adding and removing of localStorage entries.
+
+"use strict";
+
+add_task(async function () {
+ await openTabAndSetupStorage(MAIN_DOMAIN_SECURED + "storage-blank.html");
+ await selectTreeItem(["localStorage", "https://test1.example.org"]);
+
+ ok(isTableEmpty(), "Table empty on init");
+
+ for (let i = 0; i < 10; i++) {
+ await addRemove(`test${i}`);
+ }
+});
+
+async function addRemove(name) {
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [name], innerName => {
+ content.localStorage.setItem(innerName, "true");
+ content.localStorage.removeItem(innerName);
+ });
+
+ info("Waiting for store objects to be changed");
+ await gUI.once("store-objects-edit");
+
+ ok(isTableEmpty(), `Table empty after rapid add/remove of "${name}"`);
+}
diff --git a/devtools/client/storage/test/browser_storage_overflow.js b/devtools/client/storage/test/browser_storage_overflow.js
new file mode 100644
index 0000000000..b5f3989fa8
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_overflow.js
@@ -0,0 +1,104 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test endless scrolling when a lot of items are present in the storage
+// inspector table.
+"use strict";
+
+const ITEMS_PER_PAGE = 50;
+
+add_task(async function () {
+ await openTabAndSetupStorage(MAIN_DOMAIN_SECURED + "storage-overflow.html");
+
+ info("Run the tests with short DevTools");
+ await runTests();
+
+ info("Close Toolbox");
+ await gDevTools.closeToolboxForTab(gBrowser.selectedTab);
+
+ info("Set a toolbox height of 1000px");
+ await pushPref("devtools.toolbox.footer.height", 1000);
+
+ info("Open storage panel again");
+ await openStoragePanel();
+
+ info("Run the tests with tall DevTools");
+ await runTests(true);
+});
+
+async function runTests(tall) {
+ if (tall) {
+ // We need to zoom out and a tall storage panel in order to fit more than 50
+ // items in the table. We do this to ensure that we load enough content to
+ // show a scrollbar so that we can still use infinite scrolling.
+ zoom(0.5);
+ }
+
+ gUI.tree.expandAll();
+ await selectTreeItem(["localStorage", "https://test1.example.org"]);
+
+ if (tall) {
+ if (getCellLength() === ITEMS_PER_PAGE) {
+ await scrollToAddItems();
+ await waitForStorageData("item-100", "value-100");
+ }
+
+ if (getCellLength() === ITEMS_PER_PAGE * 2) {
+ await scrollToAddItems();
+ await waitForStorageData("item-150", "value-150");
+ }
+
+ if (getCellLength() === ITEMS_PER_PAGE * 3) {
+ await scrollToAddItems();
+ await waitForStorageData("item-151", "value-151");
+ }
+ } else {
+ checkCellLength(ITEMS_PER_PAGE);
+ await scrollToAddItems();
+ await waitForStorageData("item-100", "value-100");
+
+ checkCellLength(ITEMS_PER_PAGE * 2);
+ await scrollToAddItems();
+ await waitForStorageData("item-150", "value-150");
+
+ checkCellLength(ITEMS_PER_PAGE * 3);
+ await scrollToAddItems();
+ await waitForStorageData("item-151", "value-151");
+ }
+
+ is(getCellLength(), 151, "Storage table contains 151 items");
+
+ // Check that the columns are sorted in a human readable way (ascending).
+ checkCellValues("ASC");
+
+ // Sort descending.
+ clickColumnHeader("name");
+
+ // Check that the columns are sorted in a human readable way (descending).
+ checkCellValues("DEC");
+
+ if (tall) {
+ zoom(1);
+ }
+}
+
+function checkCellValues(order) {
+ const cells = [
+ ...gPanelWindow.document.querySelectorAll("#name .table-widget-cell"),
+ ];
+ cells.forEach(function (cell, index, arr) {
+ const i = order === "ASC" ? index + 1 : arr.length - index;
+ is(cell.value, `item-${i}`, `Cell value is "item-${i}" (${order}).`);
+ });
+}
+
+async function scrollToAddItems() {
+ info(`Scrolling to add ${ITEMS_PER_PAGE} items`);
+ await scroll();
+}
+
+function zoom(zoomValue) {
+ const bc = BrowsingContext.getFromWindow(gPanelWindow);
+ bc.fullZoom = zoomValue;
+}
diff --git a/devtools/client/storage/test/browser_storage_search.js b/devtools/client/storage/test/browser_storage_search.js
new file mode 100644
index 0000000000..1f7c8a5c06
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_search.js
@@ -0,0 +1,140 @@
+// Tests the filter search box in the storage inspector
+"use strict";
+
+add_task(async function () {
+ await openTabAndSetupStorage(MAIN_DOMAIN_SECURED + "storage-search.html");
+
+ gUI.tree.expandAll();
+ await selectTreeItem(["cookies", "https://test1.example.org"]);
+
+ showColumn("expires", false);
+ showColumn("host", false);
+ showColumn("isHttpOnly", false);
+ showColumn("lastAccessed", false);
+ showColumn("path", false);
+
+ // Results: 0=hidden, 1=visible
+ const testcases = [
+ // Test that search isn't case-sensitive
+ {
+ value: "FoO",
+ results: [0, 0, 1, 1, 0, 1, 0],
+ },
+ {
+ value: "OR",
+ results: [0, 1, 0, 0, 0, 1, 0],
+ },
+ {
+ value: "aNImAl",
+ results: [0, 1, 0, 0, 0, 0, 0],
+ },
+ // Test numbers
+ {
+ value: "01",
+ results: [1, 0, 0, 0, 0, 0, 1],
+ },
+ {
+ value: "2016",
+ results: [0, 0, 0, 0, 0, 0, 1],
+ },
+ {
+ value: "56789",
+ results: [1, 0, 0, 0, 0, 0, 0],
+ },
+ // Test filtering by value
+ {
+ value: "horse",
+ results: [0, 1, 0, 0, 0, 0, 0],
+ },
+ {
+ value: "$$$",
+ results: [0, 0, 0, 0, 1, 0, 0],
+ },
+ {
+ value: "bar",
+ results: [0, 0, 1, 1, 0, 0, 0],
+ },
+ // Test input with whitespace
+ {
+ value: "energy b",
+ results: [0, 0, 1, 0, 0, 0, 0],
+ },
+ // Test no input at all
+ {
+ value: "",
+ results: [1, 1, 1, 1, 1, 1, 1],
+ },
+ // Test input that matches nothing
+ {
+ value: "input that matches nothing",
+ results: [0, 0, 0, 0, 0, 0, 0],
+ },
+ ];
+
+ const testcasesAfterHiding = [
+ // Test that search isn't case-sensitive
+ {
+ value: "OR",
+ results: [0, 0, 0, 0, 0, 1, 0],
+ },
+ {
+ value: "01",
+ results: [1, 0, 0, 0, 0, 0, 0],
+ },
+ {
+ value: "2016",
+ results: [0, 0, 0, 0, 0, 0, 0],
+ },
+ {
+ value: "56789",
+ results: [0, 0, 0, 0, 0, 0, 0],
+ },
+ // Test filtering by value
+ {
+ value: "horse",
+ results: [0, 0, 0, 0, 0, 0, 0],
+ },
+ {
+ value: "$$$",
+ results: [0, 0, 0, 0, 0, 0, 0],
+ },
+ {
+ value: "bar",
+ results: [0, 0, 0, 0, 0, 0, 0],
+ },
+ // Test input with whitespace
+ {
+ value: "energy b",
+ results: [0, 0, 0, 0, 0, 0, 0],
+ },
+ ];
+
+ runTests(testcases);
+ showColumn("value", false);
+ runTests(testcasesAfterHiding);
+});
+
+function runTests(testcases) {
+ const $$ = sel => gPanelWindow.document.querySelectorAll(sel);
+ const names = $$("#name .table-widget-cell");
+ const rows = $$("#value .table-widget-cell");
+ for (const testcase of testcases) {
+ const { value, results } = testcase;
+
+ info(`Testing input: ${value}`);
+
+ gUI.searchBox.value = value;
+ gUI.filterItems();
+
+ for (let i = 0; i < rows.length; i++) {
+ info(`Testing row ${i} for "${value}"`);
+ info(`key: ${names[i].value}, value: ${rows[i].value}`);
+ const state = results[i] ? "visible" : "hidden";
+ is(
+ rows[i].hasAttribute("hidden"),
+ !results[i],
+ `Row ${i} should be ${state}`
+ );
+ }
+ }
+}
diff --git a/devtools/client/storage/test/browser_storage_search_keyboard_trap.js b/devtools/client/storage/test/browser_storage_search_keyboard_trap.js
new file mode 100644
index 0000000000..21ccd12980
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_search_keyboard_trap.js
@@ -0,0 +1,15 @@
+// Test ability to focus search field by using keyboard
+"use strict";
+
+add_task(async function () {
+ await openTabAndSetupStorage(MAIN_DOMAIN_SECURED + "storage-search.html");
+
+ gUI.tree.expandAll();
+ await selectTreeItem(["localStorage", "https://test1.example.org"]);
+
+ await focusSearchBoxUsingShortcut(gPanelWindow);
+ ok(
+ containsFocus(gPanelWindow.document, gUI.searchBox),
+ "Focus is in a searchbox"
+ );
+});
diff --git a/devtools/client/storage/test/browser_storage_sessionstorage_add.js b/devtools/client/storage/test/browser_storage_sessionstorage_add.js
new file mode 100644
index 0000000000..9f5ed9476f
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_sessionstorage_add.js
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Basic test to check the adding of sessionStorage entries.
+
+"use strict";
+
+add_task(async function () {
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN_SECURED + "storage-sessionstorage.html"
+ );
+ showAllColumns(true);
+
+ await performAdd(["sessionStorage", "https://test1.example.org"]);
+ await performAdd(["sessionStorage", "https://test1.example.org"]);
+ await performAdd(["sessionStorage", "https://test1.example.org"]);
+ await performAdd(["sessionStorage", "https://test1.example.org"]);
+ await performAdd(["sessionStorage", "https://test1.example.org"]);
+});
diff --git a/devtools/client/storage/test/browser_storage_sessionstorage_edit.js b/devtools/client/storage/test/browser_storage_sessionstorage_edit.js
new file mode 100644
index 0000000000..51e9657585
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_sessionstorage_edit.js
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Basic test to check the editing of localStorage.
+
+"use strict";
+
+add_task(async function () {
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN_SECURED + "storage-sessionstorage.html"
+ );
+
+ await selectTreeItem(["sessionStorage", "https://test1.example.org"]);
+
+ await editCell("TestSS1", "name", "newTestSS1");
+ await editCell("newTestSS1", "value", "newValueSS1");
+
+ await editCell("TestSS3", "name", "newTestSS3");
+ await editCell("newTestSS3", "value", "newValueSS3");
+
+ await editCell("TestSS5", "name", "newTestSS5");
+ await editCell("newTestSS5", "value", "newValueSS5");
+});
diff --git a/devtools/client/storage/test/browser_storage_sessionstorage_navigation.js b/devtools/client/storage/test/browser_storage_sessionstorage_navigation.js
new file mode 100644
index 0000000000..22d6d5661f
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_sessionstorage_navigation.js
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+add_task(async function () {
+ const URL1 = buildURLWithContent(
+ "example.com",
+ `<h1>example.com</h1>` +
+ `<script>sessionStorage.setItem("lorem", "ipsum");</script>`
+ );
+ const URL2 = buildURLWithContent(
+ "example.net",
+ `<h1>example.net</h1>` +
+ `<script>sessionStorage.setItem("foo", "bar");</script>`
+ );
+
+ // open tab
+ await openTabAndSetupStorage(URL1);
+ const doc = gPanelWindow.document;
+
+ // Check first domain
+ // check that both host appear in the storage tree
+ checkTree(doc, ["sessionStorage", "https://example.com"]);
+ // check the table for values
+ await selectTreeItem(["sessionStorage", "https://example.com"]);
+ checkStorageData("lorem", "ipsum");
+
+ // clear up session storage data before navigating
+ info("Cleaning up sessionStorage…");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ const win = content.wrappedJSObject;
+ await win.sessionStorage.clear();
+ });
+
+ // Check second domain
+ await navigateTo(URL2);
+ // wait for storage tree refresh, and check host
+ info("Waiting for storage tree to refresh and show correct host…");
+ await waitUntil(() =>
+ isInTree(doc, ["sessionStorage", "https://example.net"])
+ );
+
+ ok(
+ !isInTree(doc, ["sessionStorage", "https://example.com"]),
+ "example.com item is not in the tree anymore"
+ );
+
+ // check the table for values
+ await selectTreeItem(["sessionStorage", "https://example.net"]);
+ checkStorageData("foo", "bar");
+
+ info("Check that the sessionStorage node still has the expected label");
+ is(
+ getTreeNodeLabel(doc, ["sessionStorage"]),
+ "Session Storage",
+ "sessionStorage item is properly displayed"
+ );
+});
diff --git a/devtools/client/storage/test/browser_storage_sidebar.js b/devtools/client/storage/test/browser_storage_sidebar.js
new file mode 100644
index 0000000000..e55a0365f1
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_sidebar.js
@@ -0,0 +1,136 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test to verify that the sidebar opens, closes and updates
+// This test is not testing the values in the sidebar, being tested in _values
+
+// Format: [
+// <id of the table item to click> or <id array for tree item to select> or
+// null to press Escape,
+// <do we wait for the async "sidebar-updated" event>,
+// <is the sidebar open>
+// ]
+
+"use strict";
+
+const testCases = [
+ {
+ location: ["cookies", "https://sectest1.example.org"],
+ sidebarHidden: true,
+ },
+ {
+ location: getCookieId("cs2", ".example.org", "/"),
+ sidebarHidden: false,
+ },
+ {
+ sendEscape: true,
+ },
+ {
+ location: getCookieId("cs2", ".example.org", "/"),
+ sidebarHidden: true,
+ },
+ {
+ location: getCookieId("uc1", ".example.org", "/"),
+ sidebarHidden: true,
+ },
+ {
+ location: getCookieId("uc1", ".example.org", "/"),
+ sidebarHidden: true,
+ },
+
+ {
+ location: ["localStorage", "http://sectest1.example.org"],
+ sidebarHidden: true,
+ },
+ {
+ location: "iframe-u-ls1",
+ sidebarHidden: false,
+ },
+ {
+ location: "iframe-u-ls1",
+ sidebarHidden: false,
+ },
+ {
+ sendEscape: true,
+ },
+
+ {
+ location: ["sessionStorage", "http://test1.example.org"],
+ sidebarHidden: true,
+ },
+ {
+ location: "ss1",
+ sidebarHidden: false,
+ },
+ {
+ sendEscape: true,
+ },
+
+ {
+ location: ["indexedDB", "http://test1.example.org"],
+ sidebarHidden: true,
+ },
+ {
+ location: "idb2 (default)",
+ sidebarHidden: false,
+ },
+
+ {
+ location: [
+ "indexedDB",
+ "http://test1.example.org",
+ "idb2 (default)",
+ "obj3",
+ ],
+ sidebarHidden: true,
+ },
+
+ {
+ location: ["indexedDB", "https://sectest1.example.org", "idb-s2 (default)"],
+ sidebarHidden: true,
+ },
+ {
+ location: "obj-s2",
+ sidebarHidden: false,
+ },
+ {
+ sendEscape: true,
+ },
+ {
+ location: "obj-s2",
+ sidebarHidden: true,
+ },
+];
+
+add_task(async function () {
+ // storage-listings.html explicitly mixes secure and insecure frames.
+ // We should not enforce https for tests using this page.
+ await pushPref("dom.security.https_first", false);
+
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html");
+
+ for (const test of testCases) {
+ const { location, sidebarHidden, sendEscape } = test;
+
+ info("running " + JSON.stringify(test));
+
+ if (Array.isArray(location)) {
+ await selectTreeItem(location);
+ } else if (location) {
+ await selectTableItem(location);
+ }
+
+ if (sendEscape) {
+ EventUtils.sendKey("ESCAPE", gPanelWindow);
+ } else {
+ is(
+ gUI.sidebar.hidden,
+ sidebarHidden,
+ "correct visibility state of sidebar."
+ );
+ }
+
+ info("-".repeat(80));
+ }
+});
diff --git a/devtools/client/storage/test/browser_storage_sidebar_parsetree.js b/devtools/client/storage/test/browser_storage_sidebar_parsetree.js
new file mode 100644
index 0000000000..e5c169dfbd
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_sidebar_parsetree.js
@@ -0,0 +1,115 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test to verify that the sidebar parsetree is used for only values it makes sense to
+// parse into a tree.
+
+"use strict";
+
+const testCases = [
+ {
+ row: "ampersand",
+ parseTreeVisible: true,
+ },
+ {
+ row: "asterisk",
+ parseTreeVisible: true,
+ },
+ {
+ row: "base64",
+ parseTreeVisible: false,
+ },
+ {
+ row: "boolean",
+ parseTreeVisible: false,
+ },
+ {
+ row: "colon",
+ parseTreeVisible: true,
+ },
+ {
+ row: "color",
+ parseTreeVisible: false,
+ },
+ {
+ row: "comma",
+ parseTreeVisible: true,
+ },
+ {
+ row: "dataURI",
+ parseTreeVisible: false,
+ },
+ {
+ row: "date",
+ parseTreeVisible: false,
+ },
+ {
+ row: "email",
+ parseTreeVisible: false,
+ },
+ {
+ row: "equals",
+ parseTreeVisible: true,
+ },
+ {
+ row: "FQDN",
+ parseTreeVisible: false,
+ },
+ {
+ row: "hash",
+ parseTreeVisible: true,
+ },
+ {
+ row: "IP",
+ parseTreeVisible: false,
+ },
+ {
+ row: "MacAddress",
+ parseTreeVisible: false,
+ },
+ {
+ row: "maths",
+ parseTreeVisible: false,
+ },
+ {
+ row: "numbers",
+ parseTreeVisible: false,
+ },
+ {
+ row: "period",
+ parseTreeVisible: true,
+ },
+ {
+ row: "SemVer",
+ parseTreeVisible: false,
+ },
+ {
+ row: "tilde",
+ parseTreeVisible: true,
+ },
+ {
+ row: "URL",
+ parseTreeVisible: false,
+ },
+ {
+ row: "URL2",
+ parseTreeVisible: false,
+ },
+];
+
+add_task(async function () {
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN_SECURED + "storage-sidebar-parsetree.html"
+ );
+
+ await selectTreeItem(["localStorage", "https://test1.example.org"]);
+
+ for (const test of testCases) {
+ const { parseTreeVisible, row } = test;
+
+ await selectTableItem(row);
+
+ sidebarParseTreeVisible(parseTreeVisible);
+ }
+});
diff --git a/devtools/client/storage/test/browser_storage_sidebar_toggle.js b/devtools/client/storage/test/browser_storage_sidebar_toggle.js
new file mode 100644
index 0000000000..3e27e23e88
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_sidebar_toggle.js
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test to verify that the sidebar toggles when the toggle button is clicked.
+
+"use strict";
+
+const testCases = [
+ {
+ location: ["cookies", "https://sectest1.example.org"],
+ sidebarHidden: true,
+ toggleButtonVisible: false,
+ },
+ {
+ location: getCookieId("cs2", ".example.org", "/"),
+ sidebarHidden: false,
+ toggleButtonVisible: true,
+ },
+ {
+ clickToggle: true,
+ },
+ {
+ location: getCookieId("cs2", ".example.org", "/"),
+ sidebarHidden: true,
+ },
+];
+
+add_task(async function () {
+ // storage-listings.html explicitly mixes secure and insecure frames.
+ // We should not enforce https for tests using this page.
+ await pushPref("dom.security.https_first", false);
+
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html");
+
+ for (const test of testCases) {
+ const { location, sidebarHidden, clickToggle, toggleButtonVisible } = test;
+
+ info("running " + JSON.stringify(test));
+
+ if (Array.isArray(location)) {
+ await selectTreeItem(location);
+ } else if (location) {
+ await selectTableItem(location);
+ }
+
+ if (clickToggle) {
+ toggleSidebar();
+ } else if (typeof toggleButtonHidden !== "undefined") {
+ is(
+ sidebarToggleVisible(),
+ toggleButtonVisible,
+ "correct visibility state of toggle button"
+ );
+ } else {
+ is(
+ gUI.sidebar.hidden,
+ sidebarHidden,
+ "correct visibility state of sidebar."
+ );
+ }
+
+ info("-".repeat(80));
+ }
+});
diff --git a/devtools/client/storage/test/browser_storage_sidebar_update.js b/devtools/client/storage/test/browser_storage_sidebar_update.js
new file mode 100644
index 0000000000..b8f18d0ca7
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_sidebar_update.js
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test to verify that the sidebar is not broken when several updates
+// come in quick succession. See bug 1260380 - it could happen that the
+// "Parsed Value" section gets duplicated.
+
+"use strict";
+
+add_task(async function () {
+ const ITEM_NAME = "ls1";
+ const UPDATE_COUNT = 3;
+
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN_SECURED + "storage-complex-values.html"
+ );
+
+ const updated = gUI.once("sidebar-updated");
+ await selectTreeItem(["localStorage", "https://test1.example.org"]);
+ await selectTableItem(ITEM_NAME);
+ await updated;
+
+ is(gUI.sidebar.hidden, false, "sidebar is visible");
+
+ // do several updates in a row and wait for them to finish
+ const updates = [];
+ for (let i = 0; i < UPDATE_COUNT; i++) {
+ info(`Performing update #${i}`);
+ updates.push(gUI.once("sidebar-updated"));
+ gUI.updateObjectSidebar();
+ }
+ await Promise.all(updates);
+
+ info("Updates performed, going to verify result");
+ const parsedScope = gUI.view.getScopeAtIndex(1);
+ const elements = parsedScope.target.querySelectorAll(
+ `.name[value="${ITEM_NAME}"]`
+ );
+ is(
+ elements.length,
+ 1,
+ `There is only one displayed variable named '${ITEM_NAME}'`
+ );
+});
diff --git a/devtools/client/storage/test/browser_storage_type_descriptions.js b/devtools/client/storage/test/browser_storage_type_descriptions.js
new file mode 100644
index 0000000000..cad28e569b
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_type_descriptions.js
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Basic test to assert that the descriptions for the different storage types
+// are correctly displayed and the links referring to pages with further
+// information are set.
+
+"use strict";
+
+const getStorageTypeURL = require("resource://devtools/client/storage/utils/doc-utils.js");
+
+const storeItems = [
+ "Cache",
+ "cookies",
+ "indexedDB",
+ "localStorage",
+ "sessionStorage",
+];
+
+/**
+ * Test that the desired number of tree items are present
+ */
+function testTree() {
+ const doc = gPanelWindow.document;
+ for (const type of storeItems) {
+ ok(
+ doc.querySelector(`[data-id='${JSON.stringify([type])}']`),
+ `Tree item ${type} should be present in the storage tree`
+ );
+ }
+}
+
+/**
+ * Test that description is shown for each of the tree items
+ */
+const testDescriptions = async function () {
+ const doc = gPanelWindow.document;
+ const win = doc.defaultView;
+ // Expand all nodes so that the synthesized click event actually works
+ gUI.tree.expandAll();
+
+ // Click the tree items and wait for the content to be updated
+ for (const type of storeItems) {
+ await selectTreeItem([type]);
+
+ // Check whether the table is hidden
+ is(
+ win.getComputedStyle(doc.querySelector(".table-widget-body")).display,
+ "none",
+ "Table must be hidden"
+ );
+
+ // Check whether the description shown
+ is(
+ win.getComputedStyle(doc.querySelector(".table-widget-empty-text"))
+ .display,
+ "block",
+ "Description for the type must be shown"
+ );
+
+ // Check learn more link
+ const learnMoreLink = doc.querySelector(".table-widget-empty-text > a");
+ ok(learnMoreLink, "There is a [Learn more] link");
+ const expectedURL = getStorageTypeURL(type);
+ is(
+ learnMoreLink.href,
+ expectedURL,
+ `Learn more link refers to ${expectedURL}`
+ );
+ }
+};
+
+add_task(async function () {
+ await openTabAndSetupStorage(MAIN_DOMAIN + "storage-empty-objectstores.html");
+
+ testTree();
+ await testDescriptions();
+});
diff --git a/devtools/client/storage/test/browser_storage_values.js b/devtools/client/storage/test/browser_storage_values.js
new file mode 100644
index 0000000000..69dbacf592
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_values.js
@@ -0,0 +1,261 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test to verify that the values shown in sidebar are correct
+
+// Format: [
+// <id of the table item to click> or <id array for tree item to select> or
+// null do click nothing,
+// null to skip checking value in variables view or a key value pair object
+// which will be asserted to exist in the storage sidebar,
+// true if the check is to be made in the parsed value section
+// ]
+
+"use strict";
+
+const LONG_WORD = "a".repeat(1000);
+
+const testCases = [
+ [
+ getCookieId("cs2", ".example.org", "/"),
+ [
+ { name: "cs2", value: "sessionCookie" },
+ { name: "cs2.Path", value: "/" },
+ { name: "cs2.HostOnly", value: "false" },
+ { name: "cs2.HttpOnly", value: "false" },
+ { name: "cs2.Domain", value: ".example.org" },
+ { name: "cs2.Expires / Max-Age", value: "Session" },
+ { name: "cs2.Secure", value: "false" },
+ ],
+ ],
+ [
+ getCookieId("c1", "test1.example.org", "/browser"),
+ [
+ { name: "c1", value: JSON.stringify(["foo", "Bar", { foo: "Bar" }]) },
+ { name: "c1.Path", value: "/browser" },
+ { name: "c1.HostOnly", value: "true" },
+ { name: "c1.HttpOnly", value: "false" },
+ { name: "c1.Domain", value: "test1.example.org" },
+ {
+ name: "c1.Expires / Max-Age",
+ value: new Date(2000000000000).toUTCString(),
+ },
+ { name: "c1.Secure", value: "false" },
+ ],
+ ],
+ [
+ null,
+ [
+ { name: "c1", value: "Array" },
+ { name: "c1.0", value: "foo" },
+ { name: "c1.1", value: "Bar" },
+ { name: "c1.2", value: "Object" },
+ { name: "c1.2.foo", value: "Bar" },
+ ],
+ true,
+ ],
+ [
+ getCookieId(
+ "c_encoded",
+ "test1.example.org",
+ "/browser/devtools/client/storage/test"
+ ),
+ [
+ {
+ name: "c_encoded",
+ value: encodeURIComponent(JSON.stringify({ foo: { foo1: "bar" } })),
+ },
+ ],
+ ],
+ [
+ null,
+ [
+ { name: "c_encoded", value: "Object" },
+ { name: "c_encoded.foo", value: "Object" },
+ { name: "c_encoded.foo.foo1", value: "bar" },
+ ],
+ true,
+ ],
+ [["localStorage", "https://test1.example.org"]],
+ ["ls2", [{ name: "ls2", value: "foobar-2" }]],
+ [
+ "ls1",
+ [
+ {
+ name: "ls1",
+ value: JSON.stringify({
+ es6: "for",
+ the: "win",
+ baz: [
+ 0,
+ 2,
+ 3,
+ {
+ deep: "down",
+ nobody: "cares",
+ },
+ ],
+ }),
+ },
+ ],
+ ],
+ [
+ null,
+ [
+ { name: "ls1", value: "Object" },
+ { name: "ls1.es6", value: "for" },
+ { name: "ls1.the", value: "win" },
+ { name: "ls1.baz", value: "Array" },
+ { name: "ls1.baz.0", value: "0" },
+ { name: "ls1.baz.1", value: "2" },
+ { name: "ls1.baz.2", value: "3" },
+ { name: "ls1.baz.3", value: "Object" },
+ { name: "ls1.baz.3.deep", value: "down" },
+ { name: "ls1.baz.3.nobody", value: "cares" },
+ ],
+ true,
+ ],
+ ["ls3", [{ name: "ls3", value: "http://foobar.com/baz.php" }]],
+ [
+ null,
+ [{ name: "ls3", value: "http://foobar.com/baz.php", dontMatch: true }],
+ true,
+ ],
+ ["ls4", [{ name: "ls4", value: "0x1" }], false],
+ [["sessionStorage", "https://test1.example.org"]],
+ ["ss1", [{ name: "ss1", value: "This#is#an#array" }]],
+ [
+ null,
+ [
+ { name: "ss1", value: "Array" },
+ { name: "ss1.0", value: "This" },
+ { name: "ss1.1", value: "is" },
+ { name: "ss1.2", value: "an" },
+ { name: "ss1.3", value: "array" },
+ ],
+ true,
+ ],
+ [
+ "ss2",
+ [
+ { name: "ss2", value: "Array" },
+ { name: "ss2.0", value: "This" },
+ { name: "ss2.1", value: "is" },
+ { name: "ss2.2", value: "another" },
+ { name: "ss2.3", value: "array" },
+ ],
+ true,
+ ],
+ [
+ "ss3",
+ [
+ { name: "ss3", value: "Object" },
+ { name: "ss3.this", value: "is" },
+ { name: "ss3.an", value: "object" },
+ { name: "ss3.foo", value: "bar" },
+ ],
+ true,
+ ],
+ [
+ "ss4",
+ [
+ { name: "ss4", value: "Array" },
+ { name: "ss4.0", value: "" },
+ { name: "ss4.1", value: "array" },
+ { name: "ss4.2", value: "" },
+ { name: "ss4.3", value: "with" },
+ { name: "ss4.4", value: "empty" },
+ { name: "ss4.5", value: "items" },
+ ],
+ true,
+ ],
+ [
+ "ss5",
+ [
+ { name: "ss5", value: "Array" },
+ { name: "ss5.0", value: LONG_WORD },
+ { name: "ss5.1", value: LONG_WORD },
+ { name: "ss5.2", value: LONG_WORD },
+ { name: "ss5.3", value: `${LONG_WORD}&${LONG_WORD}` },
+ { name: "ss5.4", value: `${LONG_WORD}&${LONG_WORD}` },
+ ],
+ true,
+ ],
+ [["indexedDB", "https://test1.example.org", "idb1 (default)", "obj1"]],
+ [
+ 1,
+ [
+ {
+ name: 1,
+ value: JSON.stringify({ id: 1, name: "foo", email: "foo@bar.com" }),
+ },
+ ],
+ ],
+ [
+ null,
+ [
+ { name: "1.id", value: "1" },
+ { name: "1.name", value: "foo" },
+ { name: "1.email", value: "foo@bar.com" },
+ ],
+ true,
+ ],
+ [["indexedDB", "https://test1.example.org", "idb1 (default)", "obj2"]],
+ [
+ 1,
+ [
+ {
+ name: 1,
+ value: JSON.stringify({
+ id2: 1,
+ name: "foo",
+ email: "foo@bar.com",
+ extra: "baz".repeat(10000),
+ }),
+ },
+ ],
+ ],
+ [
+ null,
+ [
+ { name: "1.id2", value: "1" },
+ { name: "1.name", value: "foo" },
+ { name: "1.email", value: "foo@bar.com" },
+ { name: "1.extra", value: "baz".repeat(10000) },
+ ],
+ true,
+ ],
+];
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.documentCookies.maxage", 0]],
+ });
+
+ await openTabAndSetupStorage(
+ MAIN_DOMAIN_SECURED + "storage-complex-values.html"
+ );
+
+ gUI.tree.expandAll();
+
+ for (const item of testCases) {
+ info("clicking for item " + item);
+ const [path, ruleArray, parsed] = item;
+ const start = performance.now();
+
+ if (Array.isArray(path)) {
+ await selectTreeItem(path);
+ continue;
+ } else if (path) {
+ await selectTableItem(path);
+ }
+
+ // Parsing "0x1" used to be very slow and memory intensive.
+ // Check that each test case completes in less than 15000ms.
+ const time = performance.now() - start;
+ ok(time < 15000, `item ${item} completed in less than 15000ms ${time}`);
+
+ await findVariableViewProperties(ruleArray, parsed);
+ }
+});
diff --git a/devtools/client/storage/test/browser_storage_webext_storage_local.js b/devtools/client/storage/test/browser_storage_webext_storage_local.js
new file mode 100644
index 0000000000..3100a16f5b
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_webext_storage_local.js
@@ -0,0 +1,296 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* globals browser BigInt */
+
+"use strict";
+
+add_setup(async function () {
+ // Always on top mode mess up with toolbox focus and openStoragePanelForAddon would timeout
+ // waiting for toolbox focus.
+ await pushPref("devtools.toolbox.alwaysOnTop", false);
+});
+
+/**
+ * Since storage item values are represented in the client as strings in textboxes, not all
+ * JavaScript object types supported by the WE storage local API and its IndexedDB backend
+ * can be successfully stringified for display in the table much less parsed correctly when
+ * the user tries to edit a value in the panel. This test is expected to change over time
+ * as more and more value types are supported.
+ */
+add_task(
+ async function test_extension_toolbox_only_supported_values_editable() {
+ async function background() {
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ switch (msg) {
+ case "storage-local-set":
+ await browser.storage.local.set(args[0]);
+ break;
+ case "storage-local-get": {
+ const items = await browser.storage.local.get(args[0]);
+ for (const [key, val] of Object.entries(items)) {
+ browser.test.assertTrue(
+ val === args[1],
+ `New value ${val} is set for key ${key}.`
+ );
+ }
+ break;
+ }
+ case "storage-local-fireOnChanged": {
+ const listener = () => {
+ browser.storage.onChanged.removeListener(listener);
+ browser.test.sendMessage("storage-local-onChanged");
+ };
+ browser.storage.onChanged.addListener(listener);
+ // Call an API method implemented in the parent process
+ // to ensure that the listener has been registered
+ // in the main process before the test proceeds.
+ await browser.runtime.getPlatformInfo();
+ break;
+ }
+ default:
+ browser.test.fail(`Unexpected test message: ${msg}`);
+ }
+
+ browser.test.sendMessage(`${msg}:done`);
+ });
+ browser.test.sendMessage("extension-origin", window.location.origin);
+ }
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ },
+ background,
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+
+ const host = await extension.awaitMessage("extension-origin");
+
+ const itemsSupported = {
+ arr: [1, 2],
+ bool: true,
+ null: null,
+ num: 4,
+ obj: { a: 123 },
+ str: "hi",
+ // Nested objects or arrays at most 2 levels deep should be editable
+ nestedArr: [
+ {
+ a: "b",
+ },
+ "c",
+ ],
+ nestedObj: {
+ a: [1, 2, "long-".repeat(10000)],
+ b: 3,
+ },
+ };
+
+ const itemsUnsupported = {
+ arrBuffer: new ArrayBuffer(8),
+ bigint: BigInt(1),
+ blob: new Blob(
+ [
+ JSON.stringify(
+ {
+ hello: "world",
+ },
+ null,
+ 2
+ ),
+ ],
+ {
+ type: "application/json",
+ }
+ ),
+ date: new Date(0),
+ map: new Map().set("a", "b"),
+ regexp: /regexp/,
+ set: new Set().add(1).add("a"),
+ undef: undefined,
+ // Arrays and object literals with non-JSONifiable values should not be editable
+ arrWithMap: [1, new Map().set("a", 1)],
+ objWithArrayBuffer: { a: new ArrayBuffer(8) },
+ // Nested objects or arrays more than 2 levels deep should not be editable
+ deepNestedArr: [[{ a: "b" }, 3], 4],
+ deepNestedObj: {
+ a: {
+ b: [1, 2],
+ },
+ },
+ };
+
+ info("Add storage items from the extension");
+ const allItems = { ...itemsSupported, ...itemsUnsupported };
+ extension.sendMessage("storage-local-fireOnChanged");
+ await extension.awaitMessage("storage-local-fireOnChanged:done");
+ extension.sendMessage("storage-local-set", allItems);
+ info(
+ "Wait for the extension to add storage items and receive the 'onChanged' event"
+ );
+ await extension.awaitMessage("storage-local-set:done");
+ await extension.awaitMessage("storage-local-onChanged");
+
+ info("Open the addon toolbox storage panel");
+ const { toolbox } = await openStoragePanelForAddon(extension.id);
+
+ await selectTreeItem(["extensionStorage", host]);
+ await waitForStorageData("str", "hi");
+
+ info("Verify that values are displayed as expected in the sidebar");
+ const expectedRenderedData = {
+ arr: {
+ sidebarItems: [
+ { name: "arr", value: "Array" },
+ { name: "arr.0", value: "1" },
+ { name: "arr.1", value: "2" },
+ ],
+ parsed: true,
+ },
+ arrBuffer: {
+ sidebarItems: [{ name: "arrBuffer", value: "Object" }],
+ parsed: true,
+ },
+ arrWithMap: {
+ sidebarItems: [
+ { name: "arrWithMap", value: "Array" },
+ { name: "arrWithMap.0", value: "1" },
+ { name: "arrWithMap.1", value: "Object" },
+ ],
+ parsed: true,
+ },
+ bigint: { sidebarItems: [{ name: "bigint", value: "1n" }] },
+ blob: { sidebarItems: [{ name: "blob", value: "Object" }], parsed: true },
+ bool: {
+ sidebarItems: [{ name: "bool", value: "true" }],
+ },
+ date: {
+ sidebarItems: [{ name: "date", value: "1970-01-01T00:00:00.000Z" }],
+ },
+ deepNestedArr: {
+ sidebarItems: [
+ { name: "deepNestedArr", value: "Array" },
+ { name: "deepNestedArr.0", value: "Array" },
+ { name: "deepNestedArr.1", value: "4" },
+ { name: "deepNestedArr.length", value: "2" },
+ ],
+ parsed: true,
+ },
+ deepNestedObj: {
+ sidebarItems: [
+ { name: "deepNestedObj", value: "Object" },
+ { name: "deepNestedObj.a", value: "Object" },
+ ],
+ parsed: true,
+ },
+ map: { sidebarItems: [{ name: "map", value: "Object" }], parsed: true },
+ nestedArr: {
+ sidebarItems: [
+ { name: "nestedArr", value: "Array" },
+ { name: "nestedArr.0", value: "Object" },
+ { name: "nestedArr.0.a", value: "b" },
+ { name: "nestedArr.1", value: "c" },
+ ],
+ parsed: true,
+ },
+ nestedObj: {
+ sidebarItems: [
+ { name: "nestedObj", value: "Object" },
+ { name: "nestedObj.a", value: "Array" },
+ { name: "nestedObj.a.0", value: "1" },
+ { name: "nestedObj.a.1", value: "2" },
+ { name: "nestedObj.a.2", value: "long-".repeat(10000) },
+ { name: "nestedObj.b", value: "3" },
+ ],
+ parsed: true,
+ },
+ null: {
+ sidebarItems: [{ name: "null", value: "null" }],
+ },
+ num: {
+ sidebarItems: [{ name: "num", value: itemsSupported.num }],
+ },
+ obj: {
+ sidebarItems: [
+ { name: "obj", value: "Object" },
+ { name: "obj.a", value: "123" },
+ ],
+ parsed: true,
+ },
+ objWithArrayBuffer: {
+ sidebarItems: [
+ { name: "objWithArrayBuffer", value: "Object" },
+ { name: "objWithArrayBuffer.a", value: "Object" },
+ ],
+ parsed: true,
+ },
+ regexp: {
+ sidebarItems: [{ name: "regexp", value: "Object" }],
+ parsed: true,
+ },
+ set: { sidebarItems: [{ name: "set", value: "Object" }], parsed: true },
+ str: {
+ sidebarItems: [{ name: "str", value: itemsSupported.str }],
+ },
+
+ undef: { sidebarItems: [{ name: "undef", value: "undefined" }] },
+ };
+
+ for (const [id, { sidebarItems, parsed }] of Object.entries(
+ expectedRenderedData
+ )) {
+ info(`Verify "${id}" entry`);
+ await selectTableItem(id);
+ await findVariableViewProperties(sidebarItems, parsed);
+ }
+
+ info("Verify that value types supported by the storage actor are editable");
+ let validate = true;
+ const newValue = "anotherValue";
+ const supportedIds = Object.keys(itemsSupported);
+
+ for (const id of supportedIds) {
+ startCellEdit(id, "value", newValue);
+ await editCell(id, "value", newValue, validate);
+ }
+
+ info("Verify that associated values have been changed in the extension");
+ extension.sendMessage(
+ "storage-local-get",
+ Object.keys(itemsSupported),
+ newValue
+ );
+ await extension.awaitMessage("storage-local-get:done");
+
+ info(
+ "Verify that value types not supported by the storage actor are uneditable"
+ );
+ const expectedValStrings = {
+ arrBuffer: "{}",
+ bigint: "1n",
+ blob: "{}",
+ date: "1970-01-01T00:00:00.000Z",
+ map: "{}",
+ regexp: "{}",
+ set: "{}",
+ undef: "undefined",
+ arrWithMap: "[1,{}]",
+ objWithArrayBuffer: '{"a":{}}',
+ deepNestedArr: '[[{"a":"b"},3],4]',
+ deepNestedObj: '{"a":{"b":[1,2]}}',
+ };
+ validate = false;
+ for (const id of Object.keys(itemsUnsupported)) {
+ startCellEdit(id, "value", validate);
+ checkCellUneditable(id, "value");
+ checkCell(id, "value", expectedValStrings[id]);
+ }
+
+ info("Shut down the test");
+ await toolbox.destroy();
+ await extension.unload();
+ }
+);
diff --git a/devtools/client/storage/test/head.js b/devtools/client/storage/test/head.js
new file mode 100644
index 0000000000..54c2f11241
--- /dev/null
+++ b/devtools/client/storage/test/head.js
@@ -0,0 +1,1177 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* eslint no-unused-vars: [2, {"vars": "local"}] */
+/* import-globals-from ../../shared/test/shared-head.js */
+
+// Sometimes HTML pages have a `clear` function that cleans up the storage they
+// created. To make sure it's always called, we are registering as a cleanup
+// function, but since this needs to run before tabs are closed, we need to
+// do this registration before importing `shared-head`, since declaration
+// order matters.
+registerCleanupFunction(async () => {
+ const browser = gBrowser.selectedBrowser;
+ const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree();
+ for (const context of contexts) {
+ await SpecialPowers.spawn(context, [], async () => {
+ const win = content.wrappedJSObject;
+
+ // Some windows (e.g., about: URLs) don't have storage available
+ try {
+ win.localStorage.clear();
+ win.sessionStorage.clear();
+ } catch (ex) {
+ // ignore
+ }
+
+ if (win.clear) {
+ // Do not get hung into win.clear() forever
+ await Promise.race([
+ new Promise(r => win.setTimeout(r, 10000)),
+ win.clear(),
+ ]);
+ }
+ });
+ }
+
+ Services.cookies.removeAll();
+
+ // Close tabs and force memory collection to happen
+ while (gBrowser.tabs.length > 1) {
+ await closeTabAndToolbox(gBrowser.selectedTab);
+ }
+ forceCollections();
+});
+
+// shared-head.js handles imports, constants, and utility functions
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js",
+ this
+);
+
+const {
+ TableWidget,
+} = require("resource://devtools/client/shared/widgets/TableWidget.js");
+const {
+ LocalTabCommandsFactory,
+} = require("resource://devtools/client/framework/local-tab-commands-factory.js");
+const STORAGE_PREF = "devtools.storage.enabled";
+const DOM_CACHE = "dom.caches.enabled";
+const DUMPEMIT_PREF = "devtools.dump.emit";
+const DEBUGGERLOG_PREF = "devtools.debugger.log";
+
+// Allows Cache API to be working on usage `http` test page
+const CACHES_ON_HTTP_PREF = "dom.caches.testing.enabled";
+const PATH = "browser/devtools/client/storage/test/";
+const MAIN_DOMAIN = "http://test1.example.org/" + PATH;
+const MAIN_DOMAIN_SECURED = "https://test1.example.org/" + PATH;
+const MAIN_DOMAIN_WITH_PORT = "http://test1.example.org:8000/" + PATH;
+const ALT_DOMAIN = "http://sectest1.example.org/" + PATH;
+const ALT_DOMAIN_SECURED = "https://sectest1.example.org:443/" + PATH;
+
+// GUID to be used as a separator in compound keys. This must match the same
+// constant in devtools/server/actors/resources/storage/index.js,
+// devtools/client/storage/ui.js and devtools/server/tests/browser/head.js
+const SEPARATOR_GUID = "{9d414cc5-8319-0a04-0586-c0a6ae01670a}";
+
+var gToolbox, gPanelWindow, gUI;
+
+// Services.prefs.setBoolPref(DUMPEMIT_PREF, true);
+// Services.prefs.setBoolPref(DEBUGGERLOG_PREF, true);
+
+Services.prefs.setBoolPref(STORAGE_PREF, true);
+Services.prefs.setBoolPref(CACHES_ON_HTTP_PREF, true);
+registerCleanupFunction(() => {
+ gToolbox = gPanelWindow = gUI = null;
+ Services.prefs.clearUserPref(CACHES_ON_HTTP_PREF);
+ Services.prefs.clearUserPref(DEBUGGERLOG_PREF);
+ Services.prefs.clearUserPref(DOM_CACHE);
+ Services.prefs.clearUserPref(DUMPEMIT_PREF);
+ Services.prefs.clearUserPref(STORAGE_PREF);
+});
+
+/**
+ * This generator function opens the given url in a new tab, then sets up the
+ * page by waiting for all cookies, indexedDB items etc.
+ *
+ * @param url {String} The url to be opened in the new tab
+ * @param options {Object} The tab options for the new tab
+ *
+ * @return {Promise} A promise that resolves after the tab is ready
+ */
+async function openTab(url, options = {}) {
+ const tab = await addTab(url, options);
+
+ const browser = gBrowser.selectedBrowser;
+ const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree();
+
+ for (const context of contexts) {
+ await SpecialPowers.spawn(context, [], async () => {
+ const win = content.wrappedJSObject;
+ const readyState = win.document.readyState;
+ info(`Found a window: ${readyState}`);
+ if (readyState != "complete") {
+ await new Promise(resolve => {
+ const onLoad = () => {
+ win.removeEventListener("load", onLoad);
+ resolve();
+ };
+ win.addEventListener("load", onLoad);
+ });
+ }
+ if (win.setup) {
+ await win.setup();
+ }
+ });
+ }
+
+ return tab;
+}
+
+/**
+ * This generator function opens the given url in a new tab, then sets up the
+ * page by waiting for all cookies, indexedDB items etc. to be created; Then
+ * opens the storage inspector and waits for the storage tree and table to be
+ * populated.
+ *
+ * @param url {String} The url to be opened in the new tab
+ * @param options {Object} The tab options for the new tab
+ *
+ * @return {Promise} A promise that resolves after storage inspector is ready
+ */
+async function openTabAndSetupStorage(url, options = {}) {
+ // open tab
+ await openTab(url, options);
+
+ // open storage inspector
+ return openStoragePanel();
+}
+
+/**
+ * Open a toolbox with the storage panel opened by default
+ * for a given Web Extension.
+ *
+ * @param {String} addonId
+ * The ID of the Web Extension to debug.
+ */
+var openStoragePanelForAddon = async function (addonId) {
+ const toolbox = await gDevTools.showToolboxForWebExtension(addonId, {
+ toolId: "storage",
+ });
+
+ info("Making sure that the toolbox's frame is focused");
+ await SimpleTest.promiseFocus(toolbox.win);
+
+ const storage = _setupStoragePanelForTest(toolbox);
+
+ return {
+ toolbox,
+ storage,
+ };
+};
+
+/**
+ * Open the toolbox, with the storage tool visible.
+ *
+ * @param tab {XULTab} Optional, the tab for the toolbox; defaults to selected tab
+ * @param commands {Object} Optional, the commands for the toolbox; defaults to a tab commands
+ * @param hostType {Toolbox.HostType} Optional, type of host that will host the toolbox
+ *
+ * @return {Promise} a promise that resolves when the storage inspector is ready
+ */
+var openStoragePanel = async function ({ tab, hostType } = {}) {
+ const toolbox = await openToolboxForTab(
+ tab || gBrowser.selectedTab,
+ "storage",
+ hostType
+ );
+
+ const storage = _setupStoragePanelForTest(toolbox);
+
+ return {
+ toolbox,
+ storage,
+ };
+};
+
+/**
+ * Set global variables needed in helper functions
+ *
+ * @param toolbox {Toolbox}
+ * @return {StoragePanel}
+ */
+function _setupStoragePanelForTest(toolbox) {
+ const storage = toolbox.getPanel("storage");
+ gPanelWindow = storage.panelWindow;
+ gUI = storage.UI;
+ gToolbox = toolbox;
+
+ // The table animation flash causes some timeouts on Linux debug tests,
+ // so we disable it
+ gUI.animationsEnabled = false;
+
+ return storage;
+}
+
+/**
+ * Forces GC, CC and Shrinking GC to get rid of disconnected docshells and
+ * windows.
+ */
+function forceCollections() {
+ Cu.forceGC();
+ Cu.forceCC();
+ Cu.forceShrinkingGC();
+}
+
+// Sends a click event on the passed DOM node in an async manner
+function click(node) {
+ node.scrollIntoView();
+
+ return new Promise(resolve => {
+ // We need setTimeout here to allow any scrolling to complete before clicking
+ // the node.
+ setTimeout(() => {
+ node.click();
+ resolve();
+ }, 200);
+ });
+}
+
+/**
+ * Recursively expand the variables view up to a given property.
+ *
+ * @param options
+ * Options for view expansion:
+ * - rootVariable: start from the given scope/variable/property.
+ * - expandTo: string made up of property names you want to expand.
+ * For example: "body.firstChild.nextSibling" given |rootVariable:
+ * document|.
+ * @return object
+ * A promise that is resolved only when the last property in |expandTo|
+ * is found, and rejected otherwise. Resolution reason is always the
+ * last property - |nextSibling| in the example above. Rejection is
+ * always the last property that was found.
+ */
+function variablesViewExpandTo(options) {
+ const root = options.rootVariable;
+ const expandTo = options.expandTo.split(".");
+
+ return new Promise((resolve, reject) => {
+ function getNext(prop) {
+ const name = expandTo.shift();
+ const newProp = prop.get(name);
+
+ if (expandTo.length) {
+ ok(newProp, "found property " + name);
+ if (newProp && newProp.expand) {
+ newProp.expand();
+ getNext(newProp);
+ } else {
+ reject(prop);
+ }
+ } else if (newProp) {
+ resolve(newProp);
+ } else {
+ reject(prop);
+ }
+ }
+
+ if (root && root.expand) {
+ root.expand();
+ getNext(root);
+ } else {
+ resolve(root);
+ }
+ });
+}
+
+/**
+ * Find variables or properties in a VariablesView instance.
+ *
+ * @param array ruleArray
+ * The array of rules you want to match. Each rule is an object with:
+ * - name (string|regexp): property name to match.
+ * - value (string|regexp): property value to match.
+ * - dontMatch (boolean): make sure the rule doesn't match any property.
+ * @param boolean parsed
+ * true if we want to test the rules in the parse value section of the
+ * storage sidebar
+ * @return object
+ * A promise object that is resolved when all the rules complete
+ * matching. The resolved callback is given an array of all the rules
+ * you wanted to check. Each rule has a new property: |matchedProp|
+ * which holds a reference to the Property object instance from the
+ * VariablesView. If the rule did not match, then |matchedProp| is
+ * undefined.
+ */
+function findVariableViewProperties(ruleArray, parsed) {
+ // Initialize the search.
+ function init() {
+ // If parsed is true, we are checking rules in the parsed value section of
+ // the storage sidebar. That scope uses a blank variable as a placeholder
+ // Thus, adding a blank parent to each name
+ if (parsed) {
+ ruleArray = ruleArray.map(({ name, value, dontMatch }) => {
+ return { name: "." + name, value, dontMatch };
+ });
+ }
+ // Separate out the rules that require expanding properties throughout the
+ // view.
+ const expandRules = [];
+ const rules = ruleArray.filter(rule => {
+ if (typeof rule.name == "string" && rule.name.indexOf(".") > -1) {
+ expandRules.push(rule);
+ return false;
+ }
+ return true;
+ });
+
+ // Search through the view those rules that do not require any properties to
+ // be expanded. Build the array of matchers, outstanding promises to be
+ // resolved.
+ const outstanding = [];
+
+ finder(rules, gUI.view, outstanding);
+
+ // Process the rules that need to expand properties.
+ const lastStep = processExpandRules.bind(null, expandRules);
+
+ // Return the results - a promise resolved to hold the updated ruleArray.
+ const returnResults = onAllRulesMatched.bind(null, ruleArray);
+
+ return Promise.all(outstanding).then(lastStep).then(returnResults);
+ }
+
+ function onMatch(prop, rule, matched) {
+ if (matched && !rule.matchedProp) {
+ rule.matchedProp = prop;
+ }
+ }
+
+ function finder(rules, view, promises) {
+ for (const scope of view) {
+ for (const [, prop] of scope) {
+ for (const rule of rules) {
+ const matcher = matchVariablesViewProperty(prop, rule);
+ promises.push(matcher.then(onMatch.bind(null, prop, rule)));
+ }
+ }
+ }
+ }
+
+ function processExpandRules(rules) {
+ return new Promise(resolve => {
+ const rule = rules.shift();
+ if (!rule) {
+ resolve(null);
+ }
+
+ const expandOptions = {
+ rootVariable: gUI.view.getScopeAtIndex(parsed ? 1 : 0),
+ expandTo: rule.name,
+ };
+
+ variablesViewExpandTo(expandOptions)
+ .then(
+ function onSuccess(prop) {
+ const name = rule.name;
+ const lastName = name.split(".").pop();
+ rule.name = lastName;
+
+ const matched = matchVariablesViewProperty(prop, rule);
+ return matched
+ .then(onMatch.bind(null, prop, rule))
+ .then(function () {
+ rule.name = name;
+ });
+ },
+ function onFailure() {
+ resolve(null);
+ }
+ )
+ .then(processExpandRules.bind(null, rules))
+ .then(function () {
+ resolve(null);
+ });
+ });
+ }
+
+ function onAllRulesMatched(rules) {
+ for (const rule of rules) {
+ const matched = rule.matchedProp;
+ if (matched && !rule.dontMatch) {
+ ok(true, "rule " + rule.name + " matched for property " + matched.name);
+ } else if (matched && rule.dontMatch) {
+ ok(
+ false,
+ "rule " + rule.name + " should not match property " + matched.name
+ );
+ } else {
+ ok(rule.dontMatch, "rule " + rule.name + " did not match any property");
+ }
+ }
+ return rules;
+ }
+
+ return init();
+}
+
+/**
+ * Check if a given Property object from the variables view matches the given
+ * rule.
+ *
+ * @param object prop
+ * The variable's view Property instance.
+ * @param object rule
+ * Rules for matching the property. See findVariableViewProperties() for
+ * details.
+ * @return object
+ * A promise that is resolved when all the checks complete. Resolution
+ * result is a boolean that tells your promise callback the match
+ * result: true or false.
+ */
+function matchVariablesViewProperty(prop, rule) {
+ function resolve(result) {
+ return Promise.resolve(result);
+ }
+
+ if (!prop) {
+ return resolve(false);
+ }
+
+ // Any kind of string is accepted as name, including empty ones
+ if (typeof rule.name == "string") {
+ const match =
+ rule.name instanceof RegExp
+ ? rule.name.test(prop.name)
+ : prop.name == rule.name;
+ if (!match) {
+ return resolve(false);
+ }
+ }
+
+ if ("value" in rule) {
+ let displayValue = prop.displayValue;
+ if (prop.displayValueClassName == "token-string") {
+ displayValue = displayValue.substring(1, displayValue.length - 1);
+ }
+
+ const match =
+ rule.value instanceof RegExp
+ ? rule.value.test(displayValue)
+ : displayValue == rule.value;
+ if (!match) {
+ info(
+ "rule " +
+ rule.name +
+ " did not match value, expected '" +
+ rule.value +
+ "', found '" +
+ displayValue +
+ "'"
+ );
+ return resolve(false);
+ }
+ }
+
+ return resolve(true);
+}
+
+/**
+ * Click selects a row in the table.
+ *
+ * @param {[String]} ids
+ * The array id of the item in the tree
+ */
+async function selectTreeItem(ids) {
+ if (gUI.tree.isSelected(ids)) {
+ info(`"${ids}" is already selected, returning.`);
+ return;
+ }
+ if (!gUI.tree.exists(ids)) {
+ info(`"${ids}" does not exist, returning.`);
+ return;
+ }
+
+ // The item exists but is not selected... select it.
+ info(`Selecting "${ids}".`);
+ if (ids.length > 1) {
+ const updated = gUI.once("store-objects-updated");
+ gUI.tree.selectedItem = ids;
+ await updated;
+ } else {
+ // If the length of the IDs array is 1, a storage type
+ // gets selected and no 'store-objects-updated' event
+ // will be fired in that case.
+ gUI.tree.selectedItem = ids;
+ }
+}
+
+/**
+ * Click selects a row in the table.
+ *
+ * @param {String} id
+ * The id of the row in the table widget
+ */
+async function selectTableItem(id) {
+ const table = gUI.table;
+ const selector =
+ ".table-widget-column#" +
+ table.uniqueId +
+ " .table-widget-cell[value='" +
+ id +
+ "']";
+ const target = gPanelWindow.document.querySelector(selector);
+
+ ok(target, `row found with id "${id}"`);
+
+ if (!target) {
+ showAvailableIds();
+ }
+
+ const updated = gUI.once("sidebar-updated");
+
+ info(`selecting row "${id}"`);
+ await click(target);
+ await updated;
+}
+
+/**
+ * Wait for eventName on target.
+ * @param {Object} target An observable object that either supports on/off or
+ * addEventListener/removeEventListener
+ * @param {String} eventName
+ * @param {Boolean} useCapture Optional, for addEventListener/removeEventListener
+ * @return A promise that resolves when the event has been handled
+ */
+function once(target, eventName, useCapture = false) {
+ info("Waiting for event: '" + eventName + "' on " + target + ".");
+
+ return new Promise(resolve => {
+ for (const [add, remove] of [
+ ["addEventListener", "removeEventListener"],
+ ["addListener", "removeListener"],
+ ["on", "off"],
+ ]) {
+ if (add in target && remove in target) {
+ target[add](
+ eventName,
+ function onEvent(...aArgs) {
+ info("Got event: '" + eventName + "' on " + target + ".");
+ target[remove](eventName, onEvent, useCapture);
+ resolve(...aArgs);
+ },
+ useCapture
+ );
+ break;
+ }
+ }
+ });
+}
+
+/**
+ * Get values for a row.
+ *
+ * @param {String} id
+ * The uniqueId of the given row.
+ * @param {Boolean} includeHidden
+ * Include hidden columns.
+ *
+ * @return {Object}
+ * An object of column names to values for the given row.
+ */
+function getRowValues(id, includeHidden = false) {
+ const cells = getRowCells(id, includeHidden);
+ const values = {};
+
+ for (const name in cells) {
+ const cell = cells[name];
+
+ values[name] = cell.value;
+ }
+
+ return values;
+}
+
+/**
+ * Get cells for a row.
+ *
+ * @param {String} id
+ * The uniqueId of the given row.
+ * @param {Boolean} includeHidden
+ * Include hidden columns.
+ *
+ * @return {Object}
+ * An object of column names to cells for the given row.
+ */
+function getRowCells(id, includeHidden = false) {
+ const doc = gPanelWindow.document;
+ const table = gUI.table;
+ const item = doc.querySelector(
+ ".table-widget-column#" +
+ table.uniqueId +
+ " .table-widget-cell[value='" +
+ id +
+ "']"
+ );
+
+ if (!item) {
+ ok(
+ false,
+ `The row id '${id}' that was passed to getRowCells() does not ` +
+ `exist. ${getAvailableIds()}`
+ );
+ }
+
+ const index = table.columns.get(table.uniqueId).cellNodes.indexOf(item);
+ const cells = {};
+
+ for (const [name, column] of [...table.columns]) {
+ if (!includeHidden && column.column.parentNode.hidden) {
+ continue;
+ }
+ cells[name] = column.cellNodes[index];
+ }
+
+ return cells;
+}
+
+/**
+ * Check for an empty table.
+ */
+function isTableEmpty() {
+ const doc = gPanelWindow.document;
+ const table = gUI.table;
+ const cells = doc.querySelectorAll(
+ ".table-widget-column#" + table.uniqueId + " .table-widget-cell"
+ );
+ return cells.length === 0;
+}
+
+/**
+ * Get available ids... useful for error reporting.
+ */
+function getAvailableIds() {
+ const doc = gPanelWindow.document;
+ const table = gUI.table;
+
+ let out = "Available ids:\n";
+ const cells = doc.querySelectorAll(
+ ".table-widget-column#" + table.uniqueId + " .table-widget-cell"
+ );
+ for (const cell of cells) {
+ out += ` - ${cell.getAttribute("value")}\n`;
+ }
+
+ return out;
+}
+
+/**
+ * Show available ids.
+ */
+function showAvailableIds() {
+ info(getAvailableIds());
+}
+
+/**
+ * Get a cell value.
+ *
+ * @param {String} id
+ * The uniqueId of the row.
+ * @param {String} column
+ * The id of the column
+ *
+ * @yield {String}
+ * The cell value.
+ */
+function getCellValue(id, column) {
+ const row = getRowValues(id, true);
+
+ if (typeof row[column] === "undefined") {
+ let out = "";
+ for (const key in row) {
+ const value = row[key];
+
+ out += ` - ${key} = ${value}\n`;
+ }
+
+ ok(
+ false,
+ `The column name '${column}' that was passed to ` +
+ `getCellValue() does not exist. Current column names and row ` +
+ `values are:\n${out}`
+ );
+ }
+
+ return row[column];
+}
+
+/**
+ * Edit a cell value. The cell is assumed to be in edit mode, see startCellEdit.
+ *
+ * @param {String} id
+ * The uniqueId of the row.
+ * @param {String} column
+ * The id of the column
+ * @param {String} newValue
+ * Replacement value.
+ * @param {Boolean} validate
+ * Validate result? Default true.
+ *
+ * @yield {String}
+ * The uniqueId of the changed row.
+ */
+async function editCell(id, column, newValue, validate = true) {
+ const row = getRowCells(id, true);
+ const editableFieldsEngine = gUI.table._editableFieldsEngine;
+
+ editableFieldsEngine.edit(row[column]);
+
+ await typeWithTerminator(newValue, "KEY_Enter", validate);
+}
+
+/**
+ * Begin edit mode for a cell.
+ *
+ * @param {String} id
+ * The uniqueId of the row.
+ * @param {String} column
+ * The id of the column
+ * @param {Boolean} selectText
+ * Select text? Default true.
+ */
+function startCellEdit(id, column, selectText = true) {
+ const row = getRowCells(id, true);
+ const editableFieldsEngine = gUI.table._editableFieldsEngine;
+ const cell = row[column];
+
+ info("Selecting row " + id);
+ gUI.table.selectedRow = id;
+
+ info("Starting cell edit (" + id + ", " + column + ")");
+ editableFieldsEngine.edit(cell);
+
+ if (!selectText) {
+ const textbox = gUI.table._editableFieldsEngine.textbox;
+ textbox.selectionEnd = textbox.selectionStart;
+ }
+}
+
+/**
+ * Check a cell value.
+ *
+ * @param {String} id
+ * The uniqueId of the row.
+ * @param {String} column
+ * The id of the column
+ * @param {String} expected
+ * Expected value.
+ */
+function checkCell(id, column, expected) {
+ is(
+ getCellValue(id, column),
+ expected,
+ column + " column has the right value for " + id
+ );
+}
+
+/**
+ * Check that a cell is not in edit mode.
+ *
+ * @param {String} id
+ * The uniqueId of the row.
+ * @param {String} column
+ * The id of the column
+ */
+function checkCellUneditable(id, column) {
+ const row = getRowCells(id, true);
+ const cell = row[column];
+
+ const editableFieldsEngine = gUI.table._editableFieldsEngine;
+ const textbox = editableFieldsEngine.textbox;
+
+ // When a field is being edited, the cell is hidden, and the textbox is made visible.
+ ok(
+ !cell.hidden && textbox.hidden,
+ `The cell located in column ${column} and row ${id} is not editable.`
+ );
+}
+
+/**
+ * Show or hide a column.
+ *
+ * @param {String} id
+ * The uniqueId of the given column.
+ * @param {Boolean} state
+ * true = show, false = hide
+ */
+function showColumn(id, state) {
+ const columns = gUI.table.columns;
+ const column = columns.get(id);
+ column.column.hidden = !state;
+}
+
+/**
+ * Toggle sort direction on a column by clicking on the column header.
+ *
+ * @param {String} id
+ * The uniqueId of the given column.
+ */
+function clickColumnHeader(id) {
+ const columns = gUI.table.columns;
+ const column = columns.get(id);
+ const header = column.header;
+
+ header.click();
+}
+
+/**
+ * Show or hide all columns.
+ *
+ * @param {Boolean} state
+ * true = show, false = hide
+ */
+function showAllColumns(state) {
+ const columns = gUI.table.columns;
+
+ for (const [id] of columns) {
+ showColumn(id, state);
+ }
+}
+
+/**
+ * Type a string in the currently selected editor and then wait for the row to
+ * be updated.
+ *
+ * @param {String} str
+ * The string to type.
+ * @param {String} terminator
+ * The terminating key e.g. KEY_Enter or KEY_Tab
+ * @param {Boolean} validate
+ * Validate result? Default true.
+ */
+async function typeWithTerminator(str, terminator, validate = true) {
+ const editableFieldsEngine = gUI.table._editableFieldsEngine;
+ const textbox = editableFieldsEngine.textbox;
+ const colName = textbox.closest(".table-widget-column").id;
+
+ const changeExpected = str !== textbox.value;
+
+ if (!changeExpected) {
+ return editableFieldsEngine.currentTarget.getAttribute("data-id");
+ }
+
+ info("Typing " + str);
+ EventUtils.sendString(str, gPanelWindow);
+
+ info("Pressing " + terminator);
+ EventUtils.synthesizeKey(terminator, null, gPanelWindow);
+
+ if (validate) {
+ info("Validating results... waiting for ROW_EDIT event.");
+ const uniqueId = await gUI.table.once(TableWidget.EVENTS.ROW_EDIT);
+
+ checkCell(uniqueId, colName, str);
+ return uniqueId;
+ }
+
+ return gUI.table.once(TableWidget.EVENTS.ROW_EDIT);
+}
+
+function getCurrentEditorValue() {
+ const editableFieldsEngine = gUI.table._editableFieldsEngine;
+ const textbox = editableFieldsEngine.textbox;
+
+ return textbox.value;
+}
+
+/**
+ * Press a key x times.
+ *
+ * @param {String} key
+ * The key to press e.g. VK_RETURN or VK_TAB
+ * @param {Number} x
+ * The number of times to press the key.
+ * @param {Object} modifiers
+ * The event modifier e.g. {shiftKey: true}
+ */
+function PressKeyXTimes(key, x, modifiers = {}) {
+ for (let i = 0; i < x; i++) {
+ EventUtils.synthesizeKey(key, modifiers);
+ }
+}
+
+/**
+ * Verify the storage inspector state: check that given type/host exists
+ * in the tree, and that the table contains rows with specified names.
+ *
+ * @param {Array} state Array of state specifications. For example,
+ * [["cookies", "example.com"], ["c1", "c2"]] means to select the
+ * "example.com" host in cookies and then verify there are "c1" and "c2"
+ * cookies (and no other ones).
+ */
+async function checkState(state) {
+ for (const [store, names] of state) {
+ const storeName = store.join(" > ");
+ info(`Selecting tree item ${storeName}`);
+ await selectTreeItem(store);
+
+ const items = gUI.table.items;
+
+ is(
+ items.size,
+ names.length,
+ `There is correct number of rows in ${storeName}`
+ );
+
+ if (names.length === 0) {
+ showAvailableIds();
+ }
+
+ for (const name of names) {
+ if (!items.has(name)) {
+ showAvailableIds();
+ }
+ ok(items.has(name), `There is item with name '${name}' in ${storeName}`);
+ }
+ }
+}
+
+/**
+ * Checks if document's active element is within the given element.
+ * @param {HTMLDocument} doc document with active element in question
+ * @param {DOMNode} container element tested on focus containment
+ * @return {Boolean}
+ */
+function containsFocus(doc, container) {
+ let elm = doc.activeElement;
+ while (elm) {
+ if (elm === container) {
+ return true;
+ }
+ elm = elm.parentNode;
+ }
+ return false;
+}
+
+var focusSearchBoxUsingShortcut = async function (panelWin, callback) {
+ info("Focusing search box");
+ const searchBox = panelWin.document.getElementById("storage-searchbox");
+ const focused = once(searchBox, "focus");
+
+ panelWin.focus();
+
+ const shortcut = await panelWin.document.l10n.formatValue(
+ "storage-filter-key"
+ );
+ synthesizeKeyShortcut(shortcut);
+
+ await focused;
+
+ if (callback) {
+ callback();
+ }
+};
+
+function getCookieId(name, domain, path) {
+ return `${name}${SEPARATOR_GUID}${domain}${SEPARATOR_GUID}${path}`;
+}
+
+function setPermission(url, permission) {
+ const nsIPermissionManager = Ci.nsIPermissionManager;
+
+ const uri = Services.io.newURI(url);
+ const principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ );
+
+ Cc["@mozilla.org/permissionmanager;1"]
+ .getService(nsIPermissionManager)
+ .addFromPrincipal(principal, permission, nsIPermissionManager.ALLOW_ACTION);
+}
+
+function toggleSidebar() {
+ gUI.sidebarToggleBtn.click();
+}
+
+function sidebarToggleVisible() {
+ return !gUI.sidebarToggleBtn.hidden;
+}
+
+/**
+ * Check whether the variables view in the sidebar contains a tree.
+ *
+ * @param {Boolean} state
+ * Should a tree be visible?
+ */
+function sidebarParseTreeVisible(state) {
+ if (state) {
+ ok(gUI.view._currHierarchy.size > 2, "Parse tree should be visible.");
+ } else {
+ ok(gUI.view._currHierarchy.size <= 2, "Parse tree should not be visible.");
+ }
+}
+
+/**
+ * Add an item.
+ * @param {Array} store
+ * An array containing the path to the store to which we wish to add an
+ * item.
+ */
+async function performAdd(store) {
+ const storeName = store.join(" > ");
+ const toolbar = gPanelWindow.document.getElementById("storage-toolbar");
+ const type = store[0];
+
+ await selectTreeItem(store);
+
+ const menuAdd = toolbar.querySelector("#add-button");
+
+ if (menuAdd.hidden) {
+ is(
+ menuAdd.hidden,
+ false,
+ `performAdd called for ${storeName} but it is not supported`
+ );
+ return;
+ }
+
+ const eventEdit = gUI.table.once("row-edit");
+ const eventWait = gUI.once("store-objects-edit");
+
+ menuAdd.click();
+
+ const rowId = await eventEdit;
+ await eventWait;
+
+ const key = type === "cookies" ? "uniqueKey" : "name";
+ const value = getCellValue(rowId, key);
+
+ is(rowId, value, `Row '${rowId}' was successfully added.`);
+}
+
+// Cell css selector that can be used to count or select cells.
+// The selector is restricted to a single column to avoid counting duplicates.
+const CELL_SELECTOR =
+ "#storage-table .table-widget-column:first-child .table-widget-cell";
+
+function getCellLength() {
+ return gPanelWindow.document.querySelectorAll(CELL_SELECTOR).length;
+}
+
+function checkCellLength(len) {
+ is(getCellLength(), len, `Table should contain ${len} items`);
+}
+
+async function scroll() {
+ const $ = id => gPanelWindow.document.querySelector(id);
+ const table = $("#storage-table .table-widget-body");
+ const cell = $(CELL_SELECTOR);
+ const cellHeight = cell.getBoundingClientRect().height;
+
+ const onStoresUpdate = gUI.once("store-objects-updated");
+ table.scrollTop += cellHeight * 50;
+ await onStoresUpdate;
+}
+
+/**
+ * Asserts that the given tree path exists
+ * @param {Document} doc
+ * @param {Array} path
+ * @param {Boolean} isExpected
+ */
+function checkTree(doc, path, isExpected = true) {
+ const doesExist = isInTree(doc, path);
+ ok(
+ isExpected ? doesExist : !doesExist,
+ `${path.join(" > ")} is ${isExpected ? "" : "not "}in the tree`
+ );
+}
+
+/**
+ * Returns whether a tree path exists
+ * @param {Document} doc
+ * @param {Array} path
+ */
+function isInTree(doc, path) {
+ const treeId = JSON.stringify(path);
+ return !!doc.querySelector(`[data-id='${treeId}']`);
+}
+
+/**
+ * Returns the label of the node for the provided tree path
+ * @param {Document} doc
+ * @param {Array} path
+ * @returns {String}
+ */
+function getTreeNodeLabel(doc, path) {
+ const treeId = JSON.stringify(path);
+ return doc.querySelector(`[data-id='${treeId}'] .tree-widget-item`)
+ .textContent;
+}
+
+/**
+ * Checks that the pair <name, value> is displayed at the data table
+ * @param {String} name
+ * @param {any} value
+ */
+function checkStorageData(name, value) {
+ ok(
+ hasStorageData(name, value),
+ `Table row has an entry for: ${name} with value: ${value}`
+ );
+}
+
+async function waitForStorageData(name, value) {
+ info("Waiting for data to appear in the table");
+ await waitFor(() => hasStorageData(name, value));
+ ok(true, `Table row has an entry for: ${name} with value: ${value}`);
+}
+
+/**
+ * Returns whether the pair <name, value> is displayed at the data table
+ * @param {String} name
+ * @param {any} value
+ */
+function hasStorageData(name, value) {
+ return gUI.table.items.get(name)?.value === value;
+}
+
+/**
+ * Returns an URL of a page that uses the document-builder to generate its content
+ * @param {String} domain
+ * @param {String} html
+ * @param {String} protocol
+ */
+function buildURLWithContent(domain, html, protocol = "https") {
+ return `${protocol}://${domain}/document-builder.sjs?html=${encodeURI(html)}`;
+}
+
+/**
+ * Asserts that the given cookie holds the provided value in the data table
+ * @param {String} name
+ * @param {String} value
+ */
+function checkCookieData(name, value) {
+ ok(
+ hasCookieData(name, value),
+ `Table row has an entry for: ${name} with value: ${value}`
+ );
+}
+
+/**
+ * Returns whether the given cookie holds the provided value in the data table
+ * @param {String} name
+ * @param {String} value
+ */
+function hasCookieData(name, value) {
+ const rows = Array.from(gUI.table.items);
+ const cookie = rows.map(([, data]) => data).find(x => x.name === name);
+
+ info(`found ${cookie?.value}`);
+ return cookie?.value === value;
+}
diff --git a/devtools/client/storage/test/storage-blank.html b/devtools/client/storage/test/storage-blank.html
new file mode 100644
index 0000000000..81342ed690
--- /dev/null
+++ b/devtools/client/storage/test/storage-blank.html
@@ -0,0 +1,9 @@
+<!doctype html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <h2>storage-blank.html</h2>
+ </body>
+</html>
diff --git a/devtools/client/storage/test/storage-cache-basic-iframe.html b/devtools/client/storage/test/storage-cache-basic-iframe.html
new file mode 100644
index 0000000000..929660dc42
--- /dev/null
+++ b/devtools/client/storage/test/storage-cache-basic-iframe.html
@@ -0,0 +1,21 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector test for Cache</title>
+</head>
+<body>
+ <h1>Cache (iframe)</h1>
+<script>
+ "use strict";
+ async function setup() { // eslint-disable-line no-unused-vars
+ const cache = await caches.open("foo");
+ await cache.add("storage-blank.html");
+ }
+ function clear() { // eslint-disable-line no-unused-vars
+ return caches.delete("foo");
+ }
+</script>
+
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-cache-basic.html b/devtools/client/storage/test/storage-cache-basic.html
new file mode 100644
index 0000000000..b87023ece1
--- /dev/null
+++ b/devtools/client/storage/test/storage-cache-basic.html
@@ -0,0 +1,22 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector test for Cache</title>
+</head>
+<body>
+ <h1>Cache</h1>
+<script>
+ "use strict";
+ async function setup() { // eslint-disable-line no-unused-vars
+ const cache = await caches.open("lorem");
+ await cache.add("storage-blank.html");
+ }
+ function clear() { // eslint-disable-line no-unused-vars
+ return caches.delete("lorem");
+ }
+</script>
+
+<iframe src="https://example.net/browser/devtools/client/storage/test/storage-cache-basic-iframe.html"></iframe>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-cache-error.html b/devtools/client/storage/test/storage-cache-error.html
new file mode 100644
index 0000000000..1941c0dce0
--- /dev/null
+++ b/devtools/client/storage/test/storage-cache-error.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector test for handling errors in CacheStorage</title>
+</head>
+<!-- The test case would load this page in a private browsing window -->
+<body>
+ <iframe src="https://test2.example.org"></iframe>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-cache-overflow.html b/devtools/client/storage/test/storage-cache-overflow.html
new file mode 100644
index 0000000000..83e6636817
--- /dev/null
+++ b/devtools/client/storage/test/storage-cache-overflow.html
@@ -0,0 +1,23 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector test for Cache</title>
+</head>
+<body>
+ <h1>Cache overflow</h1>
+<script>
+ "use strict";
+ async function setup() { // eslint-disable-line no-unused-vars
+ const cache = await caches.open("lorem");
+ for (let i = 0; i < 100; i++) {
+ await cache.add(`storage-blank.html?${i}`);
+ }
+ }
+ function clear() { // eslint-disable-line no-unused-vars
+ return caches.delete("lorem");
+ }
+</script>
+
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-complex-keys.html b/devtools/client/storage/test/storage-complex-keys.html
new file mode 100644
index 0000000000..b037190dea
--- /dev/null
+++ b/devtools/client/storage/test/storage-complex-keys.html
@@ -0,0 +1,78 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector test for correct keys in the sidebar</title>
+</head>
+<body>
+<script type="application/javascript">
+"use strict";
+
+// Some local storage items ...
+localStorage.setItem("", "1");
+localStorage.setItem("é”®", "2");
+// ... and finally some session storage items too
+sessionStorage.setItem("Key with spaces", "3");
+sessionStorage.setItem("Key#with~special$characters", "4");
+// long string
+const longKey = "a".repeat(1000);
+sessionStorage.setItem(longKey, "5");
+
+const idbGenerator = async function () {
+ const request = indexedDB.open("idb", 1);
+ request.onerror = function() {
+ throw new Error("Error opening database connection");
+ };
+
+ const db = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const _db = event.target.result;
+ const store = _db.createObjectStore("obj", { keyPath: "id" });
+ store.createIndex("name", "name", { unique: false });
+ store.transaction.oncomplete = () => {
+ done(_db);
+ };
+ };
+ });
+
+ // Prevents AbortError
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+
+ const transaction = db.transaction("obj", "readwrite");
+ const store = transaction.objectStore("obj");
+
+ store.add({id: "", name: "foo"});
+ store.add({id: "é”®", name: "foo2"});
+ store.add({id: "Key with spaces", name: "foo3"});
+ store.add({id: "Key#with~special$characters", name: "foo4"});
+ store.add({id: longKey, name: "foo5"});
+
+ db.close();
+
+ console.log("Added local and session storage items and indexedDB");
+};
+
+function deleteDB(dbName) {
+ return new Promise(resolve => {
+ dump("removing database " + dbName + " from " + document.location + "\n");
+ indexedDB.deleteDatabase(dbName).onsuccess = resolve;
+ });
+}
+
+window.setup = async function () {
+ await idbGenerator();
+};
+
+window.clear = async function () {
+ localStorage.clear();
+ sessionStorage.clear();
+
+ await deleteDB("idb");
+
+ dump("Removed data from " + document.location + "\n");
+};
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-complex-values.html b/devtools/client/storage/test/storage-complex-values.html
new file mode 100644
index 0000000000..db7bc5e2ed
--- /dev/null
+++ b/devtools/client/storage/test/storage-complex-values.html
@@ -0,0 +1,124 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 970517 - Storage inspector front end - tests
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector test for correct values in the sidebar</title>
+</head>
+<body>
+<script type="application/javascript">
+"use strict";
+const partialHostname = location.hostname.match(/^[^.]+(\..*)$/)[1];
+const cookieExpiresTime = 2000000000000;
+// Setting up some cookies to eat.
+document.cookie = "c1=" + JSON.stringify([
+ "foo", "Bar", {
+ foo: "Bar"
+ }]) + "; expires=" + new Date(cookieExpiresTime).toGMTString() +
+ "; path=/browser";
+document.cookie = "cs2=sessionCookie; path=/; domain=" + partialHostname;
+// URLEncoded cookie
+document.cookie = "c_encoded=" + encodeURIComponent(JSON.stringify({foo: {foo1: "bar"}}));
+
+// ... and some local storage items ..
+const es6 = "for";
+localStorage.setItem("ls1", JSON.stringify({
+ es6, the: "win", baz: [0, 2, 3, {
+ deep: "down",
+ nobody: "cares"
+ }]}));
+localStorage.setItem("ls2", "foobar-2");
+localStorage.setItem("ls3", "http://foobar.com/baz.php");
+localStorage.setItem("ls4", "0x1");
+// ... and finally some session storage items too
+sessionStorage.setItem("ss1", "This#is#an#array");
+sessionStorage.setItem("ss2", "This~is~another~array");
+sessionStorage.setItem("ss3", "this#is~an#object~foo#bar");
+sessionStorage.setItem("ss4", "#array##with#empty#items");
+// long string that is almost an object and might trigger exponential
+// regexp backtracking
+const s = "a".repeat(1000);
+sessionStorage.setItem("ss5", `${s}=${s}=${s}=${s}&${s}=${s}&${s}`);
+console.log("added cookies and stuff from main page");
+
+const idbGenerator = async function () {
+ let request = indexedDB.open("idb1", 1);
+ request.onerror = function() {
+ throw new Error("error opening db connection");
+ };
+ const db = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const _db = event.target.result;
+ const store1 = _db.createObjectStore("obj1", { keyPath: "id" });
+ store1.createIndex("name", "name", { unique: false });
+ store1.createIndex("email", "email", { unique: true });
+ _db.createObjectStore("obj2", { keyPath: "id2" });
+ store1.transaction.oncomplete = () => {
+ done(_db);
+ };
+ };
+ });
+
+ // Prevents AbortError
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+
+ const transaction = db.transaction(["obj1", "obj2"], "readwrite");
+ const store1 = transaction.objectStore("obj1");
+ const store2 = transaction.objectStore("obj2");
+
+ store1.add({id: 1, name: "foo", email: "foo@bar.com"});
+ store1.add({id: 2, name: "foo2", email: "foo2@bar.com"});
+ store1.add({id: 3, name: "foo2", email: "foo3@bar.com"});
+ store2.add({
+ id2: 1,
+ name: "foo",
+ email: "foo@bar.com",
+ extra: "baz".repeat(10000)});
+
+ db.close();
+
+ request = indexedDB.open("idb2", 1);
+ const db2 = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const _db2 = event.target.result;
+ const store3 = _db2.createObjectStore("obj3", { keyPath: "id3" });
+ store3.createIndex("name2", "name2", { unique: true });
+ store3.transaction.oncomplete = () => {
+ done(_db2);
+ };
+ };
+ });
+
+ // Prevents AbortError during close()
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+
+ db2.close();
+ console.log("added cookies and stuff from main page");
+};
+
+function deleteDB(dbName) {
+ return new Promise(resolve => {
+ dump("removing database " + dbName + " from " + document.location + "\n");
+ indexedDB.deleteDatabase(dbName).onsuccess = resolve;
+ });
+}
+
+window.setup = async function () {
+ await idbGenerator();
+};
+
+window.clear = async function () {
+ await deleteDB("idb1");
+ await deleteDB("idb2");
+
+ dump("removed indexedDB data from " + document.location + "\n");
+};
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-cookies-samesite.html b/devtools/client/storage/test/storage-cookies-samesite.html
new file mode 100644
index 0000000000..90eb75d95e
--- /dev/null
+++ b/devtools/client/storage/test/storage-cookies-samesite.html
@@ -0,0 +1,17 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Storage inspector cookie samesite test</title>
+ </head>
+ <body>
+ <script type="application/javascript">
+ "use strict";
+ const expiresIn24Hours = new Date(Date.now() + 60 * 60 * 24 * 1000).toUTCString();
+
+ document.cookie = "test1=value1;expires=" + expiresIn24Hours + ";";
+ document.cookie = "test2=value2;expires=" + expiresIn24Hours + ";SameSite=lax";
+ document.cookie = "test3=value3;expires=" + expiresIn24Hours + ";SameSite=strict";
+ </script>
+ </body>
+</html>
diff --git a/devtools/client/storage/test/storage-cookies-sort.html b/devtools/client/storage/test/storage-cookies-sort.html
new file mode 100644
index 0000000000..fb590d5cb2
--- /dev/null
+++ b/devtools/client/storage/test/storage-cookies-sort.html
@@ -0,0 +1,26 @@
+<!DOCTYPE HTML>
+<html>
+ <!--
+ Bug 970517 - Storage inspector front end - tests
+ -->
+ <head>
+ <meta charset="utf-8">
+ <title>Storage inspector cookie test</title>
+ </head>
+ <body>
+ <script type="application/javascript">
+ "use strict";
+ const ONE_HOUR = 60 * 60 * 1000;
+ const ONE_DAY = 24 * ONE_HOUR;
+ const expiresOneHour = new Date(Date.now() + 1 * ONE_HOUR).toUTCString();
+ const expiresOneDay = new Date(Date.now() + 1 * ONE_DAY).toUTCString();
+ const expiresOneYear = new Date(Date.now() + 365 * ONE_DAY).toUTCString();
+
+ document.cookie = "test_hour=hour;expires=" + expiresOneHour;
+ document.cookie = "test_session1=session1";
+ document.cookie = "test_day=day;expires=" + expiresOneDay;
+ document.cookie = "test_session2=session2";
+ document.cookie = "test_year=year;expires=" + expiresOneYear;
+ </script>
+ </body>
+</html>
diff --git a/devtools/client/storage/test/storage-cookies.html b/devtools/client/storage/test/storage-cookies.html
new file mode 100644
index 0000000000..c0d0522961
--- /dev/null
+++ b/devtools/client/storage/test/storage-cookies.html
@@ -0,0 +1,24 @@
+<!DOCTYPE HTML>
+<html>
+ <!--
+ Bug 970517 - Storage inspector front end - tests
+ -->
+ <head>
+ <meta charset="utf-8">
+ <title>Storage inspector cookie test</title>
+ </head>
+ <body>
+ <script type="application/javascript">
+ "use strict";
+ const expiresIn24Hours = new Date(Date.now() + 60 * 60 * 24 * 1000).toUTCString();
+ for (let i = 1; i <= 5; i++) {
+ let cookieString = "test" + i + "=value" + i +
+ ";expires=" + expiresIn24Hours + ";path=/browser";
+ if (i % 2) {
+ cookieString += ";domain=test1.example.org";
+ }
+ document.cookie = cookieString;
+ }
+ </script>
+ </body>
+</html>
diff --git a/devtools/client/storage/test/storage-dfpi.html b/devtools/client/storage/test/storage-dfpi.html
new file mode 100644
index 0000000000..e440819df7
--- /dev/null
+++ b/devtools/client/storage/test/storage-dfpi.html
@@ -0,0 +1,11 @@
+<!doctype html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <h2>storage-iframe.html</h2>
+ <!-- Sync iframe.src to browser_storage_dfpi.js:PREFIX -->
+ <iframe src="https://sub1.test1.example.com/browser/devtools/client/storage/test/storage-blank.html"></iframe>
+ </body>
+</html>
diff --git a/devtools/client/storage/test/storage-empty-objectstores.html b/devtools/client/storage/test/storage-empty-objectstores.html
new file mode 100644
index 0000000000..4479fc0972
--- /dev/null
+++ b/devtools/client/storage/test/storage-empty-objectstores.html
@@ -0,0 +1,62 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for proper listing indexedDB databases with no object stores</title>
+</head>
+<body>
+<script type="application/javascript">
+"use strict";
+window.setup = async function () {
+ let request = indexedDB.open("idb1", 1);
+ const db = await new Promise((resolve, reject) => {
+ request.onerror = e => reject(Error("error opening db connection"));
+ request.onupgradeneeded = event => {
+ const _db = event.target.result;
+ const store1 = _db.createObjectStore("obj1", { keyPath: "id" });
+ store1.createIndex("name", "name", { unique: false });
+ store1.createIndex("email", "email", { unique: true });
+ _db.createObjectStore("obj2", { keyPath: "id2" });
+ store1.transaction.oncomplete = () => resolve(_db);
+ };
+ });
+
+ await new Promise(resolve => (request.onsuccess = resolve));
+
+ const transaction = db.transaction(["obj1", "obj2"], "readwrite");
+ const store1 = transaction.objectStore("obj1");
+ const store2 = transaction.objectStore("obj2");
+
+ store1.add({id: 1, name: "foo", email: "foo@bar.com"});
+ store1.add({id: 2, name: "foo2", email: "foo2@bar.com"});
+ store1.add({id: 3, name: "foo2", email: "foo3@bar.com"});
+ store2.add({id2: 1, name: "foo", email: "foo@bar.com", extra: "baz"});
+
+ await new Promise(resolve => (transaction.oncomplete = resolve));
+
+ db.close();
+
+ request = indexedDB.open("idb2", 1);
+ const db2 = await new Promise((resolve, reject) => {
+ request.onerror = e => reject(Error("error opening db2 connection"));
+ request.onupgradeneeded = event => resolve(event.target.result);
+ });
+
+ await new Promise(resolve => (request.onsuccess = resolve));
+
+ db2.close();
+ dump("added indexedDB items from main page\n");
+};
+
+window.clear = async function () {
+ for (const dbName of ["idb1", "idb2"]) {
+ await new Promise(resolve => {
+ indexedDB.deleteDatabase(dbName).onsuccess = resolve;
+ });
+ }
+ dump("removed indexedDB items from main page\n");
+};
+
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-file-url.html b/devtools/client/storage/test/storage-file-url.html
new file mode 100644
index 0000000000..1d10ab12b3
--- /dev/null
+++ b/devtools/client/storage/test/storage-file-url.html
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8" />
+ <title>Storage Test</title>
+ <script>
+ "use strict";
+ /* exported setup */
+ function setup() {
+ createIndexedDB();
+ createCookies();
+ createLocalStorage();
+ createSessionStorage();
+ }
+
+ function createIndexedDB() {
+ const open = indexedDB.open("MyDatabase", 1);
+
+ open.onupgradeneeded = function () {
+ const db = open.result;
+ db.createObjectStore("MyObjectStore", {keyPath: "id"});
+ };
+
+ open.onsuccess = function () {
+ const db = open.result;
+ const tx = db.transaction("MyObjectStore", "readwrite");
+ const store = tx.objectStore("MyObjectStore");
+
+ store.put({id: 12345, name: {first: "John", last: "Doe"}, age: 42});
+ store.put({id: 54321, name: {first: "Ralph", last: "Wood"}, age: 38});
+ store.put({id: 67890, name: {first: "Bob", last: "Smith"}, age: 35});
+ store.put({id: 98765, name: {first: "Freddie", last: "Krueger"}, age: 40});
+
+ tx.oncomplete = function () {
+ db.close();
+ };
+ };
+ }
+
+ function createCookies() {
+ document.cookie = "test1=Jean Dupond";
+ document.cookie = "test2=dnopuD naeJ";
+ }
+
+ function createLocalStorage() {
+ localStorage.setItem("test3", "John Doe");
+ localStorage.setItem("test4", "eoD nhoJ");
+ }
+
+ function createSessionStorage() {
+ sessionStorage.setItem("test5", "John Smith");
+ sessionStorage.setItem("test6", "htimS nhoJ");
+ }
+ </script>
+</head>
+<body>
+ <h1>IndexedDB Test</h1>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-idb-delete-blocked.html b/devtools/client/storage/test/storage-idb-delete-blocked.html
new file mode 100644
index 0000000000..7c7b597421
--- /dev/null
+++ b/devtools/client/storage/test/storage-idb-delete-blocked.html
@@ -0,0 +1,47 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for proper listing indexedDB databases with no object stores</title>
+</head>
+<body>
+<script type="application/javascript">
+"use strict";
+let db;
+
+window.setup = async function () {
+ db = await new Promise((resolve, reject) => {
+ const request = indexedDB.open("idb", 1);
+
+ request.onsuccess = e => resolve(e.target.result);
+ request.onerror = e => reject(new Error("error opening db connection"));
+ });
+
+ dump("opened indexedDB\n");
+};
+
+window.closeDb = function() {
+ db.close();
+};
+
+window.deleteDb = async function () {
+ await new Promise((resolve, reject) => {
+ const request = indexedDB.deleteDatabase("idb");
+
+ request.onsuccess = resolve;
+ request.onerror = e => reject(new Error("error deleting db"));
+ });
+};
+
+window.clear = async function () {
+ for (const dbName of ["idb1", "idb2"]) {
+ await new Promise(resolve => {
+ indexedDB.deleteDatabase(dbName).onsuccess = resolve;
+ });
+ }
+ dump("removed indexedDB items from main page\n");
+};
+
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-indexeddb-duplicate-names.html b/devtools/client/storage/test/storage-indexeddb-duplicate-names.html
new file mode 100644
index 0000000000..0f448f3727
--- /dev/null
+++ b/devtools/client/storage/test/storage-indexeddb-duplicate-names.html
@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html><head>
+<meta http-equiv="content-type" content="text/html; charset=UTF-8">
+ <meta charset="utf-8">
+ <title>Storage inspector IndexedDBs with duplicate names</title>
+
+ <script type="application/javascript">
+ "use strict";
+
+ /* exported setup */
+ function setup() {
+ createIndexedDB("idb1");
+ createIndexedDB("idb2");
+ }
+
+ function createIndexedDB(name) {
+ const open = indexedDB.open(name);
+
+ open.onsuccess = function () {
+ const db = open.result;
+ db.close();
+ };
+ }
+
+ function deleteDB(dbName) {
+ return new Promise(resolve => {
+ dump(`removing database ${dbName} from ${document.location}\n`);
+ indexedDB.deleteDatabase(dbName).onsuccess = resolve;
+ });
+ }
+
+ window.clear = async function () {
+ await deleteDB("idb1");
+ await deleteDB("idb2");
+
+ dump(`removed indexedDB data from ${document.location}\n`);
+ };
+ </script>
+</head>
+<body>
+ <h1>storage-indexeddb-duplicate-names.html</h1>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-indexeddb-iframe.html b/devtools/client/storage/test/storage-indexeddb-iframe.html
new file mode 100644
index 0000000000..8cf0071bd0
--- /dev/null
+++ b/devtools/client/storage/test/storage-indexeddb-iframe.html
@@ -0,0 +1,37 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector test for indexedDB - simple (alt)</title>
+</head>
+
+<body>
+ <h1>IndexedDB storage - with iframe</h1>
+ <iframe src="https://example.net/browser/devtools/client/storage/test/storage-indexeddb-simple.html"></iframe>
+
+<script>
+"use strict";
+
+const DB_NAME = "db";
+
+async function setup() { // eslint-disable-line no-unused-vars
+ await new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, 1);
+ request.onerror = event => reject(Error("Error opening DB"));
+ request.onupgradeneeded = event => {
+ const db = event.target.result;
+ const store = db.createObjectStore("store", { keyPath: "key" });
+ store.add({key: "foo", value: "bar"});
+ store.transaction.oncomplete = () => resolve(db);
+ }
+ });
+}
+
+async function clear() { // eslint-disable-line no-unused-vars
+ await new Promise(resolve => {
+ indexedDB.deleteDatabase(DB_NAME).onsuccess = resolve;
+ });
+}
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-indexeddb-simple-alt.html b/devtools/client/storage/test/storage-indexeddb-simple-alt.html
new file mode 100644
index 0000000000..0c5e56f795
--- /dev/null
+++ b/devtools/client/storage/test/storage-indexeddb-simple-alt.html
@@ -0,0 +1,38 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector test for indexedDB - simple (alt)</title>
+</head>
+
+<body>
+ <h1>IndexedDB storage - simple (alt)</h1>
+
+<script>
+"use strict";
+
+const DB_NAME = "db-alt";
+
+async function setup() { // eslint-disable-line no-unused-vars
+ await new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, 1);
+ request.onerror = event => reject(Error("Error opening DB"));
+ request.onupgradeneeded = event => {
+ const db = event.target.result;
+ const store = db.createObjectStore("store", { keyPath: "key" });
+ store.add({key: "foo", value: "bar"});
+ store.transaction.oncomplete = () => resolve(db);
+ }
+ });
+}
+
+async function clear() { // eslint-disable-line no-unused-vars
+ await new Promise((resolve, reject) => {
+ const request = indexedDB.deleteDatabase(DB_NAME);
+ request.onsuccess = resolve;
+ request.onerror = () => reject(Error("Error deleting DB"));
+ });
+}
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-indexeddb-simple.html b/devtools/client/storage/test/storage-indexeddb-simple.html
new file mode 100644
index 0000000000..9839240646
--- /dev/null
+++ b/devtools/client/storage/test/storage-indexeddb-simple.html
@@ -0,0 +1,38 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector test for indexedDB - simple</title>
+</head>
+
+<body>
+ <h1>IndexedDB storage - simple</h1>
+
+<script>
+"use strict";
+
+const DB_NAME = "db";
+
+async function setup() { // eslint-disable-line no-unused-vars
+ await new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, 1);
+ request.onerror = event => reject(Error("Error opening DB"));
+ request.onupgradeneeded = event => {
+ const db = event.target.result;
+ const store = db.createObjectStore("store", { keyPath: "key" });
+ store.add({key: "lorem", value: "ipsum"});
+ store.transaction.oncomplete = () => resolve(db);
+ }
+ });
+}
+
+async function clear() { // eslint-disable-line no-unused-vars
+ await new Promise((resolve, reject) => {
+ const request = indexedDB.deleteDatabase(DB_NAME)
+ request.onsuccess = resolve;
+ request.onerror = () => reject(Error("Error deleting DB"));
+ });
+}
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-listings-usercontextid.html b/devtools/client/storage/test/storage-listings-usercontextid.html
new file mode 100644
index 0000000000..d6eb6baf3c
--- /dev/null
+++ b/devtools/client/storage/test/storage-listings-usercontextid.html
@@ -0,0 +1,131 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Storage inspector front end for userContextId - tests
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector test for listing hosts and storages</title>
+</head>
+<body>
+<iframe src="http://sectest1.example.org/browser/devtools/client/storage/test/storage-unsecured-iframe-usercontextid.html"></iframe>
+<iframe src="https://sectest1.example.org:443/browser/devtools/client/storage/test/storage-secured-iframe-usercontextid.html"></iframe>
+<script type="application/javascript">
+"use strict";
+const partialHostname = location.hostname.match(/^[^.]+(\..*)$/)[1];
+const cookieExpiresTime1 = 2000000000000;
+const cookieExpiresTime2 = 2000000001000;
+// Setting up some cookies to eat.
+document.cookie = "c1uc1=foobar; expires=" +
+ new Date(cookieExpiresTime1).toGMTString() + "; path=/browser";
+document.cookie = "cs2uc1=sessionCookie; path=/; domain=" + partialHostname;
+document.cookie = "c3uc1=foobar-2; expires=" +
+ new Date(cookieExpiresTime2).toGMTString() + "; path=/";
+// ... and some local storage items ..
+localStorage.setItem("ls1uc1", "foobar");
+localStorage.setItem("ls2uc1", "foobar-2");
+// ... and finally some session storage items too
+sessionStorage.setItem("ss1uc1", "foobar-3");
+dump("added cookies and storage from main page\n");
+
+const idbGenerator = async function () {
+ let request = indexedDB.open("idb1uc1", 1);
+ request.onerror = function() {
+ throw new Error("error opening db connection");
+ };
+ const db = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const _db = event.target.result;
+ const store1 = _db.createObjectStore("obj1uc1", { keyPath: "id" });
+ store1.createIndex("name", "name", { unique: false });
+ store1.createIndex("email", "email", { unique: true });
+ _db.createObjectStore("obj2uc1", { keyPath: "id2" });
+ store1.transaction.oncomplete = () => {
+ done(_db);
+ };
+ };
+ });
+
+ // Prevents AbortError
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+
+ const transaction = db.transaction(["obj1uc1", "obj2uc1"], "readwrite");
+ const store1 = transaction.objectStore("obj1uc1");
+ const store2 = transaction.objectStore("obj2uc1");
+ store1.add({id: 1, name: "foo", email: "foo@bar.com"});
+ store1.add({id: 2, name: "foo2", email: "foo2@bar.com"});
+ store1.add({id: 3, name: "foo2", email: "foo3@bar.com"});
+ store2.add({
+ id2: 1,
+ name: "foo",
+ email: "foo@bar.com",
+ extra: "baz"
+ });
+ // Prevents AbortError during close()
+ await new Promise(success => {
+ transaction.oncomplete = success;
+ });
+
+ db.close();
+
+ request = indexedDB.open("idb2uc1", 1);
+ const db2 = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const _db2 = event.target.result;
+ const store3 = _db2.createObjectStore("obj3uc1", { keyPath: "id3" });
+ store3.createIndex("name2", "name2", { unique: true });
+ store3.transaction.oncomplete = () => {
+ done(_db2);
+ }
+ };
+ });
+ // Prevents AbortError during close()
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+ db2.close();
+
+ dump("added indexedDB from main page\n");
+};
+
+function deleteDB(dbName) {
+ return new Promise(resolve => {
+ dump("removing database " + dbName + " from " + document.location + "\n");
+ indexedDB.deleteDatabase(dbName).onsuccess = resolve;
+ });
+}
+
+async function fetchPut(cache, url) {
+ const response = await fetch(url);
+ await cache.put(url, response);
+}
+
+const cacheGenerator = async function () {
+ const cache = await caches.open("plopuc1");
+ await fetchPut(cache, "404_cached_file.js");
+ await fetchPut(cache, "browser_storage_basic.js");
+};
+
+window.setup = async function () {
+ await idbGenerator();
+
+ if (window.caches) {
+ await cacheGenerator();
+ }
+};
+
+window.clear = async function () {
+ await deleteDB("idb1uc1");
+ await deleteDB("idb2uc1");
+
+ if (window.caches) {
+ await caches.delete("plopuc1");
+ }
+
+ dump("removed indexedDB and cache data from " + document.location + "\n");
+};
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-listings-with-fragment.html b/devtools/client/storage/test/storage-listings-with-fragment.html
new file mode 100644
index 0000000000..2698f6ebac
--- /dev/null
+++ b/devtools/client/storage/test/storage-listings-with-fragment.html
@@ -0,0 +1,134 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+This test differs from browser_storage_listings.html only because the URLs we load
+include fragments e.g. http://example.com/test.js#abcdefg
+ ^^^^^^^^
+ fragment
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector test for listing hosts and storages with URL fragments</title>
+</head>
+<body>
+<iframe src="http://sectest1.example.org/browser/devtools/client/storage/test/storage-unsecured-iframe.html#def"></iframe>
+<iframe src="https://sectest1.example.org:443/browser/devtools/client/storage/test/storage-secured-iframe.html#ghi"></iframe>
+<script type="application/javascript">
+"use strict";
+const partialHostname = location.hostname.match(/^[^.]+(\..*)$/)[1];
+const cookieExpiresTime1 = 2000000000000;
+const cookieExpiresTime2 = 2000000001000;
+// Setting up some cookies to eat.
+document.cookie = "c1=foobar; expires=" +
+ new Date(cookieExpiresTime1).toGMTString() + "; path=/browser";
+document.cookie = "cs2=sessionCookie; path=/; domain=" + partialHostname;
+document.cookie = "c3=foobar-2; expires=" +
+ new Date(cookieExpiresTime2).toGMTString() + "; path=/";
+// ... and some local storage items ..
+localStorage.setItem("ls1", "foobar");
+localStorage.setItem("ls2", "foobar-2");
+// ... and finally some session storage items too
+sessionStorage.setItem("ss1", "foobar-3");
+dump("added cookies and storage from main page\n");
+
+const idbGenerator = async function () {
+ let request = indexedDB.open("idb1", 1);
+ request.onerror = function() {
+ throw new Error("error opening db connection");
+ };
+ const db = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const _db = event.target.result;
+ const store1 = _db.createObjectStore("obj1", { keyPath: "id" });
+ store1.createIndex("name", "name", { unique: false });
+ store1.createIndex("email", "email", { unique: true });
+ _db.createObjectStore("obj2", { keyPath: "id2" }); // eslint-disable-line no-unused-vars
+ store1.transaction.oncomplete = () => {
+ done(_db);
+ };
+ };
+ });
+
+ // Prevents AbortError
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+
+ const transaction = db.transaction(["obj1", "obj2"], "readwrite");
+ const store1 = transaction.objectStore("obj1");
+ const store2 = transaction.objectStore("obj2");
+ store1.add({id: 1, name: "foo", email: "foo@bar.com"});
+ store1.add({id: 2, name: "foo2", email: "foo2@bar.com"});
+ store1.add({id: 3, name: "foo2", email: "foo3@bar.com"});
+ store2.add({
+ id2: 1,
+ name: "foo",
+ email: "foo@bar.com",
+ extra: "baz"
+ });
+ // Prevents AbortError during close()
+ await new Promise(success => {
+ transaction.oncomplete = success;
+ });
+
+ db.close();
+
+ request = indexedDB.open("idb2", 1);
+ const db2 = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const _db2 = event.target.result;
+ const store3 = _db2.createObjectStore("obj3", { keyPath: "id3" });
+ store3.createIndex("name2", "name2", { unique: true });
+ store3.transaction.oncomplete = () => {
+ done(_db2);
+ }
+ };
+ });
+ // Prevents AbortError during close()
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+ db2.close();
+
+ dump("added indexedDB from main page\n");
+};
+
+function deleteDB(dbName) {
+ return new Promise(resolve => {
+ dump("removing database " + dbName + " from " + document.location + "\n");
+ indexedDB.deleteDatabase(dbName).onsuccess = resolve;
+ });
+}
+
+async function fetchPut(cache, url) {
+ const response = await fetch(url);
+ await cache.put(url, response);
+}
+
+const cacheGenerator = async function () {
+ const cache = await caches.open("plop");
+ await fetchPut(cache, "404_cached_file.js");
+ await fetchPut(cache, "browser_storage_basic.js");
+};
+
+window.setup = async function () {
+ await idbGenerator();
+
+ if (window.caches) {
+ await cacheGenerator();
+ }
+};
+
+window.clear = async function () {
+ await deleteDB("idb1");
+ await deleteDB("idb2");
+
+ if (window.caches) {
+ await caches.delete("plop");
+ }
+
+ dump("removed indexedDB and cache data from " + document.location + "\n");
+};
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-listings.html b/devtools/client/storage/test/storage-listings.html
new file mode 100644
index 0000000000..84ab005c50
--- /dev/null
+++ b/devtools/client/storage/test/storage-listings.html
@@ -0,0 +1,145 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Storage inspector front end - tests
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector test for listing hosts and storages</title>
+</head>
+<body>
+<iframe src="http://sectest1.example.org/browser/devtools/client/storage/test/storage-unsecured-iframe.html"></iframe>
+<iframe src="https://sectest1.example.org:443/browser/devtools/client/storage/test/storage-secured-iframe.html"></iframe>
+<script type="application/javascript">
+"use strict";
+const partialHostname = location.hostname.match(/^[^.]+(\..*)$/)[1];
+const cookieExpiresTime1 = 2000000000000;
+const cookieExpiresTime2 = 2000000001000;
+// Setting up some cookies to eat.
+document.cookie = "c1=foobar; expires=" +
+ new Date(cookieExpiresTime1).toGMTString() + "; path=/browser";
+document.cookie = "cs2=sessionCookie; path=/; domain=" + partialHostname;
+document.cookie = "c3=foobar-2; expires=" +
+ new Date(cookieExpiresTime1).toGMTString() + "; path=/";
+document.cookie = "c4=foobar-3; expires=" +
+ new Date(cookieExpiresTime2).toGMTString() + "; path=/; domain=" +
+ partialHostname;
+// ... and some local storage items ..
+localStorage.setItem("ls1", "foobar");
+localStorage.setItem("ls2", "foobar-2");
+
+// Because localStorage contains key() on the prototype and it can't be iterated
+// using object.keys() we check the the value "key" exists.
+// See bug 1451991 for details.
+localStorage.setItem("key", "value1");
+
+// ... and finally some session storage items too
+sessionStorage.setItem("ss1", "foobar-3");
+
+// Because sessionStorage contains key() on the prototype and it can't be
+// iterated using object.keys() we check the the value "key" exists.
+// See bug 1451991 for details.
+sessionStorage.setItem("key", "value2");
+
+dump("added cookies and storage from main page\n");
+
+const idbGenerator = async function () {
+ let request = indexedDB.open("idb1", 1);
+ request.onerror = function() {
+ throw new Error("error opening db connection");
+ };
+ const db = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const _db = event.target.result;
+ const store1 = _db.createObjectStore("obj1", { keyPath: "id" });
+ store1.createIndex("name", "name", { unique: false });
+ store1.createIndex("email", "email", { unique: true });
+ _db.createObjectStore("obj2", { keyPath: "id2" }); // eslint-disable-line no-unused-vars
+ store1.transaction.oncomplete = () => {
+ done(_db);
+ };
+ };
+ });
+
+ // Prevents AbortError
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+
+ const transaction = db.transaction(["obj1", "obj2"], "readwrite");
+ const store1 = transaction.objectStore("obj1");
+ const store2 = transaction.objectStore("obj2");
+ store1.add({id: 1, name: "foo", email: "foo@bar.com"});
+ store1.add({id: 2, name: "foo2", email: "foo2@bar.com"});
+ store1.add({id: 3, name: "foo2", email: "foo3@bar.com"});
+ store2.add({
+ id2: 1,
+ name: "foo",
+ email: "foo@bar.com",
+ extra: "baz"
+ });
+ // Prevents AbortError during close()
+ await new Promise(success => {
+ transaction.oncomplete = success;
+ });
+
+ db.close();
+
+ request = indexedDB.open("idb2", 1);
+ const db2 = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const _db2 = event.target.result;
+ const store3 = _db2.createObjectStore("obj3", { keyPath: "id3" });
+ store3.createIndex("name2", "name2", { unique: true });
+ store3.transaction.oncomplete = () => {
+ done(_db2);
+ }
+ };
+ });
+ // Prevents AbortError during close()
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+ db2.close();
+
+ dump("added indexedDB from main page\n");
+};
+
+function deleteDB(dbName) {
+ return new Promise(resolve => {
+ dump("removing database " + dbName + " from " + document.location + "\n");
+ indexedDB.deleteDatabase(dbName).onsuccess = resolve;
+ });
+}
+
+async function fetchPut(cache, url) {
+ const response = await fetch(url);
+ await cache.put(url, response);
+}
+
+const cacheGenerator = async function () {
+ const cache = await caches.open("plop");
+ await fetchPut(cache, "404_cached_file.js");
+ await fetchPut(cache, "browser_storage_basic.js");
+};
+
+window.setup = async function () {
+ await idbGenerator();
+
+ if (window.caches) {
+ await cacheGenerator();
+ }
+};
+
+window.clear = async function () {
+ await deleteDB("idb1");
+ await deleteDB("idb2");
+
+ if (window.caches) {
+ await caches.delete("plop");
+ }
+ dump("removed indexedDB and cache data from " + document.location + "\n");
+};
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-localstorage.html b/devtools/client/storage/test/storage-localstorage.html
new file mode 100644
index 0000000000..b22c7d42f6
--- /dev/null
+++ b/devtools/client/storage/test/storage-localstorage.html
@@ -0,0 +1,23 @@
+<!doctype html>
+<html>
+ <!--
+ Bug 1231155 - Storage inspector front end - tests
+ -->
+ <head>
+ <meta charset="utf-8" />
+ <title>Storage inspector localStorage test</title>
+ <script type="application/javascript">
+ "use strict";
+ /* exported setup */
+ function setup() {
+ localStorage.setItem("TestLS1", "ValueLS1");
+ localStorage.setItem("TestLS2", "ValueLS2");
+ localStorage.setItem("TestLS3", "ValueLS3");
+ localStorage.setItem("TestLS4", "ValueLS4");
+ localStorage.setItem("TestLS5", "ValueLS5");
+ }
+ </script>
+ </head>
+ <body>
+ </body>
+</html>
diff --git a/devtools/client/storage/test/storage-overflow-indexeddb.html b/devtools/client/storage/test/storage-overflow-indexeddb.html
new file mode 100644
index 0000000000..68bc6522b0
--- /dev/null
+++ b/devtools/client/storage/test/storage-overflow-indexeddb.html
@@ -0,0 +1,49 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1171903 - Storage Inspector endless scrolling
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector endless scrolling test</title>
+</head>
+<body>
+<script type="text/javascript">
+"use strict";
+
+window.setup = async function() {
+ await new Promise(resolve => {
+ const open = indexedDB.open("database", 1);
+ open.onupgradeneeded = function() {
+ const db = open.result;
+ const store = db.createObjectStore("store", {keyPath: "id"});
+ store.transaction.oncomplete = () => {
+ const transaction = db.transaction(["store"], "readwrite");
+ for (let i = 1; i < 150; i++) {
+ transaction.objectStore("store").add({id: i});
+ }
+
+ transaction.oncomplete = function() {
+ db.close();
+ resolve();
+ };
+ };
+ };
+ });
+};
+
+function deleteDB(dbName) {
+ return new Promise(resolve => {
+ indexedDB.deleteDatabase(dbName).onsuccess = resolve;
+ });
+}
+
+window.clear = async function() {
+ await deleteDB("database");
+
+ dump(`removed indexedDB data from ${document.location}\n`);
+};
+
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-overflow.html b/devtools/client/storage/test/storage-overflow.html
new file mode 100644
index 0000000000..c922a18e69
--- /dev/null
+++ b/devtools/client/storage/test/storage-overflow.html
@@ -0,0 +1,19 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1171903 - Storage Inspector endless scrolling
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector endless scrolling test</title>
+</head>
+<body>
+<script type="text/javascript">
+"use strict";
+
+for (let i = 1; i < 152; i++) {
+ localStorage.setItem(`item-${i}`, `value-${i}`);
+}
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-search.html b/devtools/client/storage/test/storage-search.html
new file mode 100644
index 0000000000..914138aa39
--- /dev/null
+++ b/devtools/client/storage/test/storage-search.html
@@ -0,0 +1,28 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1224115 - Storage Inspector table filtering
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector table filtering test</title>
+
+ <script type="text/javascript">
+ "use strict";
+
+ /* exported setup */
+ function setup() {
+ document.cookie = "01234=56789";
+ document.cookie = "ANIMAL=hOrSe";
+ document.cookie = "food=energy bar";
+ document.cookie = "FOO=bArBaz";
+ document.cookie = "money=##$$$**";
+ document.cookie = "sport=football";
+ document.cookie = "year=2016";
+ }
+ </script>
+
+</head>
+<body>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-secured-iframe-usercontextid.html b/devtools/client/storage/test/storage-secured-iframe-usercontextid.html
new file mode 100644
index 0000000000..c3958661d4
--- /dev/null
+++ b/devtools/client/storage/test/storage-secured-iframe-usercontextid.html
@@ -0,0 +1,91 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Iframe for testing multiple host detetion in storage actor
+-->
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+<script type="application/javascript">
+"use strict";
+document.cookie = "sc1uc1=foobar;";
+localStorage.setItem("iframe-s-ls1uc1", "foobar");
+sessionStorage.setItem("iframe-s-ss1uc1", "foobar-2");
+dump("added cookies and storage from secured iframe\n");
+
+const idbGenerator = async function () {
+ let request = indexedDB.open("idb-s1uc1", 1);
+ request.onerror = function() {
+ throw new Error("error opening db connection");
+ };
+ const db = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const _db = event.target.result;
+ const store1 = _db.createObjectStore("obj-s1uc1", { keyPath: "id" });
+ store1.transaction.oncomplete = () => {
+ done(_db);
+ };
+ };
+ });
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+
+ let transaction = db.transaction(["obj-s1uc1"], "readwrite");
+ const store1 = transaction.objectStore("obj-s1uc1");
+ store1.add({id: 6, name: "foo", email: "foo@bar.com"});
+ store1.add({id: 7, name: "foo2", email: "foo2@bar.com"});
+ await new Promise(success => {
+ transaction.oncomplete = success;
+ });
+
+ db.close();
+
+ request = indexedDB.open("idb-s2uc1", 1);
+ const db2 = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const _db2 = event.target.result;
+ const store3 =
+ _db2.createObjectStore("obj-s2uc1", { keyPath: "id3", autoIncrement: true });
+ store3.createIndex("name2", "name2", { unique: true });
+ store3.transaction.oncomplete = () => {
+ done(_db2);
+ };
+ };
+ });
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+
+ transaction = db2.transaction(["obj-s2uc1"], "readwrite");
+ const store3 = transaction.objectStore("obj-s2uc1");
+ store3.add({id3: 16, name2: "foo", email: "foo@bar.com"});
+ await new Promise(success => {
+ transaction.oncomplete = success;
+ });
+
+ db2.close();
+ dump("added indexedDB from secured iframe\n");
+};
+
+function deleteDB(dbName) {
+ return new Promise(resolve => {
+ dump("removing database " + dbName + " from " + document.location + "\n");
+ indexedDB.deleteDatabase(dbName).onsuccess = resolve;
+ });
+}
+
+window.setup = async function () {
+ await idbGenerator();
+};
+
+window.clear = async function () {
+ await deleteDB("idb-s1uc1");
+ await deleteDB("idb-s2uc1");
+
+ dump("removed indexedDB data from " + document.location + "\n");
+};
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-secured-iframe.html b/devtools/client/storage/test/storage-secured-iframe.html
new file mode 100644
index 0000000000..e0b7cc3716
--- /dev/null
+++ b/devtools/client/storage/test/storage-secured-iframe.html
@@ -0,0 +1,94 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Iframe for testing multiple host detetion in storage actor
+-->
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+<script type="application/javascript">
+"use strict";
+const cookieExpiresTime = 2000000000000;
+document.cookie = "sc1=foobar;";
+document.cookie = "sc2=foobar-2; expires=" +
+ new Date(cookieExpiresTime).toGMTString() + ";";
+localStorage.setItem("iframe-s-ls1", "foobar");
+sessionStorage.setItem("iframe-s-ss1", "foobar-2");
+dump("added cookies and storage from secured iframe\n");
+
+const idbGenerator = async function () {
+ let request = indexedDB.open("idb-s1", 1);
+ request.onerror = function() {
+ throw new Error("error opening db connection");
+ };
+ const db = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const _db = event.target.result;
+ const store1 = _db.createObjectStore("obj-s1", { keyPath: "id" });
+ store1.transaction.oncomplete = () => {
+ done(_db);
+ };
+ };
+ });
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+
+ let transaction = db.transaction(["obj-s1"], "readwrite");
+ const store1 = transaction.objectStore("obj-s1");
+ store1.add({id: 6, name: "foo", email: "foo@bar.com"});
+ store1.add({id: 7, name: "foo2", email: "foo2@bar.com"});
+ await new Promise(success => {
+ transaction.oncomplete = success;
+ });
+
+ db.close();
+
+ request = indexedDB.open("idb-s2", 1);
+ const db2 = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const _db2 = event.target.result;
+ const store3 =
+ _db2.createObjectStore("obj-s2", { keyPath: "id3", autoIncrement: true });
+ store3.createIndex("name2", "name2", { unique: true });
+ store3.transaction.oncomplete = () => {
+ done(_db2);
+ };
+ };
+ });
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+
+ transaction = db2.transaction(["obj-s2"], "readwrite");
+ const store3 = transaction.objectStore("obj-s2");
+ store3.add({id3: 16, name2: "foo", email: "foo@bar.com"});
+ await new Promise(success => {
+ transaction.oncomplete = success;
+ });
+
+ db2.close();
+ dump("added indexedDB from secured iframe\n");
+};
+
+function deleteDB(dbName) {
+ return new Promise(resolve => {
+ dump("removing database " + dbName + " from " + document.location + "\n");
+ indexedDB.deleteDatabase(dbName).onsuccess = resolve;
+ });
+}
+
+window.setup = async function () {
+ await idbGenerator();
+};
+
+window.clear = async function () {
+ await deleteDB("idb-s1");
+ await deleteDB("idb-s2");
+
+ dump("removed indexedDB data from " + document.location + "\n");
+};
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-sessionstorage.html b/devtools/client/storage/test/storage-sessionstorage.html
new file mode 100644
index 0000000000..2e0fb2f131
--- /dev/null
+++ b/devtools/client/storage/test/storage-sessionstorage.html
@@ -0,0 +1,23 @@
+<!doctype html>
+<html>
+ <!--
+ Bug 1231179 - Storage inspector front end - tests
+ -->
+ <head>
+ <meta charset="utf-8" />
+ <title>Storage inspector sessionStorage test</title>
+ <script type="application/javascript">
+ "use strict";
+ /* exported setup */
+ function setup() {
+ sessionStorage.setItem("TestSS1", "ValueSS1");
+ sessionStorage.setItem("TestSS2", "ValueSS2");
+ sessionStorage.setItem("TestSS3", "ValueSS3");
+ sessionStorage.setItem("TestSS4", "ValueSS4");
+ sessionStorage.setItem("TestSS5", "ValueSS5");
+ }
+ </script>
+ </head>
+ <body>
+ </body>
+</html>
diff --git a/devtools/client/storage/test/storage-sidebar-parsetree.html b/devtools/client/storage/test/storage-sidebar-parsetree.html
new file mode 100644
index 0000000000..47f669dd18
--- /dev/null
+++ b/devtools/client/storage/test/storage-sidebar-parsetree.html
@@ -0,0 +1,40 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Storage inspector sidebar parsetree test</title>
+ <script type="application/javascript">
+ "use strict";
+ /* exported setup */
+ function setup() {
+ // These values should not be parsed into a tree.
+ localStorage.setItem("base64", "aGVsbG93b3JsZA==");
+ localStorage.setItem("boolean", "true");
+ localStorage.setItem("color", "#ff0034");
+ localStorage.setItem("dataURI", "data:,Hello World!");
+ localStorage.setItem("date", "2009-05-19 14:39:22-01");
+ localStorage.setItem("email", "foo@bar.co.uk");
+ localStorage.setItem("FQDN", "xn--froschgrn-x9a.co.uk");
+ localStorage.setItem("IP", "192.168.1.1");
+ localStorage.setItem("MacAddress", "01:AB:03:04:05:06");
+ localStorage.setItem("maths", "9-1");
+ localStorage.setItem("numbers", "10,123,456");
+ localStorage.setItem("SemVer", "1.0.4");
+ localStorage.setItem("URL", "www.google.co.uk");
+ localStorage.setItem("URL2", "http://www.google.co.uk");
+
+ // These values should be parsed into a tree.
+ localStorage.setItem("ampersand", "a&b&c&d&e&f&g");
+ localStorage.setItem("asterisk", "a*b*c*d*e*f*g");
+ localStorage.setItem("colon", "a:b:c:d:e:f:g");
+ localStorage.setItem("comma", "a,b,c,d,e,f,g");
+ localStorage.setItem("equals", "a=b=c=d=e=f=g");
+ localStorage.setItem("hash", "a#b#c#d#e#f#g");
+ localStorage.setItem("period", "a.b.c.d.e.f.g");
+ localStorage.setItem("tilde", "a~b~c~d~e~f~g");
+ }
+ </script>
+ </head>
+ <body>
+ </body>
+</html>
diff --git a/devtools/client/storage/test/storage-unsecured-iframe-usercontextid.html b/devtools/client/storage/test/storage-unsecured-iframe-usercontextid.html
new file mode 100644
index 0000000000..c40ac8a761
--- /dev/null
+++ b/devtools/client/storage/test/storage-unsecured-iframe-usercontextid.html
@@ -0,0 +1,19 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Iframe for testing multiple host detetion in storage actor
+-->
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+<script>
+"use strict";
+document.cookie = "uc1uc1=foobar; domain=.example.org; path=/";
+localStorage.setItem("iframe-u-ls1uc1", "foobar");
+sessionStorage.setItem("iframe-u-ss1uc1", "foobar1");
+sessionStorage.setItem("iframe-u-ss2uc1", "foobar2");
+dump("added cookies and storage from unsecured iframe\n");
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-unsecured-iframe.html b/devtools/client/storage/test/storage-unsecured-iframe.html
new file mode 100644
index 0000000000..ad737dae0c
--- /dev/null
+++ b/devtools/client/storage/test/storage-unsecured-iframe.html
@@ -0,0 +1,22 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Iframe for testing multiple host detetion in storage actor
+-->
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+<script>
+"use strict";
+const cookieExpiresTime = 2000000000000;
+document.cookie = "uc1=foobar; domain=.example.org; path=/";
+document.cookie = "uc2=foobar-2; expires=" +
+ new Date(cookieExpiresTime).toGMTString() + "; path=/; domain=.example.org";
+localStorage.setItem("iframe-u-ls1", "foobar");
+sessionStorage.setItem("iframe-u-ss1", "foobar1");
+sessionStorage.setItem("iframe-u-ss2", "foobar2");
+dump("added cookies and storage from unsecured iframe\n");
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/test/storage-updates.html b/devtools/client/storage/test/storage-updates.html
new file mode 100644
index 0000000000..5fcbd10c4c
--- /dev/null
+++ b/devtools/client/storage/test/storage-updates.html
@@ -0,0 +1,68 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 965872 - Storage inspector actor with cookies, local storage and session storage.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector blank html for tests</title>
+</head>
+<body>
+<script type="application/javascript">
+"use strict";
+window.addCookie = function(name, value, path, domain, expires, secure) {
+ let cookieString = name + "=" + value + ";";
+ if (path) {
+ cookieString += "path=" + path + ";";
+ }
+ if (domain) {
+ cookieString += "domain=" + domain + ";";
+ }
+ if (expires) {
+ cookieString += "expires=" + expires + ";";
+ }
+ if (secure) {
+ cookieString += "secure=true;";
+ }
+ document.cookie = cookieString;
+};
+
+window.removeCookie = function(name, path) {
+ document.cookie =
+ name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=" + path;
+};
+
+/**
+ * We keep this method here even though these items are automatically cleared
+ * after the test is complete. this is so that the store-objects-cleared event
+ * can be tested.
+ */
+window.clear = function() {
+ localStorage.clear();
+ dump("removed localStorage from " + document.location + "\n");
+
+ sessionStorage.clear();
+ dump("removed sessionStorage from " + document.location + "\n");
+};
+
+window.onload = function() {
+ window.addCookie("c1", "1.2.3.4.5.6.7", "/browser");
+ window.addCookie("c2", "foobar", "/browser");
+
+ // Some keys have to be set to strings that JSON.parse can parse successfully
+ // instead of throwings (to verify the issue fixed by Bug 1578447 doesn't regress).
+ localStorage.setItem("1", "testing");
+ localStorage.setItem("2", "testing");
+ localStorage.setItem("3", "testing");
+ localStorage.setItem("4", "testing");
+ localStorage.setItem("5", "testing");
+ localStorage.setItem("null", "testing");
+ localStorage.setItem("non-json-parsable", "testing");
+
+ sessionStorage.setItem("ss1", "foobar");
+ sessionStorage.setItem("ss2", "foobar");
+ sessionStorage.setItem("ss3", "foobar");
+};
+</script>
+</body>
+</html>
diff --git a/devtools/client/storage/ui.js b/devtools/client/storage/ui.js
new file mode 100644
index 0000000000..c2359d8ac3
--- /dev/null
+++ b/devtools/client/storage/ui.js
@@ -0,0 +1,1769 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const { ELLIPSIS } = require("resource://devtools/shared/l10n.js");
+const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js");
+const {
+ parseItemValue,
+} = require("resource://devtools/shared/storage/utils.js");
+const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
+const {
+ getUnicodeHostname,
+} = require("resource://devtools/client/shared/unicode-url.js");
+const getStorageTypeURL = require("resource://devtools/client/storage/utils/doc-utils.js");
+
+// GUID to be used as a separator in compound keys. This must match the same
+// constant in devtools/server/actors/resources/storage/index.js,
+// devtools/client/storage/test/head.js and
+// devtools/server/tests/browser/head.js
+const SEPARATOR_GUID = "{9d414cc5-8319-0a04-0586-c0a6ae01670a}";
+
+loader.lazyRequireGetter(
+ this,
+ "TreeWidget",
+ "resource://devtools/client/shared/widgets/TreeWidget.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "TableWidget",
+ "resource://devtools/client/shared/widgets/TableWidget.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "debounce",
+ "resource://devtools/shared/debounce.js",
+ true
+);
+loader.lazyGetter(this, "standardSessionString", () => {
+ const l10n = new Localization(["devtools/client/storage.ftl"], true);
+ return l10n.formatValueSync("storage-expires-session");
+});
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ VariablesView: "resource://devtools/client/storage/VariablesView.sys.mjs",
+});
+
+const REASON = {
+ NEW_ROW: "new-row",
+ NEXT_50_ITEMS: "next-50-items",
+ POPULATE: "populate",
+ UPDATE: "update",
+};
+
+// How long we wait to debounce resize events
+const LAZY_RESIZE_INTERVAL_MS = 200;
+
+// Maximum length of item name to show in context menu label - will be
+// trimmed with ellipsis if it's longer.
+const ITEM_NAME_MAX_LENGTH = 32;
+
+const HEADERS_L10N_IDS = {
+ Cache: {
+ status: "storage-table-headers-cache-status",
+ },
+ cookies: {
+ creationTime: "storage-table-headers-cookies-creation-time",
+ expires: "storage-table-headers-cookies-expires",
+ lastAccessed: "storage-table-headers-cookies-last-accessed",
+ name: "storage-table-headers-cookies-name",
+ size: "storage-table-headers-cookies-size",
+ value: "storage-table-headers-cookies-value",
+ },
+ extensionStorage: {
+ area: "storage-table-headers-extension-storage-area",
+ },
+};
+
+// We only localize certain table headers. The headers that we do not localize
+// along with their label are stored in this dictionary for easy reference.
+const HEADERS_NON_L10N_STRINGS = {
+ Cache: {
+ url: "URL",
+ },
+ cookies: {
+ host: "Domain",
+ hostOnly: "HostOnly",
+ isHttpOnly: "HttpOnly",
+ isSecure: "Secure",
+ path: "Path",
+ sameSite: "SameSite",
+ uniqueKey: "Unique key",
+ },
+ extensionStorage: {
+ name: "Key",
+ value: "Value",
+ },
+ indexedDB: {
+ autoIncrement: "Auto Increment",
+ db: "Database Name",
+ indexes: "Indexes",
+ keyPath: "Key Path",
+ name: "Key",
+ objectStore: "Object Store Name",
+ objectStores: "Object Stores",
+ origin: "Origin",
+ storage: "Storage",
+ uniqueKey: "Unique key",
+ value: "Value",
+ version: "Version",
+ },
+ localStorage: {
+ name: "Key",
+ value: "Value",
+ },
+ sessionStorage: {
+ name: "Key",
+ value: "Value",
+ },
+};
+
+/**
+ * StorageUI is controls and builds the UI of the Storage Inspector.
+ *
+ * @param {Window} panelWin
+ * Window of the toolbox panel to populate UI in.
+ * @param {Object} commands
+ * The commands object with all interfaces defined from devtools/shared/commands/
+ */
+class StorageUI {
+ constructor(panelWin, toolbox, commands) {
+ EventEmitter.decorate(this);
+ this._window = panelWin;
+ this._panelDoc = panelWin.document;
+ this._toolbox = toolbox;
+ this._commands = commands;
+ this.sidebarToggledOpen = null;
+ this.shouldLoadMoreItems = true;
+
+ const treeNode = this._panelDoc.getElementById("storage-tree");
+ this.tree = new TreeWidget(treeNode, {
+ defaultType: "dir",
+ contextMenuId: "storage-tree-popup",
+ });
+ this.onHostSelect = this.onHostSelect.bind(this);
+ this.tree.on("select", this.onHostSelect);
+
+ const tableNode = this._panelDoc.getElementById("storage-table");
+ this.table = new TableWidget(tableNode, {
+ emptyText: "storage-table-empty-text",
+ highlightUpdated: true,
+ cellContextMenuId: "storage-table-popup",
+ l10n: this._panelDoc.l10n,
+ });
+
+ this.updateObjectSidebar = this.updateObjectSidebar.bind(this);
+ this.table.on(TableWidget.EVENTS.ROW_SELECTED, this.updateObjectSidebar);
+
+ this.handleScrollEnd = this.loadMoreItems.bind(this);
+ this.table.on(TableWidget.EVENTS.SCROLL_END, this.handleScrollEnd);
+
+ this.editItem = this.editItem.bind(this);
+ this.table.on(TableWidget.EVENTS.CELL_EDIT, this.editItem);
+
+ this.sidebar = this._panelDoc.getElementById("storage-sidebar");
+ this.sidebar.style.width = "300px";
+ this.view = new lazy.VariablesView(this.sidebar.firstChild, {
+ lazyEmpty: true,
+ // ms
+ lazyEmptyDelay: 10,
+ searchEnabled: true,
+ contextMenuId: "variable-view-popup",
+ preventDescriptorModifiers: true,
+ });
+
+ this.filterItems = this.filterItems.bind(this);
+ this.onPaneToggleButtonClicked = this.onPaneToggleButtonClicked.bind(this);
+ this.setupToolbar();
+
+ this.handleKeypress = this.handleKeypress.bind(this);
+ this._panelDoc.addEventListener("keypress", this.handleKeypress);
+
+ this.onTreePopupShowing = this.onTreePopupShowing.bind(this);
+ this._treePopup = this._panelDoc.getElementById("storage-tree-popup");
+ this._treePopup.addEventListener("popupshowing", this.onTreePopupShowing);
+
+ this.onTablePopupShowing = this.onTablePopupShowing.bind(this);
+ this._tablePopup = this._panelDoc.getElementById("storage-table-popup");
+ this._tablePopup.addEventListener("popupshowing", this.onTablePopupShowing);
+
+ this.onVariableViewPopupShowing =
+ this.onVariableViewPopupShowing.bind(this);
+ this._variableViewPopup = this._panelDoc.getElementById(
+ "variable-view-popup"
+ );
+ this._variableViewPopup.addEventListener(
+ "popupshowing",
+ this.onVariableViewPopupShowing
+ );
+
+ this.onRefreshTable = this.onRefreshTable.bind(this);
+ this.onAddItem = this.onAddItem.bind(this);
+ this.onCopyItem = this.onCopyItem.bind(this);
+ this.onPanelWindowResize = debounce(
+ this.#onLazyPanelResize,
+ LAZY_RESIZE_INTERVAL_MS,
+ this
+ );
+ this.onRemoveItem = this.onRemoveItem.bind(this);
+ this.onRemoveAllFrom = this.onRemoveAllFrom.bind(this);
+ this.onRemoveAll = this.onRemoveAll.bind(this);
+ this.onRemoveAllSessionCookies = this.onRemoveAllSessionCookies.bind(this);
+ this.onRemoveTreeItem = this.onRemoveTreeItem.bind(this);
+
+ this._refreshButton = this._panelDoc.getElementById("refresh-button");
+ this._refreshButton.addEventListener("click", this.onRefreshTable);
+
+ this._addButton = this._panelDoc.getElementById("add-button");
+ this._addButton.addEventListener("click", this.onAddItem);
+
+ this._window.addEventListener("resize", this.onPanelWindowResize, true);
+
+ this._variableViewPopupCopy = this._panelDoc.getElementById(
+ "variable-view-popup-copy"
+ );
+ this._variableViewPopupCopy.addEventListener("command", this.onCopyItem);
+
+ this._tablePopupAddItem = this._panelDoc.getElementById(
+ "storage-table-popup-add"
+ );
+ this._tablePopupAddItem.addEventListener("command", this.onAddItem);
+
+ this._tablePopupDelete = this._panelDoc.getElementById(
+ "storage-table-popup-delete"
+ );
+ this._tablePopupDelete.addEventListener("command", this.onRemoveItem);
+
+ this._tablePopupDeleteAllFrom = this._panelDoc.getElementById(
+ "storage-table-popup-delete-all-from"
+ );
+ this._tablePopupDeleteAllFrom.addEventListener(
+ "command",
+ this.onRemoveAllFrom
+ );
+
+ this._tablePopupDeleteAll = this._panelDoc.getElementById(
+ "storage-table-popup-delete-all"
+ );
+ this._tablePopupDeleteAll.addEventListener("command", this.onRemoveAll);
+
+ this._tablePopupDeleteAllSessionCookies = this._panelDoc.getElementById(
+ "storage-table-popup-delete-all-session-cookies"
+ );
+ this._tablePopupDeleteAllSessionCookies.addEventListener(
+ "command",
+ this.onRemoveAllSessionCookies
+ );
+
+ this._treePopupDeleteAll = this._panelDoc.getElementById(
+ "storage-tree-popup-delete-all"
+ );
+ this._treePopupDeleteAll.addEventListener("command", this.onRemoveAll);
+
+ this._treePopupDeleteAllSessionCookies = this._panelDoc.getElementById(
+ "storage-tree-popup-delete-all-session-cookies"
+ );
+ this._treePopupDeleteAllSessionCookies.addEventListener(
+ "command",
+ this.onRemoveAllSessionCookies
+ );
+
+ this._treePopupDelete = this._panelDoc.getElementById(
+ "storage-tree-popup-delete"
+ );
+ this._treePopupDelete.addEventListener("command", this.onRemoveTreeItem);
+ }
+
+ get currentTarget() {
+ return this._commands.targetCommand.targetFront;
+ }
+
+ async init() {
+ // This is a distionary of arrays, keyed by storage key
+ // - Keys are storage keys, available on each storage resource, via ${resource.resourceKey}
+ // and are typically "Cache", "cookies", "indexedDB", "localStorage", ...
+ // - Values are arrays of storage fronts. This isn't the deprecated global storage front (target.getFront(storage), only used by legacy listener),
+ // but rather the storage specific front, i.e. a storage resource. Storage resources are fronts.
+ this.storageResources = {};
+
+ await this._initL10NStringsMap();
+
+ // This can only be done after l10n strings were retrieved as we're using "storage-filter-key"
+ const shortcuts = new KeyShortcuts({
+ window: this._panelDoc.defaultView,
+ });
+ const key = this._l10nStrings.get("storage-filter-key");
+ shortcuts.on(key, event => {
+ event.preventDefault();
+ this.searchBox.focus();
+ });
+
+ this._onTargetAvailable = this._onTargetAvailable.bind(this);
+ this._onTargetDestroyed = this._onTargetDestroyed.bind(this);
+ await this._commands.targetCommand.watchTargets({
+ types: [this._commands.targetCommand.TYPES.FRAME],
+ onAvailable: this._onTargetAvailable,
+ onDestroyed: this._onTargetDestroyed,
+ });
+
+ this._onResourceListAvailable = this._onResourceListAvailable.bind(this);
+
+ const { resourceCommand } = this._toolbox;
+
+ this._listenedResourceTypes = [
+ // The first item in this list will be the first selected storage item
+ // Tests assume Cookie -- moving cookie will break tests
+ resourceCommand.TYPES.COOKIE,
+ resourceCommand.TYPES.CACHE_STORAGE,
+ resourceCommand.TYPES.INDEXED_DB,
+ resourceCommand.TYPES.LOCAL_STORAGE,
+ resourceCommand.TYPES.SESSION_STORAGE,
+ ];
+ // EXTENSION_STORAGE is only relevant when debugging web extensions
+ if (this._commands.descriptorFront.isWebExtensionDescriptor) {
+ this._listenedResourceTypes.push(resourceCommand.TYPES.EXTENSION_STORAGE);
+ }
+ await this._toolbox.resourceCommand.watchResources(
+ this._listenedResourceTypes,
+ {
+ onAvailable: this._onResourceListAvailable,
+ }
+ );
+ }
+
+ async _initL10NStringsMap() {
+ const ids = [
+ "storage-filter-key",
+ "storage-table-headers-cookies-name",
+ "storage-table-headers-cookies-value",
+ "storage-table-headers-cookies-expires",
+ "storage-table-headers-cookies-size",
+ "storage-table-headers-cookies-last-accessed",
+ "storage-table-headers-cookies-creation-time",
+ "storage-table-headers-cache-status",
+ "storage-table-headers-extension-storage-area",
+ "storage-tree-labels-cookies",
+ "storage-tree-labels-local-storage",
+ "storage-tree-labels-session-storage",
+ "storage-tree-labels-indexed-db",
+ "storage-tree-labels-cache",
+ "storage-tree-labels-extension-storage",
+ "storage-expires-session",
+ ];
+ const results = await this._panelDoc.l10n.formatValues(
+ ids.map(s => ({ id: s }))
+ );
+
+ this._l10nStrings = new Map(ids.map((id, i) => [id, results[i]]));
+ }
+
+ async _onResourceListAvailable(resources) {
+ for (const resource of resources) {
+ if (resource.isDestroyed()) {
+ continue;
+ }
+ const { resourceKey } = resource;
+
+ // NOTE: We might be getting more than 1 resource per storage type when
+ // we have remote frames in content process resources, so we need
+ // an array to store these.
+ if (!this.storageResources[resourceKey]) {
+ this.storageResources[resourceKey] = [];
+ }
+ this.storageResources[resourceKey].push(resource);
+
+ resource.on(
+ "single-store-update",
+ this._onStoreUpdate.bind(this, resource)
+ );
+ resource.on(
+ "single-store-cleared",
+ this._onStoreCleared.bind(this, resource)
+ );
+ }
+
+ try {
+ await this.populateStorageTree();
+ } catch (e) {
+ if (!this._toolbox || this._toolbox._destroyer) {
+ // The toolbox is in the process of being destroyed... in this case throwing here
+ // is expected and normal so let's ignore the error.
+ return;
+ }
+
+ // The toolbox is open so the error is unexpected and real so let's log it.
+ console.error(e);
+ }
+ }
+
+ // We only need to listen to target destruction, but TargetCommand.watchTarget
+ // requires a target available function...
+ async _onTargetAvailable({ targetFront }) {}
+
+ _onTargetDestroyed({ targetFront }) {
+ // Remove all storages related to this target
+ for (const type in this.storageResources) {
+ this.storageResources[type] = this.storageResources[type].filter(
+ storage => {
+ // Note that the storage front may already be destroyed,
+ // and have a null targetFront attribute. So also remove all already
+ // destroyed fronts.
+ return !storage.isDestroyed() && storage.targetFront != targetFront;
+ }
+ );
+ }
+
+ // Only support top level target and navigation to new processes.
+ // i.e. ignore additional targets created for remote <iframes>
+ if (!targetFront.isTopLevel) {
+ return;
+ }
+
+ this.storageResources = {};
+ this.table.clear();
+ this.hideSidebar();
+ this.tree.clear();
+ }
+
+ set animationsEnabled(value) {
+ this._panelDoc.documentElement.classList.toggle("no-animate", !value);
+ }
+
+ destroy() {
+ if (this._destroyed) {
+ return;
+ }
+ this._destroyed = true;
+
+ const { resourceCommand } = this._toolbox;
+ resourceCommand.unwatchResources(this._listenedResourceTypes, {
+ onAvailable: this._onResourceListAvailable,
+ });
+
+ this.table.off(TableWidget.EVENTS.ROW_SELECTED, this.updateObjectSidebar);
+ this.table.off(TableWidget.EVENTS.SCROLL_END, this.loadMoreItems);
+ this.table.off(TableWidget.EVENTS.CELL_EDIT, this.editItem);
+ this.table.destroy();
+
+ this._panelDoc.removeEventListener("keypress", this.handleKeypress);
+ this.searchBox.removeEventListener("input", this.filterItems);
+ this.searchBox = null;
+
+ this.sidebarToggleBtn.removeEventListener(
+ "click",
+ this.onPaneToggleButtonClicked
+ );
+ this.sidebarToggleBtn = null;
+
+ this._window.removeEventListener("resize", this.#onLazyPanelResize, true);
+
+ this._treePopup.removeEventListener(
+ "popupshowing",
+ this.onTreePopupShowing
+ );
+ this._refreshButton.removeEventListener("click", this.onRefreshTable);
+ this._addButton.removeEventListener("click", this.onAddItem);
+ this._tablePopupAddItem.removeEventListener("command", this.onAddItem);
+ this._treePopupDeleteAll.removeEventListener("command", this.onRemoveAll);
+ this._treePopupDeleteAllSessionCookies.removeEventListener(
+ "command",
+ this.onRemoveAllSessionCookies
+ );
+ this._treePopupDelete.removeEventListener("command", this.onRemoveTreeItem);
+
+ this._tablePopup.removeEventListener(
+ "popupshowing",
+ this.onTablePopupShowing
+ );
+ this._tablePopupDelete.removeEventListener("command", this.onRemoveItem);
+ this._tablePopupDeleteAllFrom.removeEventListener(
+ "command",
+ this.onRemoveAllFrom
+ );
+ this._tablePopupDeleteAll.removeEventListener("command", this.onRemoveAll);
+ this._tablePopupDeleteAllSessionCookies.removeEventListener(
+ "command",
+ this.onRemoveAllSessionCookies
+ );
+ }
+
+ setupToolbar() {
+ this.searchBox = this._panelDoc.getElementById("storage-searchbox");
+ this.searchBox.addEventListener("input", this.filterItems);
+
+ // Setup the sidebar toggle button.
+ this.sidebarToggleBtn = this._panelDoc.querySelector(".sidebar-toggle");
+ this.updateSidebarToggleButton();
+
+ this.sidebarToggleBtn.addEventListener(
+ "click",
+ this.onPaneToggleButtonClicked
+ );
+ }
+
+ onPaneToggleButtonClicked() {
+ if (this.sidebar.hidden && this.table.selectedRow) {
+ this.sidebar.hidden = false;
+ this.sidebarToggledOpen = true;
+ this.updateSidebarToggleButton();
+ } else {
+ this.sidebarToggledOpen = false;
+ this.hideSidebar();
+ }
+ }
+
+ updateSidebarToggleButton() {
+ let dataL10nId;
+ this.sidebarToggleBtn.hidden = !this.table.hasSelectedRow;
+
+ if (this.sidebar.hidden) {
+ this.sidebarToggleBtn.classList.add("pane-collapsed");
+ dataL10nId = "storage-expand-pane";
+ } else {
+ this.sidebarToggleBtn.classList.remove("pane-collapsed");
+ dataL10nId = "storage-collapse-pane";
+ }
+
+ this._panelDoc.l10n.setAttributes(this.sidebarToggleBtn, dataL10nId);
+ }
+
+ /**
+ * Hide the object viewer sidebar
+ */
+ hideSidebar() {
+ this.sidebar.hidden = true;
+ this.updateSidebarToggleButton();
+ }
+
+ getCurrentFront() {
+ const { datatype, host } = this.table;
+ return this._getStorage(datatype, host);
+ }
+
+ _getStorage(type, host) {
+ const storageType = this.storageResources[type];
+ return storageType.find(x => host in x.hosts);
+ }
+
+ /**
+ * Make column fields editable
+ *
+ * @param {Array} editableFields
+ * An array of keys of columns to be made editable
+ */
+ makeFieldsEditable(editableFields) {
+ if (editableFields && editableFields.length) {
+ this.table.makeFieldsEditable(editableFields);
+ } else if (this.table._editableFieldsEngine) {
+ this.table._editableFieldsEngine.destroy();
+ }
+ }
+
+ editItem(data) {
+ const selectedItem = this.tree.selectedItem;
+ if (!selectedItem) {
+ return;
+ }
+ const front = this.getCurrentFront();
+
+ front.editItem(data);
+ }
+
+ /**
+ * Removes the given item from the storage table. Reselects the next item in
+ * the table and repopulates the sidebar with that item's data if the item
+ * being removed was selected.
+ */
+ async removeItemFromTable(name) {
+ if (this.table.isSelected(name) && this.table.items.size > 1) {
+ if (this.table.selectedIndex == 0) {
+ this.table.selectNextRow();
+ } else {
+ this.table.selectPreviousRow();
+ }
+ }
+
+ this.table.remove(name);
+ await this.updateObjectSidebar();
+ }
+
+ /**
+ * Event handler for "stores-cleared" event coming from the storage actor.
+ *
+ * @param {object}
+ * An object containing which hosts/paths are cleared from a
+ * storage
+ */
+ _onStoreCleared(resource, { clearedHostsOrPaths }) {
+ const { resourceKey } = resource;
+ function* enumPaths() {
+ if (Array.isArray(clearedHostsOrPaths)) {
+ // Handle the legacy response with array of hosts
+ for (const host of clearedHostsOrPaths) {
+ yield [host];
+ }
+ } else {
+ // Handle the new format that supports clearing sub-stores in a host
+ for (const host in clearedHostsOrPaths) {
+ const paths = clearedHostsOrPaths[host];
+
+ if (!paths.length) {
+ yield [host];
+ } else {
+ for (let path of paths) {
+ try {
+ path = JSON.parse(path);
+ yield [host, ...path];
+ } catch (ex) {
+ // ignore
+ }
+ }
+ }
+ }
+ }
+ }
+
+ for (const path of enumPaths()) {
+ // Find if the path is selected (there is max one) and clear it
+ if (this.tree.isSelected([resourceKey, ...path])) {
+ this.table.clear();
+ this.hideSidebar();
+
+ // Reset itemOffset to 0 so that items added after local storate is
+ // cleared will be shown
+ this.itemOffset = 0;
+
+ this.emit("store-objects-cleared");
+ break;
+ }
+ }
+ }
+
+ /**
+ * Event handler for "stores-update" event coming from the storage actor.
+ *
+ * @param {object} argument0
+ * An object containing the details of the added, changed and deleted
+ * storage objects.
+ * Each of these 3 objects are of the following format:
+ * {
+ * <store_type1>: {
+ * <host1>: [<store_names1>, <store_name2>...],
+ * <host2>: [<store_names34>...], ...
+ * },
+ * <store_type2>: {
+ * <host1>: [<store_names1>, <store_name2>...],
+ * <host2>: [<store_names34>...], ...
+ * }, ...
+ * }
+ * Where store_type1 and store_type2 is one of cookies, indexedDB,
+ * sessionStorage and localStorage; host1, host2 are the host in which
+ * this change happened; and [<store_namesX] is an array of the names
+ * of the changed store objects. This array is empty for deleted object
+ * if the host was completely removed.
+ */
+ async _onStoreUpdate(resource, update) {
+ const { changed, added, deleted } = update;
+ if (added) {
+ await this.handleAddedItems(added);
+ }
+
+ if (changed) {
+ await this.handleChangedItems(changed);
+ }
+
+ // We are dealing with batches of changes here. Deleted **MUST** come last in case it
+ // is in the same batch as added and changed events e.g.
+ // - An item is changed then deleted in the same batch: deleted then changed will
+ // display an item that has been deleted.
+ // - An item is added then deleted in the same batch: deleted then added will
+ // display an item that has been deleted.
+ if (deleted) {
+ await this.handleDeletedItems(deleted);
+ }
+
+ if (added || deleted || changed) {
+ this.emit("store-objects-edit");
+ }
+ }
+
+ /**
+ * If the panel is resized we need to check if we should load the next batch of
+ * storage items.
+ */
+ async #onLazyPanelResize() {
+ // We can be called on a closed window or destroyed toolbox because of the
+ // deferred task.
+ if (this._window.closed || this._destroyed || this.table.hasScrollbar) {
+ return;
+ }
+
+ await this.loadMoreItems();
+ this.emit("storage-resize");
+ }
+
+ /**
+ * Get a string for a column name automatically choosing whether or not the
+ * string should be localized.
+ *
+ * @param {String} type
+ * The storage type.
+ * @param {String} name
+ * The field name that may need to be localized.
+ */
+ _getColumnName(type, name) {
+ // If the ID exists in HEADERS_NON_L10N_STRINGS then we do not translate it
+ const columnName = HEADERS_NON_L10N_STRINGS[type]?.[name];
+ if (columnName) {
+ return columnName;
+ }
+
+ // otherwise we get it from the L10N Map (populated during init)
+ const l10nId = HEADERS_L10N_IDS[type]?.[name];
+ if (l10nId && this._l10nStrings.has(l10nId)) {
+ return this._l10nStrings.get(l10nId);
+ }
+
+ // If the string isn't localized, we will just use the field name.
+ return name;
+ }
+
+ /**
+ * Handle added items received by onEdit
+ *
+ * @param {object} See onEdit docs
+ */
+ async handleAddedItems(added) {
+ for (const type in added) {
+ for (const host in added[type]) {
+ const label = this.getReadableLabelFromHostname(host);
+ this.tree.add([type, { id: host, label, type: "url" }]);
+ for (let name of added[type][host]) {
+ try {
+ name = JSON.parse(name);
+ if (name.length == 3) {
+ name.splice(2, 1);
+ }
+ this.tree.add([type, host, ...name]);
+ if (!this.tree.selectedItem) {
+ this.tree.selectedItem = [type, host, name[0], name[1]];
+ await this.fetchStorageObjects(
+ type,
+ host,
+ [JSON.stringify(name)],
+ REASON.NEW_ROW
+ );
+ }
+ } catch (ex) {
+ // Do nothing
+ }
+ }
+
+ if (this.tree.isSelected([type, host])) {
+ await this.fetchStorageObjects(
+ type,
+ host,
+ added[type][host],
+ REASON.NEW_ROW
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * Handle deleted items received by onEdit
+ *
+ * @param {object} See onEdit docs
+ */
+ async handleDeletedItems(deleted) {
+ for (const type in deleted) {
+ for (const host in deleted[type]) {
+ if (!deleted[type][host].length) {
+ // This means that the whole host is deleted, thus the item should
+ // be removed from the storage tree
+ if (this.tree.isSelected([type, host])) {
+ this.table.clear();
+ this.hideSidebar();
+ this.tree.selectPreviousItem();
+ }
+
+ this.tree.remove([type, host]);
+ } else {
+ for (const name of deleted[type][host]) {
+ try {
+ if (["indexedDB", "Cache"].includes(type)) {
+ // For indexedDB and Cache, the key is being parsed because
+ // these storages are represented as a tree and the key
+ // used to notify their changes is not a simple string.
+ const names = JSON.parse(name);
+ // Is a whole cache, database or objectstore deleted?
+ // Then remove it from the tree.
+ if (names.length < 3) {
+ if (this.tree.isSelected([type, host, ...names])) {
+ this.table.clear();
+ this.hideSidebar();
+ this.tree.selectPreviousItem();
+ }
+ this.tree.remove([type, host, ...names]);
+ }
+
+ // Remove the item from table if currently displayed.
+ if (names.length) {
+ const tableItemName = names.pop();
+ if (this.tree.isSelected([type, host, ...names])) {
+ await this.removeItemFromTable(tableItemName);
+ }
+ }
+ } else if (this.tree.isSelected([type, host])) {
+ // For all the other storage types with a simple string key,
+ // remove the item from the table by name without any parsing.
+ await this.removeItemFromTable(name);
+ }
+ } catch (ex) {
+ if (this.tree.isSelected([type, host])) {
+ await this.removeItemFromTable(name);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Handle changed items received by onEdit
+ *
+ * @param {object} See onEdit docs
+ */
+ async handleChangedItems(changed) {
+ const selectedItem = this.tree.selectedItem;
+ if (!selectedItem) {
+ return;
+ }
+
+ const [type, host, db, objectStore] = selectedItem;
+ if (!changed[type] || !changed[type][host] || !changed[type][host].length) {
+ return;
+ }
+ try {
+ const toUpdate = [];
+ for (const name of changed[type][host]) {
+ if (["indexedDB", "Cache"].includes(type)) {
+ // For indexedDB and Cache, the key is being parsed because
+ // these storage are represented as a tree and the key
+ // used to notify their changes is not a simple string.
+ const names = JSON.parse(name);
+ if (names[0] == db && names[1] == objectStore && names[2]) {
+ toUpdate.push(name);
+ }
+ } else {
+ // For all the other storage types with a simple string key,
+ // update the item from the table by name without any parsing.
+ toUpdate.push(name);
+ }
+ }
+ await this.fetchStorageObjects(type, host, toUpdate, REASON.UPDATE);
+ } catch (ex) {
+ await this.fetchStorageObjects(
+ type,
+ host,
+ changed[type][host],
+ REASON.UPDATE
+ );
+ }
+ }
+
+ /**
+ * Fetches the storage objects from the storage actor and populates the
+ * storage table with the returned data.
+ *
+ * @param {string} type
+ * The type of storage. Ex. "cookies"
+ * @param {string} host
+ * Hostname
+ * @param {array} names
+ * Names of particular store objects. Empty if all are requested
+ * @param {Constant} reason
+ * See REASON constant at top of file.
+ */
+ async fetchStorageObjects(type, host, names, reason) {
+ const fetchOpts =
+ reason === REASON.NEXT_50_ITEMS ? { offset: this.itemOffset } : {};
+ fetchOpts.sessionString = standardSessionString;
+ const storage = this._getStorage(type, host);
+ this.sidebarToggledOpen = null;
+
+ if (
+ reason !== REASON.NEXT_50_ITEMS &&
+ reason !== REASON.UPDATE &&
+ reason !== REASON.NEW_ROW &&
+ reason !== REASON.POPULATE
+ ) {
+ throw new Error("Invalid reason specified");
+ }
+
+ try {
+ if (
+ reason === REASON.POPULATE ||
+ (reason === REASON.NEW_ROW && this.table.items.size === 0)
+ ) {
+ let subType = null;
+ // The indexedDB type could have sub-type data to fetch.
+ // If having names specified, then it means
+ // we are fetching details of specific database or of object store.
+ if (type === "indexedDB" && names) {
+ const [dbName, objectStoreName] = JSON.parse(names[0]);
+ if (dbName) {
+ subType = "database";
+ }
+ if (objectStoreName) {
+ subType = "object store";
+ }
+ }
+
+ await this.resetColumns(type, host, subType);
+ }
+
+ const { data, total } = await storage.getStoreObjects(
+ host,
+ names,
+ fetchOpts
+ );
+ if (data.length) {
+ await this.populateTable(data, reason, total);
+ } else if (reason === REASON.POPULATE) {
+ await this.clearHeaders();
+ }
+ this.updateToolbar();
+ this.emit("store-objects-updated");
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ supportsAddItem(type, host) {
+ const storage = this._getStorage(type, host);
+ return storage?.traits.supportsAddItem || false;
+ }
+
+ supportsRemoveItem(type, host) {
+ const storage = this._getStorage(type, host);
+ return storage?.traits.supportsRemoveItem || false;
+ }
+
+ supportsRemoveAll(type, host) {
+ const storage = this._getStorage(type, host);
+ return storage?.traits.supportsRemoveAll || false;
+ }
+
+ supportsRemoveAllSessionCookies(type, host) {
+ const storage = this._getStorage(type, host);
+ return storage?.traits.supportsRemoveAllSessionCookies || false;
+ }
+
+ /**
+ * Updates the toolbar hiding and showing buttons as appropriate.
+ */
+ updateToolbar() {
+ const item = this.tree.selectedItem;
+ if (!item) {
+ return;
+ }
+
+ const [type, host] = item;
+
+ // Add is only supported if the selected item has a host.
+ this._addButton.hidden = !host || !this.supportsAddItem(type, host);
+ }
+
+ /**
+ * Populates the storage tree which displays the list of storages present for
+ * the page.
+ */
+ async populateStorageTree() {
+ const populateTreeFromResource = (type, resource) => {
+ for (const host in resource.hosts) {
+ const label = this.getReadableLabelFromHostname(host);
+ this.tree.add([type, { id: host, label, type: "url" }]);
+ for (const name of resource.hosts[host]) {
+ try {
+ const names = JSON.parse(name);
+ this.tree.add([type, host, ...names]);
+ if (!this.tree.selectedItem) {
+ this.tree.selectedItem = [type, host, names[0], names[1]];
+ }
+ } catch (ex) {
+ // Do Nothing
+ }
+ }
+ if (!this.tree.selectedItem) {
+ this.tree.selectedItem = [type, host];
+ }
+ }
+ };
+
+ // When can we expect the "store-objects-updated" event?
+ // -> TreeWidget setter `selectedItem` emits a "select" event
+ // -> on tree "select" event, this module calls `onHostSelect`
+ // -> finally `onHostSelect` calls `fetchStorageObjects`, which will emit
+ // "store-objects-updated" at the end of the method.
+ // So if the selection changed, we can wait for "store-objects-updated",
+ // which is emitted at the end of `fetchStorageObjects`.
+ const onStoresObjectsUpdated = this.once("store-objects-updated");
+
+ // Save the initially selected item to check if tree.selected was updated,
+ // see comment above.
+ const initialSelectedItem = this.tree.selectedItem;
+
+ for (const [type, resources] of Object.entries(this.storageResources)) {
+ let typeLabel = type;
+ try {
+ typeLabel = this.getStorageTypeLabel(type);
+ } catch (e) {
+ console.error("Unable to localize tree label type:" + type);
+ }
+
+ this.tree.add([{ id: type, label: typeLabel, type: "store" }]);
+
+ // storageResources values are arrays, with storage resources.
+ // we may have many storage resources per type if we get remote iframes.
+ for (const resource of resources) {
+ populateTreeFromResource(type, resource);
+ }
+ }
+
+ if (initialSelectedItem !== this.tree.selectedItem) {
+ await onStoresObjectsUpdated;
+ }
+ }
+
+ getStorageTypeLabel(type) {
+ let dataL10nId;
+
+ switch (type) {
+ case "cookies":
+ dataL10nId = "storage-tree-labels-cookies";
+ break;
+ case "localStorage":
+ dataL10nId = "storage-tree-labels-local-storage";
+ break;
+ case "sessionStorage":
+ dataL10nId = "storage-tree-labels-session-storage";
+ break;
+ case "indexedDB":
+ dataL10nId = "storage-tree-labels-indexed-db";
+ break;
+ case "Cache":
+ dataL10nId = "storage-tree-labels-cache";
+ break;
+ case "extensionStorage":
+ dataL10nId = "storage-tree-labels-extension-storage";
+ break;
+ default:
+ throw new Error("Unknown storage type");
+ }
+
+ return this._l10nStrings.get(dataL10nId);
+ }
+
+ /**
+ * Populates the selected entry from the table in the sidebar for a more
+ * detailed view.
+ */
+ /* eslint-disable-next-line */
+ async updateObjectSidebar() {
+ const item = this.table.selectedRow;
+ let value;
+
+ // Get the string value (async action) and the update the UI synchronously.
+ if ((item?.name || item?.name === "") && item?.valueActor) {
+ value = await item.valueActor.string();
+ }
+
+ // Bail if the selectedRow is no longer selected, the item doesn't exist or the state
+ // changed in another way during the above yield.
+ if (
+ this.table.items.size === 0 ||
+ !item ||
+ !this.table.selectedRow ||
+ item.uniqueKey !== this.table.selectedRow.uniqueKey
+ ) {
+ this.hideSidebar();
+ return;
+ }
+
+ // Start updating the UI. Everything is sync beyond this point.
+ if (this.sidebarToggledOpen === null || this.sidebarToggledOpen === true) {
+ this.sidebar.hidden = false;
+ }
+
+ this.updateSidebarToggleButton();
+ this.view.empty();
+ const mainScope = this.view.addScope("storage-data");
+ mainScope.expanded = true;
+
+ if (value) {
+ const itemVar = mainScope.addItem(item.name + "", {}, { relaxed: true });
+
+ // The main area where the value will be displayed
+ itemVar.setGrip(value);
+
+ // May be the item value is a json or a key value pair itself
+ const obj = parseItemValue(value);
+ if (typeof obj === "object") {
+ this.populateSidebar(item.name, obj);
+ }
+
+ // By default the item name and value are shown. If this is the only
+ // information available, then nothing else is to be displayed.
+ const itemProps = Object.keys(item);
+ if (itemProps.length > 3) {
+ // Display any other information other than the item name and value
+ // which may be available.
+ const rawObject = Object.create(null);
+ const otherProps = itemProps.filter(
+ e => !["name", "value", "valueActor"].includes(e)
+ );
+ for (const prop of otherProps) {
+ const column = this.table.columns.get(prop);
+ if (column?.private) {
+ continue;
+ }
+
+ const fieldName = this._getColumnName(this.table.datatype, prop);
+ rawObject[fieldName] = item[prop];
+ }
+ itemVar.populate(rawObject, { sorted: true });
+ itemVar.twisty = true;
+ itemVar.expanded = true;
+ }
+ } else {
+ // Case when displaying IndexedDB db/object store properties.
+ for (const key in item) {
+ const column = this.table.columns.get(key);
+ if (column?.private) {
+ continue;
+ }
+
+ mainScope.addItem(key, {}, true).setGrip(item[key]);
+ const obj = parseItemValue(item[key]);
+ if (typeof obj === "object") {
+ this.populateSidebar(item.name, obj);
+ }
+ }
+ }
+
+ this.emit("sidebar-updated");
+ }
+
+ /**
+ * Gets a readable label from the hostname. If the hostname is a Punycode
+ * domain(I.e. an ASCII domain name representing a Unicode domain name), then
+ * this function decodes it to the readable Unicode domain name, and label
+ * the Unicode domain name toggether with the original domian name, and then
+ * return the label; if the hostname isn't a Punycode domain(I.e. it isn't
+ * encoded and is readable on its own), then this function simply returns the
+ * original hostname.
+ *
+ * @param {string} host
+ * The string representing a host, e.g, example.com, example.com:8000
+ */
+ getReadableLabelFromHostname(host) {
+ try {
+ const { hostname } = new URL(host);
+ const unicodeHostname = getUnicodeHostname(hostname);
+ if (hostname !== unicodeHostname) {
+ // If the hostname is a Punycode domain representing a Unicode domain,
+ // we decode it to the Unicode domain name, and then label the Unicode
+ // domain name together with the original domain name.
+ return host.replace(hostname, unicodeHostname) + " [ " + host + " ]";
+ }
+ } catch (_) {
+ // Skip decoding for a host which doesn't include a domain name, simply
+ // consider them to be readable.
+ }
+ return host;
+ }
+
+ /**
+ * Populates the sidebar with a parsed object.
+ *
+ * @param {object} obj - Either a json or a key-value separated object or a
+ * key separated array
+ */
+ populateSidebar(name, obj) {
+ const jsonObject = Object.create(null);
+ const view = this.view;
+ jsonObject[name] = obj;
+ const valueScope =
+ view.getScopeAtIndex(1) || view.addScope("storage-parsed-value");
+ valueScope.expanded = true;
+ const jsonVar = valueScope.addItem("", Object.create(null), {
+ relaxed: true,
+ });
+ jsonVar.expanded = true;
+ jsonVar.twisty = true;
+ jsonVar.populate(jsonObject, { expanded: true });
+ }
+
+ /**
+ * Select handler for the storage tree. Fetches details of the selected item
+ * from the storage details and populates the storage tree.
+ *
+ * @param {array} item
+ * An array of ids which represent the location of the selected item in
+ * the storage tree
+ */
+ async onHostSelect(item) {
+ if (!item) {
+ return;
+ }
+
+ this.table.clear();
+ this.hideSidebar();
+ this.searchBox.value = "";
+
+ const [type, host] = item;
+ this.table.host = host;
+ this.table.datatype = type;
+
+ this.updateToolbar();
+
+ let names = null;
+ if (!host) {
+ let storageTypeHintL10nId = "";
+ switch (type) {
+ case "Cache":
+ storageTypeHintL10nId = "storage-table-type-cache-hint";
+ break;
+ case "cookies":
+ storageTypeHintL10nId = "storage-table-type-cookies-hint";
+ break;
+ case "extensionStorage":
+ storageTypeHintL10nId = "storage-table-type-extensionstorage-hint";
+ break;
+ case "localStorage":
+ storageTypeHintL10nId = "storage-table-type-localstorage-hint";
+ break;
+ case "indexedDB":
+ storageTypeHintL10nId = "storage-table-type-indexeddb-hint";
+ break;
+ case "sessionStorage":
+ storageTypeHintL10nId = "storage-table-type-sessionstorage-hint";
+ break;
+ }
+ this.table.setPlaceholder(
+ storageTypeHintL10nId,
+ getStorageTypeURL(this.table.datatype)
+ );
+
+ // If selected item has no host then reset table headers
+ await this.clearHeaders();
+ return;
+ }
+ if (item.length > 2) {
+ names = [JSON.stringify(item.slice(2))];
+ }
+
+ this.itemOffset = 0;
+ await this.fetchStorageObjects(type, host, names, REASON.POPULATE);
+ }
+
+ /**
+ * Clear the column headers in the storage table
+ */
+ async clearHeaders() {
+ this.table.setColumns({}, null, {}, {});
+ }
+
+ /**
+ * Resets the column headers in the storage table with the pased object `data`
+ *
+ * @param {string} type
+ * The type of storage corresponding to the after-reset columns in the
+ * table.
+ * @param {string} host
+ * The host name corresponding to the table after reset.
+ *
+ * @param {string} [subType]
+ * The sub type under the given type.
+ */
+ async resetColumns(type, host, subtype) {
+ this.table.host = host;
+ this.table.datatype = type;
+
+ let uniqueKey = null;
+ const columns = {};
+ const editableFields = [];
+ const hiddenFields = [];
+ const privateFields = [];
+ const fields = await this.getCurrentFront().getFields(subtype);
+
+ fields.forEach(f => {
+ if (!uniqueKey) {
+ this.table.uniqueId = uniqueKey = f.name;
+ }
+
+ if (f.editable) {
+ editableFields.push(f.name);
+ }
+
+ if (f.hidden) {
+ hiddenFields.push(f.name);
+ }
+
+ if (f.private) {
+ privateFields.push(f.name);
+ }
+
+ const columnName = this._getColumnName(type, f.name);
+ if (columnName) {
+ columns[f.name] = columnName;
+ } else if (!f.private) {
+ // Private fields are only displayed when running tests so there is no
+ // need to log an error if they are not localized.
+ columns[f.name] = f.name;
+ console.error(
+ `No string defined in HEADERS_NON_L10N_STRINGS for '${type}.${f.name}'`
+ );
+ }
+ });
+
+ this.table.setColumns(columns, null, hiddenFields, privateFields);
+ this.hideSidebar();
+
+ this.makeFieldsEditable(editableFields);
+ }
+
+ /**
+ * Populates or updates the rows in the storage table.
+ *
+ * @param {array[object]} data
+ * Array of objects to be populated in the storage table
+ * @param {Constant} reason
+ * See REASON constant at top of file.
+ * @param {number} totalAvailable
+ * The total number of items available in the current storage type.
+ */
+ async populateTable(data, reason, totalAvailable) {
+ for (const item of data) {
+ if (item.value) {
+ item.valueActor = item.value;
+ item.value = item.value.initial || "";
+ }
+ if (item.expires != null) {
+ item.expires = item.expires
+ ? new Date(item.expires).toUTCString()
+ : this._l10nStrings.get("storage-expires-session");
+ }
+ if (item.creationTime != null) {
+ item.creationTime = new Date(item.creationTime).toUTCString();
+ }
+ if (item.lastAccessed != null) {
+ item.lastAccessed = new Date(item.lastAccessed).toUTCString();
+ }
+
+ switch (reason) {
+ case REASON.POPULATE:
+ case REASON.NEXT_50_ITEMS:
+ // Update without flashing the row.
+ this.table.push(item, true);
+ break;
+ case REASON.NEW_ROW:
+ // Update and flash the row.
+ this.table.push(item, false);
+ break;
+ case REASON.UPDATE:
+ this.table.update(item);
+ if (item == this.table.selectedRow && !this.sidebar.hidden) {
+ await this.updateObjectSidebar();
+ }
+ break;
+ }
+
+ this.shouldLoadMoreItems = true;
+ }
+
+ if (
+ (reason === REASON.POPULATE || reason === REASON.NEXT_50_ITEMS) &&
+ this.table.items.size < totalAvailable &&
+ !this.table.hasScrollbar
+ ) {
+ await this.loadMoreItems();
+ }
+ }
+
+ /**
+ * Handles keypress event on the body table to close the sidebar when open
+ *
+ * @param {DOMEvent} event
+ * The event passed by the keypress event.
+ */
+ handleKeypress(event) {
+ if (event.keyCode == KeyCodes.DOM_VK_ESCAPE) {
+ if (!this.sidebar.hidden) {
+ this.hideSidebar();
+ this.sidebarToggledOpen = false;
+ // Stop Propagation to prevent opening up of split console
+ event.stopPropagation();
+ event.preventDefault();
+ }
+ } else if (
+ event.keyCode == KeyCodes.DOM_VK_BACK_SPACE ||
+ event.keyCode == KeyCodes.DOM_VK_DELETE
+ ) {
+ if (this.table.selectedRow && event.target.localName != "input") {
+ this.onRemoveItem(event);
+ event.stopPropagation();
+ event.preventDefault();
+ }
+ }
+ }
+
+ /**
+ * Handles filtering the table
+ */
+ filterItems() {
+ const value = this.searchBox.value;
+ this.table.filterItems(value, ["valueActor"]);
+ this._panelDoc.documentElement.classList.toggle("filtering", !!value);
+ }
+
+ /**
+ * Load the next batch of 50 items
+ */
+ async loadMoreItems() {
+ if (
+ !this.shouldLoadMoreItems ||
+ this._toolbox.currentToolId !== "storage" ||
+ !this.tree.selectedItem
+ ) {
+ return;
+ }
+ this.shouldLoadMoreItems = false;
+ this.itemOffset += 50;
+
+ const item = this.tree.selectedItem;
+ const [type, host] = item;
+ let names = null;
+ if (item.length > 2) {
+ names = [JSON.stringify(item.slice(2))];
+ }
+ await this.fetchStorageObjects(type, host, names, REASON.NEXT_50_ITEMS);
+ }
+
+ /**
+ * Fires before a cell context menu with the "Add" or "Delete" action is
+ * shown. If the currently selected storage object doesn't support adding or
+ * removing items, prevent showing the menu.
+ */
+ onTablePopupShowing(event) {
+ const selectedItem = this.tree.selectedItem;
+ const [type, host] = selectedItem;
+
+ // IndexedDB only supports removing items from object stores (level 4 of the tree)
+ if (
+ (!this.supportsAddItem(type, host) &&
+ !this.supportsRemoveItem(type, host)) ||
+ (type === "indexedDB" && selectedItem.length !== 4)
+ ) {
+ event.preventDefault();
+ return;
+ }
+
+ const rowId = this.table.contextMenuRowId;
+ const data = this.table.items.get(rowId);
+
+ if (this.supportsRemoveItem(type, host)) {
+ const name = data[this.table.uniqueId];
+ const separatorRegex = new RegExp(SEPARATOR_GUID, "g");
+ const label = addEllipsis((name + "").replace(separatorRegex, "-"));
+
+ this._tablePopupDelete.setAttribute(
+ "data-l10n-args",
+ JSON.stringify({
+ itemName: label,
+ })
+ );
+ this._tablePopupDelete.hidden = false;
+ } else {
+ this._tablePopupDelete.hidden = true;
+ }
+
+ if (this.supportsAddItem(type, host)) {
+ this._tablePopupAddItem.hidden = false;
+ } else {
+ this._tablePopupAddItem.hidden = true;
+ }
+
+ 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._tablePopupDeleteAllFrom.setAttribute(
+ "data-l10n-args",
+ JSON.stringify({
+ 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._treePopupDelete.setAttribute(
+ "data-l10n-args",
+ JSON.stringify({ 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",
+)