/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.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 ../../framework/test/shared-head.js */ // shared-head.js handles imports, constants, and utility functions Services.scriptloader.loadSubScript( "chrome://mochitests/content/browser/devtools/client/framework/test/shared-head.js", this); const {TableWidget} = require("devtools/client/shared/widgets/TableWidget"); const SPLIT_CONSOLE_PREF = "devtools.toolbox.splitconsoleEnabled"; 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_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/storage.js, // devtools/client/storage/ui.js and devtools/server/tests/browser/head.js const SEPARATOR_GUID = "{9d414cc5-8319-0a04-0586-c0a6ae01670a}"; var gToolbox, gPanelWindow, gWindow, 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 = gWindow = 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(SPLIT_CONSOLE_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. 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 * * @return {Promise} A promise that resolves after storage inspector is ready */ function* openTabAndSetupStorage(url) { let tab = yield addTab(url); let content = tab.linkedBrowser.contentWindow; gWindow = content.wrappedJSObject; // Setup the async storages in main window and for all its iframes yield ContentTask.spawn(gBrowser.selectedBrowser, null, function* () { /** * Get all windows including frames recursively. * * @param {Window} [baseWindow] * The base window at which to start looking for child windows * (optional). * @return {Set} * A set of windows. */ function getAllWindows(baseWindow) { let windows = new Set(); let _getAllWindows = function (win) { windows.add(win.wrappedJSObject); for (let i = 0; i < win.length; i++) { _getAllWindows(win[i]); } }; _getAllWindows(baseWindow); return windows; } let windows = getAllWindows(content); for (let win of windows) { let readyState = win.document.readyState; info(`Found a window: ${readyState}`); if (readyState != "complete") { yield new Promise(resolve => { let onLoad = () => { win.removeEventListener("load", onLoad); resolve(); }; win.addEventListener("load", onLoad); }); } if (win.setup) { yield win.setup(); } } }); // open storage inspector return yield openStoragePanel(); } /** * Open the toolbox, with the storage tool visible. * * @param cb {Function} Optional callback, if you don't want to use the returned * promise * * @return {Promise} a promise that resolves when the storage inspector is ready */ var openStoragePanel = Task.async(function* (cb) { info("Opening the storage inspector"); let target = TargetFactory.forTab(gBrowser.selectedTab); let storage, toolbox; // Checking if the toolbox and the storage are already loaded // The storage-updated event should only be waited for if the storage // isn't loaded yet toolbox = gDevTools.getToolbox(target); if (toolbox) { storage = toolbox.getPanel("storage"); if (storage) { gPanelWindow = storage.panelWindow; gUI = storage.UI; gToolbox = toolbox; info("Toolbox and storage already open"); if (cb) { return cb(storage, toolbox); } return { toolbox: toolbox, storage: storage }; } } info("Opening the toolbox"); toolbox = yield gDevTools.showToolbox(target, "storage"); 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; info("Waiting for the stores to update"); yield gUI.once("store-objects-updated"); yield waitForToolboxFrameFocus(toolbox); if (cb) { return cb(storage, toolbox); } return { toolbox: toolbox, storage: storage }; }); /** * Wait for the toolbox frame to receive focus after it loads * * @param toolbox {Toolbox} * * @return a promise that resolves when focus has been received */ function waitForToolboxFrameFocus(toolbox) { info("Making sure that the toolbox's frame is focused"); let def = promise.defer(); waitForFocus(def.resolve, toolbox.win); return def.promise; } /** * Forces GC, CC and Shrinking GC to get rid of disconnected docshells and * windows. */ function forceCollections() { Cu.forceGC(); Cu.forceCC(); Cu.forceShrinkingGC(); } /** * Cleans up and finishes the test */ function* finishTests() { // Bug 1233497 makes it so that we can no longer yield CPOWs from Tasks. // We work around this by calling clear() via a ContentTask instead. yield ContentTask.spawn(gBrowser.selectedBrowser, null, function* () { /** * Get all windows including frames recursively. * * @param {Window} [baseWindow] * The base window at which to start looking for child windows * (optional). * @return {Set} * A set of windows. */ function getAllWindows(baseWindow) { let windows = new Set(); let _getAllWindows = function (win) { windows.add(win.wrappedJSObject); for (let i = 0; i < win.length; i++) { _getAllWindows(win[i]); } }; _getAllWindows(baseWindow); return windows; } let windows = getAllWindows(content); for (let win of windows) { // Some windows (e.g., about: URLs) don't have storage available try { win.localStorage.clear(); win.sessionStorage.clear(); } catch (ex) { // ignore } if (win.clear) { yield win.clear(); } } }); Services.cookies.removeAll(); forceCollections(); finish(); } // Sends a click event on the passed DOM node in an async manner function* click(node) { let def = promise.defer(); node.scrollIntoView(); // We need setTimeout here to allow any scrolling to complete before clicking // the node. setTimeout(() => { node.click(); def.resolve(); }, 200); return def; } /** * 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) { let root = options.rootVariable; let expandTo = options.expandTo.split("."); let lastDeferred = promise.defer(); function getNext(prop) { let name = expandTo.shift(); let newProp = prop.get(name); if (expandTo.length > 0) { ok(newProp, "found property " + name); if (newProp && newProp.expand) { newProp.expand(); getNext(newProp); } else { lastDeferred.reject(prop); } } else if (newProp) { lastDeferred.resolve(newProp); } else { lastDeferred.reject(prop); } } if (root && root.expand) { root.expand(); getNext(root); } else { lastDeferred.resolve(root); } return lastDeferred.promise; } /** * 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. let expandRules = []; let 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. let outstanding = []; finder(rules, gUI.view, outstanding); // Process the rules that need to expand properties. let lastStep = processExpandRules.bind(null, expandRules); // Return the results - a promise resolved to hold the updated ruleArray. let 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 (let scope of view) { for (let [, prop] of scope) { for (let rule of rules) { let matcher = matchVariablesViewProperty(prop, rule); promises.push(matcher.then(onMatch.bind(null, prop, rule))); } } } } function processExpandRules(rules) { let rule = rules.shift(); if (!rule) { return promise.resolve(null); } let deferred = promise.defer(); let expandOptions = { rootVariable: gUI.view.getScopeAtIndex(parsed ? 1 : 0), expandTo: rule.name }; variablesViewExpandTo(expandOptions).then(function onSuccess(prop) { let name = rule.name; let lastName = name.split(".").pop(); rule.name = lastName; let matched = matchVariablesViewProperty(prop, rule); return matched.then(onMatch.bind(null, prop, rule)).then(function () { rule.name = name; }); }, function onFailure() { return promise.resolve(null); }).then(processExpandRules.bind(null, rules)).then(function () { deferred.resolve(null); }); return deferred.promise; } function onAllRulesMatched(rules) { for (let rule of rules) { let 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); } if (rule.name) { let 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); } let 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 */ function* selectTreeItem(ids) { /* If this item is already selected, return */ if (gUI.tree.isSelected(ids)) { return; } let updated = gUI.once("store-objects-updated"); gUI.tree.selectedItem = ids; yield updated; } /** * Click selects a row in the table. * * @param {String} id * The id of the row in the table widget */ function* selectTableItem(id) { let table = gUI.table; let selector = ".table-widget-column#" + table.uniqueId + " .table-widget-cell[value='" + id + "']"; let target = gPanelWindow.document.querySelector(selector); ok(target, "table item found with ids " + id); if (!target) { showAvailableIds(); } yield click(target); yield gUI.once("sidebar-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] 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 + "."); let deferred = promise.defer(); for (let [add, remove] of [ ["addEventListener", "removeEventListener"], ["addListener", "removeListener"], ["on", "off"] ]) { if ((add in target) && (remove in target)) { target[add](eventName, function onEvent(...aArgs) { target[remove](eventName, onEvent, useCapture); deferred.resolve.apply(deferred, aArgs); }, useCapture); break; } } return deferred.promise; } /** * 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) { let cells = getRowCells(id, includeHidden); let values = {}; for (let name in cells) { let 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) { let doc = gPanelWindow.document; let table = gUI.table; let item = doc.querySelector(".table-widget-column#" + table.uniqueId + " .table-widget-cell[value='" + id + "']"); if (!item) { ok(false, "Row id '" + id + "' exists"); showAvailableIds(); } let index = table.columns.get(table.uniqueId).cellNodes.indexOf(item); let cells = {}; for (let [name, column] of [...table.columns]) { if (!includeHidden && column.column.parentNode.hidden) { continue; } cells[name] = column.cellNodes[index]; } return cells; } /** * Show available ids. */ function showAvailableIds() { let doc = gPanelWindow.document; let table = gUI.table; info("Available ids:"); let cells = doc.querySelectorAll(".table-widget-column#" + table.uniqueId + " .table-widget-cell"); for (let cell of cells) { info(" - " + cell.getAttribute("value")); } } /** * 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) { let row = getRowValues(id, true); 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. */ function* editCell(id, column, newValue, validate = true) { let row = getRowCells(id, true); let editableFieldsEngine = gUI.table._editableFieldsEngine; editableFieldsEngine.edit(row[column]); yield typeWithTerminator(newValue, "VK_RETURN", 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) { let row = getRowCells(id, true); let editableFieldsEngine = gUI.table._editableFieldsEngine; let cell = row[column]; info("Selecting row " + id); gUI.table.selectedRow = id; info("Starting cell edit (" + id + ", " + column + ")"); editableFieldsEngine.edit(cell); if (!selectText) { let 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); } /** * 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) { let columns = gUI.table.columns; let column = columns.get(id); if (state) { column.wrapper.removeAttribute("hidden"); } else { column.wrapper.setAttribute("hidden", true); } } /** * Toggle sort direction on a column by clicking on the column header. * * @param {String} id * The uniqueId of the given column. */ function clickColumnHeader(id) { let columns = gUI.table.columns; let column = columns.get(id); let header = column.header; header.click(); } /** * Show or hide all columns. * * @param {Boolean} state * true = show, false = hide */ function showAllColumns(state) { let columns = gUI.table.columns; for (let [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. VK_RETURN or VK_TAB * @param {Boolean} validate * Validate result? Default true. */ function* typeWithTerminator(str, terminator, validate = true) { let editableFieldsEngine = gUI.table._editableFieldsEngine; let textbox = editableFieldsEngine.textbox; let colName = textbox.closest(".table-widget-column").id; let changeExpected = str !== textbox.value; if (!changeExpected) { return editableFieldsEngine.currentTarget.getAttribute("data-id"); } info("Typing " + str); EventUtils.sendString(str); info("Pressing " + terminator); EventUtils.synthesizeKey(terminator, {}); if (validate) { info("Validating results... waiting for ROW_EDIT event."); let uniqueId = yield gUI.table.once(TableWidget.EVENTS.ROW_EDIT); checkCell(uniqueId, colName, str); return uniqueId; } return yield gUI.table.once(TableWidget.EVENTS.ROW_EDIT); } function getCurrentEditorValue() { let editableFieldsEngine = gUI.table._editableFieldsEngine; let 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). */ function* checkState(state) { for (let [store, names] of state) { let storeName = store.join(" > "); info(`Selecting tree item ${storeName}`); yield selectTreeItem(store); let items = gUI.table.items; is(items.size, names.length, `There is correct number of rows in ${storeName}`); if (names.length === 0) { showAvailableIds(); } for (let name of names) { ok(items.has(name), `There is item with name '${name}' in ${storeName}`); if (!items.has(name)) { showAvailableIds(); } } } } /** * 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 = Task.async(function* (panelWin, callback) { info("Focusing search box"); let searchBox = panelWin.document.getElementById("storage-searchbox"); let focused = once(searchBox, "focus"); panelWin.focus(); let strings = Services.strings.createBundle( "chrome://devtools/locale/storage.properties"); synthesizeKeyShortcut(strings.GetStringFromName("storage.filter.key")); yield focused; if (callback) { callback(); } }); function getCookieId(name, domain, path) { return `${name}${SEPARATOR_GUID}${domain}${SEPARATOR_GUID}${path}`; } function setPermission(url, permission) { const nsIPermissionManager = Components.interfaces.nsIPermissionManager; let uri = Components.classes["@mozilla.org/network/io-service;1"] .getService(Components.interfaces.nsIIOService) .newURI(url, null, null); let ssm = Components.classes["@mozilla.org/scriptsecuritymanager;1"] .getService(Ci.nsIScriptSecurityManager); let principal = ssm.createCodebasePrincipal(uri, {}); Components.classes["@mozilla.org/permissionmanager;1"] .getService(nsIPermissionManager) .addFromPrincipal(principal, permission, nsIPermissionManager.ALLOW_ACTION); } /** * Add an item. * @param {Array} store * An array containing the path to the store to which we wish to add an * item. */ function* performAdd(store) { let storeName = store.join(" > "); let toolbar = gPanelWindow.document.getElementById("storage-toolbar"); let type = store[0]; yield selectTreeItem(store); let menuAdd = toolbar.querySelector( "#add-button"); if (menuAdd.hidden) { is(menuAdd.hidden, false, `performAdd called for ${storeName} but it is not supported`); return; } let eventEdit = gUI.table.once("row-edit"); let eventWait = gUI.once("store-objects-updated"); menuAdd.click(); let rowId = yield eventEdit; yield eventWait; let key = type === "cookies" ? "uniqueKey" : "name"; let value = getCellValue(rowId, key); is(rowId, value, `Row '${rowId}' was successfully added.`); }