Bug 1542035 - Add read-only support for extension storage.local in addon debugger r=miker,rpl

* Add a new extensionStorage actor to enable inspection of data stored by an extension using the WebExtension storage.local API in the Storage panel client.
* The actor is only listed when the developer toolbox is targeting an extension process. For multi-process Firefox (e10s), this applies to only the toolbox accessed in about:debugging.
* The actor is gated behind a preference: devtools.storage.extensionStorage.enabled. This preference is set to false by default.
* The Storage panel displays storage item values as strings. If a storage item value is not JSON-stringifiable, it will be displayed in the table as "Object".
* It should be noted that extension storage.local’s storage backend is in the process of migrating from a JSON file to IndexedDB as of Firefox 66 for performance reasons. This actor only works for extensions that have migrated to the IndexedDB storage backend.
* In-line comments referencing Bugs 1542038 and 1542039 indicate places where the implementation may differ for local storage versus the other storage areas in the actor.

Differential Revision: https://phabricator.services.mozilla.com/D34415

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Bianca Danforth 2019-08-19 20:16:50 +00:00
parent f88768528b
commit 780679ebf7
9 changed files with 1443 additions and 1 deletions

View File

@ -78,6 +78,9 @@ pref("extensions.langpacks.signatures.required", true);
pref("xpinstall.signatures.required", true);
pref("xpinstall.signatures.devInfoURL", "https://wiki.mozilla.org/Addons/Extension_Signing");
// Disable extensionStorage storage actor by default
pref("devtools.storage.extensionStorage.enabled", false);
// Dictionary download preference
pref("browser.dictionaries.download.url", "https://addons.mozilla.org/%LOCALE%/firefox/language-tools/");

View File

@ -31,6 +31,7 @@ tree.labels.localStorage=Local Storage
tree.labels.sessionStorage=Session Storage
tree.labels.indexedDB=Indexed DB
tree.labels.Cache=Cache Storage
tree.labels.extensionStorage=Extension Storage
# LOCALIZATION NOTE (table.headers.*.*):
# These strings are the header names of the columns in the Storage Table for
@ -62,6 +63,10 @@ table.headers.indexedDB.keyPath2=Key Path
table.headers.indexedDB.autoIncrement=Auto Increment
table.headers.indexedDB.indexes=Indexes
table.headers.extensionStorage.area=Storage Area
table.headers.extensionStorage.name=Key
table.headers.extensionStorage.value=Value
# LOCALIZATION NOTE (label.expires.session):
# This string is displayed in the expires column when the cookie is Session
# Cookie

View File

@ -12,9 +12,24 @@ const Services = require("Services");
const defer = require("devtools/shared/defer");
const { isWindowIncluded } = require("devtools/shared/layout/utils");
const specs = require("devtools/shared/specs/storage");
loader.lazyGetter(this, "ExtensionProcessScript", () => {
return require("resource://gre/modules/ExtensionProcessScript.jsm")
.ExtensionProcessScript;
});
loader.lazyGetter(this, "ExtensionStorageIDB", () => {
return require("resource://gre/modules/ExtensionStorageIDB.jsm")
.ExtensionStorageIDB;
});
loader.lazyGetter(
this,
"WebExtensionPolicy",
() => Cu.getGlobalForObject(ExtensionProcessScript).WebExtensionPolicy
);
const CHROME_ENABLED_PREF = "devtools.chrome.enabled";
const REMOTE_ENABLED_PREF = "devtools.debugger.remote-enabled";
const EXTENSION_STORAGE_ENABLED_PREF =
"devtools.storage.extensionStorage.enabled";
const DEFAULT_VALUE = "value";
@ -1386,6 +1401,483 @@ StorageActors.createActor(
getObjectForLocalOrSessionStorage("sessionStorage")
);
const extensionStorageHelpers = {
unresolvedPromises: new Map(),
// Map of addonId => onStorageChange listeners in the parent process. Each addon toolbox targets
// a single addon, and multiple addon toolboxes could be open at the same time.
onChangedParentListeners: new Map(),
// Set of onStorageChange listeners in the extension child process. Each addon toolbox will create
// a separate extensionStorage actor targeting that addon. The addonId is passed into the listener,
// so that changes propagate only if the storage actor has a matching addonId.
onChangedChildListeners: new Set(),
// Sets the parent process message manager
setPpmm(ppmm) {
this.ppmm = ppmm;
},
// A promise in the main process has resolved, and we need to pass the return value(s)
// back to the child process
backToChild(...args) {
Services.mm.broadcastAsyncMessage(
"debug:storage-extensionStorage-request-child",
{
method: "backToChild",
args: args,
}
);
},
// Send a message from the main process to a listener in the child process that the
// extension has modified storage local data
fireStorageOnChanged({ addonId, changes }) {
Services.mm.broadcastAsyncMessage(
"debug:storage-extensionStorage-request-child",
{
addonId,
changes,
method: "storageOnChanged",
}
);
},
// Subscribe a listener for event notifications from the WE storage API when
// storage local data has been changed by the extension, and keep track of the
// listener to remove it when the debugger is being disconnected.
subscribeOnChangedListenerInParent(addonId) {
if (!this.onChangedParentListeners.has(addonId)) {
const onChangedListener = changes => {
this.fireStorageOnChanged({ addonId, changes });
};
ExtensionStorageIDB.addOnChangedListener(addonId, onChangedListener);
this.onChangedParentListeners.set(addonId, onChangedListener);
}
},
// The main process does not require an extension context to select the backend
// Bug 1542038, 1542039: Each storage area will need its own implementation, as
// they use different storage backends.
async setupStorageInParent(addonId) {
const { extension } = WebExtensionPolicy.getByID(addonId);
const parentResult = await ExtensionStorageIDB.selectBackend({ extension });
const result = {
...parentResult,
// Received as a StructuredCloneHolder, so we need to deserialize
storagePrincipal: parentResult.storagePrincipal.deserialize(this, true),
};
this.subscribeOnChangedListenerInParent(addonId);
return this.backToChild("setupStorageInParent", result);
},
onDisconnected() {
for (const [addonId, listener] of this.onChangedParentListeners) {
ExtensionStorageIDB.removeOnChangedListener(addonId, listener);
}
this.onChangedParentListeners.clear();
},
// Runs in the main process. This determines what code to execute based on the message
// received from the child process.
async handleChildRequest(msg) {
switch (msg.json.method) {
case "setupStorageInParent":
const addonId = msg.data.args[0];
const result = await extensionStorageHelpers.setupStorageInParent(
addonId
);
return result;
default:
console.error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD", msg.json.method);
throw new Error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD");
}
},
// Runs in the child process. This determines what code to execute based on the message
// received from the parent process.
handleParentRequest(msg) {
switch (msg.json.method) {
case "backToChild": {
const [func, rv] = msg.json.args;
const deferred = this.unresolvedPromises.get(func);
if (deferred) {
this.unresolvedPromises.delete(func);
deferred.resolve(rv);
}
break;
}
case "storageOnChanged": {
const { addonId, changes } = msg.data;
for (const listener of this.onChangedChildListeners) {
try {
listener({ addonId, changes });
} catch (err) {
console.error(err);
// Ignore errors raised from listeners.
}
}
break;
}
default:
console.error("ERR_DIRECTOR_CLIENT_UNKNOWN_METHOD", msg.json.method);
throw new Error("ERR_DIRECTOR_CLIENT_UNKNOWN_METHOD");
}
},
callParentProcessAsync(methodName, ...args) {
const deferred = defer();
this.unresolvedPromises.set(methodName, deferred);
this.ppmm.sendAsyncMessage(
"debug:storage-extensionStorage-request-parent",
{
method: methodName,
args: args,
}
);
return deferred.promise;
},
};
/**
* E10S parent/child setup helpers
* Add a message listener in the parent process to receive messages from the child
* process.
*/
exports.setupParentProcessForExtensionStorage = function({ mm, prefix }) {
// listen for director-script requests from the child process
setMessageManager(mm);
function setMessageManager(newMM) {
if (mm) {
mm.removeMessageListener(
"debug:storage-extensionStorage-request-parent",
extensionStorageHelpers.handleChildRequest
);
}
mm = newMM;
if (mm) {
mm.addMessageListener(
"debug:storage-extensionStorage-request-parent",
extensionStorageHelpers.handleChildRequest
);
}
}
return {
onBrowserSwap: setMessageManager,
onDisconnected: () => {
// Although "disconnected-from-child" implies that the child is already
// disconnected this is not the case. The disconnection takes place after
// this method has finished. This gives us chance to clean up items within
// the parent process e.g. observers.
setMessageManager(null);
extensionStorageHelpers.onDisconnected();
},
};
};
/**
* The Extension Storage actor.
*/
if (Services.prefs.getBoolPref(EXTENSION_STORAGE_ENABLED_PREF, false)) {
StorageActors.createActor(
{
typeName: "extensionStorage",
},
{
initialize(storageActor) {
protocol.Actor.prototype.initialize.call(this, null);
this.storageActor = storageActor;
this.addonId = this.storageActor.parentActor.addonId;
// Retrieve the base moz-extension url for the extension
// (and also remove the final '/' from it).
this.extensionHostURL = this.getExtensionPolicy()
.getURL()
.slice(0, -1);
// Map<host, ExtensionStorageIDB db connection>
// Bug 1542038, 1542039: Each storage area will need its own
// dbConnectionForHost, as they each have different storage backends.
// Anywhere dbConnectionForHost is used, we need to know the storage
// area to access the correct database.
this.dbConnectionForHost = new Map();
// Bug 1542038, 1542039: Each storage area will need its own
// this.hostVsStores or this actor will need to deviate from how
// this.hostVsStores is defined in the framework to associate each
// storage item with a storage area. Any methods that use it will also
// need to be updated (e.g. getNamesForHost).
this.hostVsStores = new Map();
this.onStorageChange = this.onStorageChange.bind(this);
this.setupChildProcess();
this.onWindowReady = this.onWindowReady.bind(this);
this.onWindowDestroyed = this.onWindowDestroyed.bind(this);
this.storageActor.on("window-ready", this.onWindowReady);
this.storageActor.on("window-destroyed", this.onWindowDestroyed);
},
getExtensionPolicy() {
return WebExtensionPolicy.getByID(this.addonId);
},
destroy() {
extensionStorageHelpers.onChangedChildListeners.delete(
this.onStorageChange
);
this.storageActor.off("window-ready", this.onWindowReady);
this.storageActor.off("window-destroyed", this.onWindowDestroyed);
this.hostVsStores.clear();
protocol.Actor.prototype.destroy.call(this);
this.storageActor = null;
},
setupChildProcess() {
const ppmm = this.conn.parentMessageManager;
extensionStorageHelpers.setPpmm(ppmm);
// eslint-disable-next-line no-restricted-properties
this.conn.setupInParent({
module: "devtools/server/actors/storage",
setupParent: "setupParentProcessForExtensionStorage",
});
extensionStorageHelpers.onChangedChildListeners.add(
this.onStorageChange
);
this.setupStorageInParent = extensionStorageHelpers.callParentProcessAsync.bind(
extensionStorageHelpers,
"setupStorageInParent"
);
// Add a message listener in the child process to receive messages from the parent
// process
ppmm.addMessageListener(
"debug:storage-extensionStorage-request-child",
extensionStorageHelpers.handleParentRequest.bind(
extensionStorageHelpers
)
);
},
/**
* This fires when the extension changes storage data while the storage
* inspector is open. Ensures this.hostVsStores stays up-to-date and
* passes the changes on to update the client.
*/
onStorageChange({ addonId, changes }) {
if (addonId !== this.addonId) {
return;
}
const host = this.extensionHostURL;
const storeMap = this.hostVsStores.get(host);
function isStructuredCloneHolder(value) {
return (
value &&
typeof value === "object" &&
Cu.getClassName(value, true) === "StructuredCloneHolder"
);
}
for (const key in changes) {
const storageChange = changes[key];
let { newValue, oldValue } = storageChange;
if (isStructuredCloneHolder(newValue)) {
newValue = newValue.deserialize(this);
}
if (isStructuredCloneHolder(oldValue)) {
oldValue = oldValue.deserialize(this);
}
let action;
if (typeof newValue === "undefined") {
action = "deleted";
storeMap.delete(key);
} else if (typeof oldValue === "undefined") {
action = "added";
storeMap.set(key, newValue);
} else {
action = "changed";
storeMap.set(key, newValue);
}
this.storageActor.update(action, this.typeName, { [host]: [key] });
}
},
/**
* Purpose of this method is same as populateStoresForHosts but this is async.
* This exact same operation cannot be performed in populateStoresForHosts
* method, as that method is called in initialize method of the actor, which
* cannot be asynchronous.
*/
async preListStores() {
// Ensure the actor's target is an extension and it is enabled
if (!this.addonId || !WebExtensionPolicy.getByID(this.addonId)) {
return;
}
await this.populateStoresForHost(this.extensionHostURL);
},
/**
* This method is overriden and left blank as for extensionStorage, this operation
* cannot be performed synchronously. Thus, the preListStores method exists to
* do the same task asynchronously.
*/
populateStoresForHosts() {},
/**
* This method asynchronously reads the storage data for the target extension
* and caches this data into this.hostVsStores.
* @param {String} host - the hostname for the extension
*/
async populateStoresForHost(host) {
if (host !== this.extensionHostURL) {
return;
}
const extension = ExtensionProcessScript.getExtensionChild(
this.addonId
);
if (!extension || !extension.hasPermission("storage")) {
return;
}
// Make sure storeMap is defined and set in this.hostVsStores before subscribing
// a storage onChanged listener in the parent process
const storeMap = new Map();
this.hostVsStores.set(host, storeMap);
const storagePrincipal = await this.getStoragePrincipal(extension.id);
if (!storagePrincipal) {
// This could happen if the extension fails to be migrated to the
// IndexedDB backend
return;
}
const db = await ExtensionStorageIDB.open(storagePrincipal);
this.dbConnectionForHost.set(host, db);
const data = await db.get();
for (const [key, value] of Object.entries(data)) {
storeMap.set(key, value);
}
// Show the storage actor in the add-on storage inspector even when there
// is no extension page currently open
const storageData = {};
storageData[host] = this.getNamesForHost(host);
this.storageActor.update("added", this.typeName, storageData);
},
async getStoragePrincipal(addonId) {
const {
backendEnabled,
storagePrincipal,
} = await this.setupStorageInParent(addonId);
if (!backendEnabled) {
// IDB backend disabled; give up.
return null;
}
return storagePrincipal;
},
getValuesForHost(host, name) {
const result = [];
if (!this.hostVsStores.has(host)) {
return result;
}
if (name) {
return [{ name, value: this.hostVsStores.get(host).get(name) }];
}
for (const [key, value] of Array.from(
this.hostVsStores.get(host).entries()
)) {
result.push({ name: key, value });
}
return result;
},
/**
* Converts a storage item to an "extensionobject" as defined in
* devtools/shared/specs/storage.js
* @param {Object} item - The storage item to convert
* @param {String} item.name - The storage item key
* @param {*} item.value - The storage item value
* @return {extensionobject}
*/
toStoreObject(item) {
if (!item) {
return null;
}
const { name, value } = item;
let newValue;
if (typeof value === "string") {
newValue = value;
} else {
try {
newValue = JSON.stringify(value) || String(value);
} catch (error) {
// throws for bigint
newValue = String(value);
}
// JavaScript objects that are not JSON stringifiable will be represented
// by the string "Object"
if (newValue === "{}") {
newValue = "Object";
}
}
// FIXME: Bug 1318029 - Due to a bug that is thrown whenever a
// LongStringActor string reaches DebuggerServer.LONG_STRING_LENGTH we need
// to trim the value. When the bug is fixed we should stop trimming the
// string here.
const maxLength = DebuggerServer.LONG_STRING_LENGTH - 1;
if (newValue.length > maxLength) {
newValue = newValue.substr(0, maxLength);
}
return {
name,
value: new LongStringActor(this.conn, newValue || ""),
area: "local", // Bug 1542038, 1542039: set the correct storage area
};
},
getFields() {
return [
{ name: "name", editable: false },
{ name: "value", editable: false },
{ name: "area", editable: false },
];
},
}
);
}
StorageActors.createActor(
{
typeName: "Cache",
@ -2730,6 +3222,11 @@ const StorageActor = protocol.ActorClassWithSpec(specs.storageSpec, {
// Initialize the registered store types
for (const [store, ActorConstructor] of storageTypePool) {
// Only create the extensionStorage actor when the debugging target
// is an extension.
if (store === "extensionStorage" && !this.parentActor.addonId) {
continue;
}
this.childActorPool.set(store, new ActorConstructor(this));
}
@ -2810,6 +3307,14 @@ const StorageActor = protocol.ActorClassWithSpec(specs.storageSpec, {
return null;
},
isIncludedInTargetExtension(subject) {
const { document } = subject;
return (
document.nodePrincipal.addonId &&
document.nodePrincipal.addonId === this.parentActor.addonId
);
},
isIncludedInTopLevelWindow(window) {
return isWindowIncluded(this.window, window);
},
@ -2848,9 +3353,13 @@ const StorageActor = protocol.ActorClassWithSpec(specs.storageSpec, {
return null;
}
// We don't want to try to find a top level window for an extension page, as
// in many cases (e.g. background page), it is not loaded in a tab, and
// 'isIncludedInTopLevelWindow' throws an error
if (
topic == "content-document-global-created" &&
this.isIncludedInTopLevelWindow(subject)
(this.isIncludedInTargetExtension(subject) ||
this.isIncludedInTopLevelWindow(subject))
) {
this.childWindowPool.add(subject);
this.emit("window-ready", subject);

View File

@ -147,6 +147,7 @@ fail-if = fission
fail-if = fission
[browser_storage_updates.js]
fail-if = fission
[browser_storage_webext_storage_local.js]
[browser_styles_getRuleText.js]
[browser_stylesheets_getTextEmpty.js]
[browser_stylesheets_nested-iframes.js]

View File

@ -0,0 +1,39 @@
/* 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/ */
"use strict";
// Pref remains in effect until test completes and is automatically cleared afterwards
add_task(async function set_enable_extensionStorage_pref() {
await SpecialPowers.pushPrefEnv({
set: [["devtools.storage.extensionStorage.enabled", true]],
});
});
add_task(
async function test_extensionStorage_disabled_for_non_extension_target() {
info(
"Setting up and connecting Debugger Server and Client in main process"
);
initDebuggerServer();
const transport = DebuggerServer.connectPipe();
const client = new DebuggerClient(transport);
await client.connect();
info("Opening a non-extension page in a tab");
const target = await addTabTarget("data:text/html;charset=utf-8,");
info("Getting all stores for the target process");
const storageFront = await target.getFront("storage");
const stores = await storageFront.listStores();
ok(
!("extensionStorage" in stores),
"Should not have an extensionStorage store when non-extension process is targeted"
);
await target.destroy();
gBrowser.removeCurrentTab();
}
);

View File

@ -0,0 +1,861 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/* globals browser */
"use strict";
const { AddonTestUtils } = ChromeUtils.import(
"resource://testing-common/AddonTestUtils.jsm"
);
const { FileUtils } = ChromeUtils.import(
"resource://gre/modules/FileUtils.jsm"
);
const { ExtensionTestUtils } = ChromeUtils.import(
"resource://testing-common/ExtensionXPCShellUtils.jsm"
);
// Ignore rejection related to the storage.onChanged listener being removed while the extension context is being closed.
const { PromiseTestUtils } = ChromeUtils.import(
"resource://testing-common/PromiseTestUtils.jsm"
);
PromiseTestUtils.whitelistRejectionsGlobally(/Message manager disconnected/);
const { createAppInfo, promiseStartupManager } = AddonTestUtils;
const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall";
const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall";
const EXTENSION_STORAGE_ENABLED_PREF =
"devtools.storage.extensionStorage.enabled";
AddonTestUtils.init(this);
createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
ExtensionTestUtils.init(this);
// This storage actor is gated behind a pref, so make sure it is enabled first
Services.prefs.setBoolPref(EXTENSION_STORAGE_ENABLED_PREF, true);
registerCleanupFunction(() => {
Services.prefs.clearUserPref(EXTENSION_STORAGE_ENABLED_PREF);
});
/**
* Starts up Debugger server and connects a new Debugger client.
*
* @return {Promise} Resolves with a client object when the debugger has started up.
*/
async function startDebugger() {
DebuggerServer.init();
DebuggerServer.registerAllActors();
const transport = DebuggerServer.connectPipe();
const client = new DebuggerClient(transport);
await client.connect();
return client;
}
/**
* Set up the equivalent of an `about:debugging` toolbox for a given extension, minus
* the toolbox.
*
* @param {String} id - The id for the extension to be targeted by the toolbox.
* @return {Object} Resolves with the web extension actor front and target objects when
* the debugger has been connected to the extension.
*/
async function setupExtensionDebugging(id) {
const client = await startDebugger();
const front = await client.mainRoot.getAddon({ id });
// Starts a DevTools server in the extension child process.
const target = await front.connect();
return { front, target };
}
/**
* Loads and starts up a test extension given the provided extension configuration.
*
* @param {Object} extConfig - The extension configuration object
* @return {ExtensionWrapper} extension - Resolves with an extension object once the
* extension has started up.
*/
async function startupExtension(extConfig) {
const extension = ExtensionTestUtils.loadExtension(extConfig);
await extension.startup();
return extension;
}
/**
* Initializes the extensionStorage actor for a target extension. This is effectively
* what happens when the addon storage panel is opened in the browser.
*
* @param {String} - id, The addon id
* @return {Object} - Resolves with the web extension actor target and extensionStorage
* store objects when the panel has been opened.
*/
async function openAddonStoragePanel(id) {
const { target } = await setupExtensionDebugging(id);
const storageFront = await target.getFront("storage");
const stores = await storageFront.listStores();
const extensionStorage = stores.extensionStorage || null;
return { target, extensionStorage };
}
/**
* Builds the extension configuration object passed into ExtensionTestUtils.loadExtension
*
* @param {Object} options - Options, if any, to add to the configuration
* @param {Function} options.background - A function comprising the test extension's
* background script if provided
* @param {Object} options.files - An object whose keys correspond to file names and
* values map to the file contents
* @param {Object} options.manifest - An object representing the extension's manifest
* @return {Object} - The extension configuration object
*/
function getExtensionConfig(options = {}) {
const { manifest, ...otherOptions } = options;
const baseConfig = {
manifest: {
...manifest,
permissions: ["storage"],
},
useAddonManager: "temporary",
};
return {
...baseConfig,
...otherOptions,
};
}
/**
* An extension script that can be used in any extension context (e.g. as a background
* script or as an extension page script loaded in a tab).
*/
async function extensionScriptWithMessageListener() {
let fireOnChanged = false;
browser.storage.onChanged.addListener(() => {
if (fireOnChanged) {
// Do not fire it again until explicitly requested again using the "storage-local-fireOnChanged" test message.
fireOnChanged = false;
browser.test.sendMessage("storage-local-onChanged");
}
});
browser.test.onMessage.addListener(async (msg, ...args) => {
let value = null;
switch (msg) {
case "storage-local-set":
await browser.storage.local.set(args[0]);
break;
case "storage-local-get":
value = (await browser.storage.local.get(args[0]))[args[0]];
break;
case "storage-local-remove":
await browser.storage.local.remove(args[0]);
break;
case "storage-local-clear":
await browser.storage.local.clear();
break;
case "storage-local-fireOnChanged": {
// Allow the storage.onChanged listener to send a test event
// message when onChanged is being fired.
fireOnChanged = true;
// Do not fire fireOnChanged:done.
return;
}
default:
browser.test.fail(`Unexpected test message: ${msg}`);
}
browser.test.sendMessage(`${msg}:done`, value);
});
browser.test.sendMessage("extension-origin", window.location.origin);
}
/**
* Shared files for a test extension that has no background page but adds storage
* items via a transient extension page in a tab
*/
const ext_no_bg = {
files: {
"extension_page_in_tab.html": `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<h1>Extension Page in a Tab</h1>
<script src="extension_page_in_tab.js"></script>
</body>
</html>`,
"extension_page_in_tab.js": extensionScriptWithMessageListener,
},
};
/**
* Shutdown procedure common to all tasks.
*
* @param {Object} extension - The test extension
* @param {Object} target - The web extension actor targeted by the DevTools client
*/
async function shutdown(extension, target) {
if (target) {
await target.destroy();
}
await extension.unload();
}
/**
* Mocks the missing 'storage/permanent' directory needed by the "indexedDB"
* storage actor's 'preListStores' method (called when 'listStores' is called). This
* directory exists in a full browser i.e. mochitest.
*/
function createMissingIndexedDBDirs() {
const dir = Services.dirsvc.get("ProfD", Ci.nsIFile).clone();
dir.append("storage");
if (!dir.exists()) {
dir.create(dir.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
}
dir.append("permanent");
if (!dir.exists()) {
dir.create(dir.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
}
Assert.ok(
dir.exists(),
"Should have a 'storage/permanent' dir in the profile dir"
);
}
add_task(async function setup() {
await promiseStartupManager();
createMissingIndexedDBDirs();
});
add_task(async function test_extension_store_exists() {
const extension = await startupExtension(getExtensionConfig());
const { target, extensionStorage } = await openAddonStoragePanel(
extension.id
);
ok(extensionStorage, "Should have an extensionStorage store");
await shutdown(extension, target);
});
add_task(async function test_extension_origin_matches_debugger_target() {
async function background() {
browser.test.sendMessage("extension-origin", window.location.origin);
}
const extension = await startupExtension(getExtensionConfig({ background }));
const { target, extensionStorage } = await openAddonStoragePanel(
extension.id
);
const { hosts } = extensionStorage;
const expectedHost = await extension.awaitMessage("extension-origin");
ok(
expectedHost in hosts,
"Should have the expected extension host in the extensionStorage store"
);
await shutdown(extension, target);
});
/**
* Test case: Background page modifies items while storage panel is open.
* - Load extension with background page.
* - Open the add-on debugger storage panel.
* - With the panel still open, from the extension background page:
* - Bulk add storage items
* - Edit the values of some of the storage items
* - Remove some storage items
* - Clear all storage items
* - For each modification, the storage data in the panel should match the
* changes made by the extension.
*/
add_task(async function test_panel_live_updates() {
const extension = await startupExtension(
getExtensionConfig({ background: extensionScriptWithMessageListener })
);
const { target, extensionStorage } = await openAddonStoragePanel(
extension.id
);
const host = await extension.awaitMessage("extension-origin");
let { data } = await extensionStorage.getStoreObjects(host);
Assert.deepEqual(data, [], "Got the expected results on empty storage.local");
info("Waiting for extension to bulk add 50 items to storage local");
const bulkStorageItems = {};
// limited by MAX_STORE_OBJECT_COUNT in devtools/server/actors/storage.js
const numItems = 2;
for (let i = 1; i <= numItems; i++) {
bulkStorageItems[i] = i;
}
// fireOnChanged avoids the race condition where the extension
// modifies storage then immediately tries to access storage before
// the storage actor has finished updating.
extension.sendMessage("storage-local-fireOnChanged");
extension.sendMessage("storage-local-set", {
...bulkStorageItems,
a: 123,
b: [4, 5],
c: { d: 678 },
d: true,
e: "hi",
f: null,
});
await extension.awaitMessage("storage-local-set:done");
await extension.awaitMessage("storage-local-onChanged");
info(
"Confirming items added by extension match items in extensionStorage store"
);
const bulkStorageObjects = [];
for (const [name, value] of Object.entries(bulkStorageItems)) {
bulkStorageObjects.push({
area: "local",
name,
value: { str: String(value) },
});
}
data = (await extensionStorage.getStoreObjects(host)).data;
Assert.deepEqual(
data,
[
...bulkStorageObjects,
{ area: "local", name: "a", value: { str: "123" } },
{ area: "local", name: "b", value: { str: "[4,5]" } },
{ area: "local", name: "c", value: { str: '{"d":678}' } },
{ area: "local", name: "d", value: { str: "true" } },
{ area: "local", name: "e", value: { str: "hi" } },
{ area: "local", name: "f", value: { str: "null" } },
],
"Got the expected results on populated storage.local"
);
info("Waiting for extension to edit a few storage item values");
extension.sendMessage("storage-local-fireOnChanged");
extension.sendMessage("storage-local-set", {
a: ["c", "d"],
b: 456,
c: false,
});
await extension.awaitMessage("storage-local-set:done");
await extension.awaitMessage("storage-local-onChanged");
info(
"Confirming items edited by extension match items in extensionStorage store"
);
data = (await extensionStorage.getStoreObjects(host)).data;
Assert.deepEqual(
data,
[
...bulkStorageObjects,
{ area: "local", name: "a", value: { str: '["c","d"]' } },
{ area: "local", name: "b", value: { str: "456" } },
{ area: "local", name: "c", value: { str: "false" } },
{ area: "local", name: "d", value: { str: "true" } },
{ area: "local", name: "e", value: { str: "hi" } },
{ area: "local", name: "f", value: { str: "null" } },
],
"Got the expected results on populated storage.local"
);
info("Waiting for extension to remove a few storage item values");
extension.sendMessage("storage-local-fireOnChanged");
extension.sendMessage("storage-local-remove", ["d", "e", "f"]);
await extension.awaitMessage("storage-local-remove:done");
await extension.awaitMessage("storage-local-onChanged");
info(
"Confirming items removed by extension were removed in extensionStorage store"
);
data = (await extensionStorage.getStoreObjects(host)).data;
Assert.deepEqual(
data,
[
...bulkStorageObjects,
{ area: "local", name: "a", value: { str: '["c","d"]' } },
{ area: "local", name: "b", value: { str: "456" } },
{ area: "local", name: "c", value: { str: "false" } },
],
"Got the expected results on populated storage.local"
);
info("Waiting for extension to remove all remaining storage items");
extension.sendMessage("storage-local-fireOnChanged");
extension.sendMessage("storage-local-clear");
await extension.awaitMessage("storage-local-clear:done");
await extension.awaitMessage("storage-local-onChanged");
info("Confirming extensionStorage store was cleared");
data = (await extensionStorage.getStoreObjects(host)).data;
Assert.deepEqual(
data,
[],
"Got the expected results on populated storage.local"
);
await shutdown(extension, target);
});
/**
* Test case: No bg page. Transient page adds item before storage panel opened.
* - Load extension with no background page.
* - Open an extension page in a tab that adds a local storage item.
* - With the extension page still open, open the add-on storage panel.
* - The data in the storage panel should match the items added by the extension.
*/
add_task(
async function test_panel_data_matches_extension_with_transient_page_open() {
const extension = await startupExtension(
getExtensionConfig({ files: ext_no_bg.files })
);
const url = extension.extension.baseURI.resolve(
"extension_page_in_tab.html"
);
const contentPage = await ExtensionTestUtils.loadContentPage(url, {
extension,
});
const host = await extension.awaitMessage("extension-origin");
extension.sendMessage("storage-local-set", { a: 123 });
await extension.awaitMessage("storage-local-set:done");
const { target, extensionStorage } = await openAddonStoragePanel(
extension.id
);
const { data } = await extensionStorage.getStoreObjects(host);
Assert.deepEqual(
data,
[{ area: "local", name: "a", value: { str: "123" } }],
"Got the expected results on populated storage.local"
);
await contentPage.close();
await shutdown(extension, target);
}
);
/**
* Test case: No bg page. Transient page adds item then closes before storage panel opened.
* - Load extension with no background page.
* - Open an extension page in a tab that adds a local storage item.
* - Close all extension pages.
* - Open the add-on storage panel.
* - The data in the storage panel should match the item added by the extension.
*/
add_task(async function test_panel_data_matches_extension_with_no_pages_open() {
const extension = await startupExtension(
getExtensionConfig({ files: ext_no_bg.files })
);
const url = extension.extension.baseURI.resolve("extension_page_in_tab.html");
const contentPage = await ExtensionTestUtils.loadContentPage(url, {
extension,
});
const host = await extension.awaitMessage("extension-origin");
extension.sendMessage("storage-local-set", { a: 123 });
await extension.awaitMessage("storage-local-set:done");
await contentPage.close();
const { target, extensionStorage } = await openAddonStoragePanel(
extension.id
);
const { data } = await extensionStorage.getStoreObjects(host);
Assert.deepEqual(
data,
[{ area: "local", name: "a", value: { str: "123" } }],
"Got the expected results on populated storage.local"
);
await shutdown(extension, target);
});
/**
* Test case: No bg page. Storage panel live updates when a transient page adds an item.
* - Load extension with no background page.
* - Open the add-on storage panel.
* - With the storage panel still open, open an extension page in a new tab that adds an
* item.
* - Assert:
* - The data in the storage panel should live update to match the item added by the
* extension.
* - If an extension page adds the same data again, the data in the storage panel should
* not change.
*/
add_task(
async function test_panel_data_live_updates_for_extension_without_bg_page() {
const extension = await startupExtension(
getExtensionConfig({ files: ext_no_bg.files })
);
const { target, extensionStorage } = await openAddonStoragePanel(
extension.id
);
const url = extension.extension.baseURI.resolve(
"extension_page_in_tab.html"
);
const contentPage = await ExtensionTestUtils.loadContentPage(url, {
extension,
});
const host = await extension.awaitMessage("extension-origin");
let { data } = await extensionStorage.getStoreObjects(host);
Assert.deepEqual(
data,
[],
"Got the expected results on empty storage.local"
);
extension.sendMessage("storage-local-fireOnChanged");
extension.sendMessage("storage-local-set", { a: 123 });
await extension.awaitMessage("storage-local-set:done");
await extension.awaitMessage("storage-local-onChanged");
data = (await extensionStorage.getStoreObjects(host)).data;
Assert.deepEqual(
data,
[{ area: "local", name: "a", value: { str: "123" } }],
"Got the expected results on populated storage.local"
);
extension.sendMessage("storage-local-fireOnChanged");
extension.sendMessage("storage-local-set", { a: 123 });
await extension.awaitMessage("storage-local-set:done");
await extension.awaitMessage("storage-local-onChanged");
data = (await extensionStorage.getStoreObjects(host)).data;
Assert.deepEqual(
data,
[{ area: "local", name: "a", value: { str: "123" } }],
"The results are unchanged when an extension page adds duplicate items"
);
await contentPage.close();
await shutdown(extension, target);
}
);
/**
* Test case: Storage panel shows extension storage data added prior to extension startup
* - Load extension that adds a storage item
* - Uninstall the extension
* - Reinstall the extension
* - Open the add-on storage panel.
* - The data in the storage panel should match the data added the first time the extension
* was installed
* Related test case: Storage panel shows extension storage data when an extension that has
* already migrated to the IndexedDB storage backend prior to extension startup adds
* another storage item.
* - (Building from previous steps)
* - The reinstalled extension adds a storage item
* - The data in the storage panel should live update with both items: the item added from
* the first and the item added from the reinstall.
*/
add_task(
async function test_panel_data_matches_data_added_prior_to_ext_startup() {
// The pref to leave the addonid->uuid mapping around after uninstall so that we can
// re-attach to the same storage
Services.prefs.setBoolPref(LEAVE_UUID_PREF, true);
// The pref to prevent cleaning up storage on uninstall
Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true);
let extension = await startupExtension(
getExtensionConfig({ background: extensionScriptWithMessageListener })
);
const host = await extension.awaitMessage("extension-origin");
extension.sendMessage("storage-local-set", { a: 123 });
await extension.awaitMessage("storage-local-set:done");
await shutdown(extension);
// Reinstall the same extension
extension = await startupExtension(
getExtensionConfig({ background: extensionScriptWithMessageListener })
);
await extension.awaitMessage("extension-origin");
const { target, extensionStorage } = await openAddonStoragePanel(
extension.id
);
let { data } = await extensionStorage.getStoreObjects(host);
Assert.deepEqual(
data,
[{ area: "local", name: "a", value: { str: "123" } }],
"Got the expected results on populated storage.local"
);
// Related test case
extension.sendMessage("storage-local-fireOnChanged");
extension.sendMessage("storage-local-set", { b: 456 });
await extension.awaitMessage("storage-local-set:done");
await extension.awaitMessage("storage-local-onChanged");
data = (await extensionStorage.getStoreObjects(host)).data;
Assert.deepEqual(
data,
[
{ area: "local", name: "a", value: { str: "123" } },
{ area: "local", name: "b", value: { str: "456" } },
],
"Got the expected results on populated storage.local"
);
Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, false);
Services.prefs.setBoolPref(LEAVE_UUID_PREF, false);
await shutdown(extension, target);
}
);
add_task(
function cleanup_for_test_panel_data_matches_data_added_prior_to_ext_startup() {
Services.prefs.clearUserPref(LEAVE_UUID_PREF);
Services.prefs.clearUserPref(LEAVE_STORAGE_PREF);
}
);
/**
* Test case: Bg page adds an item to storage. With storage panel open, reload extension.
* - Load extension with background page that adds a storage item on message.
* - Open the add-on storage panel.
* - With the storage panel still open, reload the extension.
* - The data in the storage panel should match the item added prior to reloading.
*/
add_task(async function test_panel_live_reload() {
const EXTENSION_ID = "test_panel_live_reload@xpcshell.mozilla.org";
let manifest = {
version: "1.0",
applications: {
gecko: {
id: EXTENSION_ID,
},
},
};
info("Loading extension version 1.0");
const extension = await startupExtension(
getExtensionConfig({
manifest,
background: extensionScriptWithMessageListener,
})
);
info("Waiting for message from test extension");
const host = await extension.awaitMessage("extension-origin");
info("Adding storage item");
extension.sendMessage("storage-local-set", { a: 123 });
await extension.awaitMessage("storage-local-set:done");
const { target, extensionStorage } = await openAddonStoragePanel(
extension.id
);
manifest = {
...manifest,
version: "2.0",
};
// "Reload" is most similar to an upgrade, as e.g. storage data is preserved
info("Update to version 2.0");
await extension.upgrade(
getExtensionConfig({
manifest,
background: extensionScriptWithMessageListener,
})
);
await extension.awaitMessage("extension-origin");
const { data } = await extensionStorage.getStoreObjects(host);
Assert.deepEqual(
data,
[{ area: "local", name: "a", value: { str: "123" } }],
"Got the expected results on populated storage.local"
);
await shutdown(extension, target);
});
/**
* Test case: Transient page adds an item to storage. With storage panel open,
* reload extension.
* - Load extension with no background page.
* - Open transient page that adds a storage item on message.
* - Open the add-on storage panel.
* - With the storage panel still open, reload the extension.
* - The data in the storage panel should match the item added prior to reloading.
*/
add_task(async function test_panel_live_reload_for_extension_without_bg_page() {
const EXTENSION_ID = "test_local_storage_live_reload@xpcshell.mozilla.org";
let manifest = {
version: "1.0",
applications: {
gecko: {
id: EXTENSION_ID,
},
},
};
info("Loading and starting extension version 1.0");
const extension = await startupExtension(
getExtensionConfig({
manifest,
files: ext_no_bg.files,
})
);
info("Opening extension page in a tab");
const url = extension.extension.baseURI.resolve("extension_page_in_tab.html");
const contentPage = await ExtensionTestUtils.loadContentPage(url, {
extension,
});
const host = await extension.awaitMessage("extension-origin");
info("Waiting for extension page in a tab to add storage item");
extension.sendMessage("storage-local-set", { a: 123 });
await extension.awaitMessage("storage-local-set:done");
await contentPage.close();
info("Opening storage panel");
const { target, extensionStorage } = await openAddonStoragePanel(
extension.id
);
manifest = {
...manifest,
version: "2.0",
};
// "Reload" is most similar to an upgrade, as e.g. storage data is preserved
info("Updating extension to version 2.0");
await extension.upgrade(
getExtensionConfig({
manifest,
files: ext_no_bg.files,
})
);
const { data } = await extensionStorage.getStoreObjects(host);
Assert.deepEqual(
data,
[{ area: "local", name: "a", value: { str: "123" } }],
"Got the expected results on populated storage.local"
);
await shutdown(extension, target);
});
/**
* Test case: Bg page auto adds item(s). With storage panel open, reload extension.
* - Load extension with background page that automatically adds a storage item on startup.
* - Open the add-on storage panel.
* - With the storage panel still open, reload the extension.
* - The data in the storage panel should match the item(s) added by the reloaded
* extension.
*/
add_task(
async function test_panel_live_reload_when_extension_auto_adds_items() {
async function background() {
await browser.storage.local.set({ a: { b: 123 }, c: { d: 456 } });
browser.test.sendMessage("extension-origin", window.location.origin);
}
const EXTENSION_ID = "test_local_storage_live_reload@xpcshell.mozilla.org";
let manifest = {
version: "1.0",
applications: {
gecko: {
id: EXTENSION_ID,
},
},
};
info("Loading and starting extension version 1.0");
const extension = await startupExtension(
getExtensionConfig({ manifest, background })
);
info("Waiting for message from test extension");
const host = await extension.awaitMessage("extension-origin");
info("Opening storage panel");
const { target, extensionStorage } = await openAddonStoragePanel(
extension.id
);
manifest = {
...manifest,
version: "2.0",
};
// "Reload" is most similar to an upgrade, as e.g. storage data is preserved
info("Update to version 2.0");
await extension.upgrade(
getExtensionConfig({
manifest,
background,
})
);
await extension.awaitMessage("extension-origin");
const { data } = await extensionStorage.getStoreObjects(host);
Assert.deepEqual(
data,
[
{ area: "local", name: "a", value: { str: '{"b":123}' } },
{ area: "local", name: "c", value: { str: '{"d":456}' } },
],
"Got the expected results on populated storage.local"
);
await shutdown(extension, target);
}
);
/*
* This task should be last, as it sets a pref to disable the extensionStorage
* storage actor. Since this pref is set at the beginning of the file, it
* already will be cleared via registerCleanupFunction when the test finishes.
*/
add_task(async function test_extensionStorage_store_disabled_on_pref() {
Services.prefs.setBoolPref(EXTENSION_STORAGE_ENABLED_PREF, false);
const extension = await startupExtension(getExtensionConfig());
const { target, extensionStorage } = await openAddonStoragePanel(
extension.id
);
ok(
extensionStorage === null,
"Should not have an extensionStorage store when pref disabled"
);
await shutdown(extension, target);
});

View File

@ -60,6 +60,7 @@ skip-if = (os == 'win' && bits == 32) #Bug 1543156
[test_blackboxing-05.js]
[test_blackboxing-07.js]
[test_blackboxing-08.js]
[test_extension_storage_actor.js]
[test_frameactor-01.js]
[test_frameactor-02.js]
[test_frameactor-03.js]

View File

@ -167,6 +167,23 @@ createStorageSpec({
methods: storageMethods,
});
types.addDictType("extensionobject", {
name: "nullable:string",
value: "nullable:longstring",
});
types.addDictType("extensionstoreobject", {
total: "number",
offset: "number",
data: "array:nullable:extensionobject",
});
createStorageSpec({
typeName: "extensionStorage",
storeObjectType: "extensionstoreobject",
methods: {},
});
types.addDictType("cacheobject", {
url: "string",
status: "string",

View File

@ -695,6 +695,9 @@ this.ExtensionStorageIDB = {
promise = migrateJSONFileData(extension, storagePrincipal)
.then(() => {
extension.setSharedData("storageIDBBackend", true);
extension.setSharedData("storageIDBPrincipal", storagePrincipal);
Services.ppmm.sharedData.flush();
return {
backendEnabled: true,
storagePrincipal: serializedPrincipal,
@ -714,6 +717,9 @@ this.ExtensionStorageIDB = {
"JSONFile backend is being kept enabled by an unexpected " +
`IDBBackend failure: ${err.message}::${err.stack}`
);
extension.setSharedData("storageIDBBackend", false);
Services.ppmm.sharedData.flush();
return { backendEnabled: false };
});
}