Bug 1253740 - Implement storage.sync, r?bsilverberg draft
authorMichiel de Jong <mbdejong@mozilla.com>
Thu, 11 Aug 2016 18:16:37 -0400
changeset 400746 69d2d27f7ea2deba44253270a5a64ed92537852d
parent 390795 1ae3b32b315cc8003dc4a6c15bf70019d030f7e2
child 400747 09de144abee47d8971ff1ec6c07f5db6b27559f6
push id26269
push usereglassercamp@mozilla.com
push dateMon, 15 Aug 2016 17:28:13 +0000
reviewersbsilverberg
bugs1253740
milestone50.0a1
Bug 1253740 - Implement storage.sync, r?bsilverberg MozReview-Commit-ID: 5v9nYBTgekj
toolkit/components/extensions/ExtensionStorageSync.jsm
toolkit/components/extensions/ext-storage.js
toolkit/components/extensions/moz.build
toolkit/components/extensions/schemas/storage.json
toolkit/components/extensions/test/mochitest/test_ext_storage.html
toolkit/components/extensions/test/xpcshell/test_storage_sync.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionStorageSync.jsm
@@ -0,0 +1,267 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = ["ExtensionStorageSync"];
+
+const Ci = Components.interfaces;
+const Cc = Components.classes;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+
+const STORAGE_SYNC_ENABLED_PREF = "extension.storage.sync.enabled";
+
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "loadKinto",
+                                  "resource://services-common/kinto-offline-client.js");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage",
+                                  "resource://gre/modules/ExtensionStorage.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AppsUtils",
+                                  "resource://gre/modules/AppsUtils.jsm");
+XPCOMUtils.defineLazyPreferenceGetter(this, "prefPermitsStorageSync",
+                                      STORAGE_SYNC_ENABLED_PREF, false);
+
+/* globals ExtensionStorageSync */
+
+var collectionPromises = new Map();
+
+// An "id schema" used to validate Kinto IDs and generate new ones.
+const storageSyncIdSchema = {
+  // We should never generate IDs; chrome.storage only acts as a
+  // key-value store, so we should always have a key.
+  generate() {
+    throw new Error("cannot generate IDs");
+  },
+
+  // See keyToId and idToKey for more details.
+  validate(id) {
+    return idToKey(id) !== null;
+  }
+}
+
+
+/**
+ * Generate a promise that produces the Collection for an extension.
+ */
+function openCollection(extensionId, context) {
+  // FIXME: This leaks metadata about what extensions a user has
+  // installed.  We should calculate collection ID using a hash of
+  // user ID, extension ID, and some secret.
+  let collectionId = extensionId;
+  const Kinto = loadKinto();
+  let coll;
+  return Promise.resolve().then(() => {
+    // TODO: implement sync process
+    const db = new Kinto({
+      adapter: Kinto.adapters.FirefoxAdapter,
+      adapterOptions: {path: "storage-sync.sqlite"},
+    });
+    coll = db.collection(collectionId, {
+      idSchema: storageSyncIdSchema,
+    });
+    context.callOnClose({
+      close: () => closeCollection(extensionId, coll)
+    });
+    return coll.db.open();
+  }).then(() => {
+    return coll;
+  });
+}
+
+/**
+ * Shut down a collection.
+ */
+function closeCollection(extensionId, collection) {
+  collectionPromises.delete(extensionId);
+  collection.db.close();
+}
+
+
+// Kinto record IDs have two condtions:
+//
+// - They must contain only ASCII alphanumerics plus - and _. To fix
+// this, we encode all non-letters using _C_, where C is the
+// percent-encoded character, so space becomes _20_
+// and underscore becomes _5F_.
+//
+// - They must start with an ASCII letter. To ensure this, we prefix
+// all keys with "key-".
+function keyToId(key) {
+  function escapeChar(match) {
+    return '_' + match.codePointAt(0).toString(16).toUpperCase() + '_';
+  }
+  return "key-" + key.replace(/[^a-zA-Z0-9]/g, escapeChar);
+}
+
+// Convert a Kinto ID back into a chrome.storage key.
+// Returns null if a key couldn't be parsed.
+function idToKey(id) {
+  function unescapeNumber(match, group1) {
+    return String.fromCodePoint(parseInt(group1, 16));
+  }
+  // An escaped ID should match this regex.
+  // An escaped ID should consist of only letters and numbers, plus
+  // code points escaped as _[0-9a-f]+_.
+  const ESCAPED_ID_FORMAT = /[a-zA-Z0-9]*(_[0-9A-F]+_[a-zA-Z0-9]*)*/;
+
+  if(!id.startsWith("key-")) {
+    return null;
+  }
+  var unprefixed = id.slice(4);
+  // Verify that the ID is the correct format.
+  if(!unprefixed.match(ESCAPED_ID_FORMAT)) {
+    return null;
+  }
+  return unprefixed.replace(/_([0-9A-F]+)_/g, unescapeNumber);
+}
+
+this.ExtensionStorageSync = {
+  listeners: new Map(),
+
+  /**
+   * Get the collection for an extension, consulting a cache to
+   * save time.
+   *
+   * @return Promise<Collection>
+   */
+  getCollection(extensionId, context) {
+    if (prefPermitsStorageSync !== true) {
+      return Promise.reject({message: `Please set ${STORAGE_SYNC_ENABLED_PREF} to true in about:config`});
+    }
+    if (!collectionPromises.has(extensionId)) {
+      collectionPromises.set(extensionId, openCollection(extensionId, context));
+    }
+    return collectionPromises.get(extensionId);
+  },
+
+  set(extensionId, items, context) {
+    return this.getCollection(extensionId, context).then(coll => {
+      const keys = Object.keys(items);
+      const ids = keys.map(keyToId);
+      return coll.execute(txn => {
+        let changes = {};
+        for (let i in keys) {
+          const key = keys[i];
+          const id = ids[i];
+          let item = ExtensionStorage.sanitize(items[key], context);
+          let {oldRecord} = txn.upsert({
+            id: id,
+            key: key,
+            data: item
+          });
+          changes[key] = {
+            // Extract the "data" field from the old record, which
+            // represents the value part of the key-value store
+            oldValue: oldRecord && oldRecord.data,
+            newValue: item
+          };
+        }
+        return changes;
+      }, {preloadIds: ids});
+    }).then(changes => this.notifyListeners(extensionId, changes));
+  },
+
+  remove(extensionId, keys, context) {
+    return this.getCollection(extensionId, context).then(coll => {
+      keys = [].concat(keys);
+      const ids = keys.map(keyToId);
+      let changes = {};
+      return coll.execute(txn => {
+        for (let i in keys) {
+          const key = keys[i];
+          const id = ids[i];
+          const res = txn.deleteAny(id);
+          if (res.deleted) {
+            changes[key] = {
+              oldValue: res.data.data,
+              newValue: undefined
+            };
+          }
+        }
+        return changes;
+      }, {preloadIds: ids});
+    }).then(changes => {
+      if (Object.keys(changes).length > 0) {
+        this.notifyListeners(extensionId, changes);
+      }
+    });
+  },
+
+  clear(extensionId, context) {
+    // We can't call Collection#clear here, because that just clears
+    // the local database. We have to explicitly delete everything so
+    // that the deletions can be synced as well.
+    return this.getCollection(extensionId, context).then(coll => {
+      return coll.list();
+    }).then(res => {
+      const records = res.data;
+      const keys = records.map(record => record.key);
+      return this.remove(extensionId, keys);
+    });
+  },
+
+  get(extensionId, spec, context) {
+    return this.getCollection(extensionId, context).then(coll => {
+      let keys, records;
+      if (spec === null) {
+        records = {};
+        return coll.list().then(res => {
+          res.data.map(record => {
+            records[record.key] = record.data;
+          });
+          return records;
+        });
+      }
+      if (typeof spec === "string") {
+        keys = [spec];
+        records = {};
+      } else if (Array.isArray(spec)) {
+        keys = spec;
+        records = {};
+      } else {
+        keys = Object.keys(spec);
+        records = spec;
+      }
+
+      return Promise.all(keys.map(key => {
+        return coll.getAny(keyToId(key)).then(res => {
+          if (res.data && res.data._status != "deleted") {
+            records[res.data.key] = res.data.data;
+            return res.data;
+          }
+        });
+      })).then(() => {
+        return records;
+      });
+    });
+  },
+
+  addOnChangedListener(extensionId, listener) {
+    let listeners = this.listeners.get(extensionId) || new Set();
+    listeners.add(listener);
+    this.listeners.set(extensionId, listeners);
+  },
+
+  removeOnChangedListener(extensionId, listener) {
+    let listeners = this.listeners.get(extensionId);
+    listeners.delete(listener);
+  },
+
+  notifyListeners(extensionId, changes) {
+    let listeners = this.listeners.get(extensionId);
+    if (listeners) {
+      for (let listener of listeners) {
+        try {
+          listener(changes);
+        } catch(e) {
+          Cu.reportError(e);
+        }
+      }
+    }
+  },
+};
--- a/toolkit/components/extensions/ext-storage.js
+++ b/toolkit/components/extensions/ext-storage.js
@@ -1,43 +1,65 @@
 "use strict";
 
 var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage",
                                   "resource://gre/modules/ExtensionStorage.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorageSync",
+                                  "resource://gre/modules/ExtensionStorageSync.jsm");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   EventManager,
 } = ExtensionUtils;
 
 extensions.registerSchemaAPI("storage", (extension, context) => {
   return {
     storage: {
       local: {
-        get: function(keys) {
-          return ExtensionStorage.get(extension.id, keys);
+        get: function(spec) {
+          return ExtensionStorage.get(extension.id, spec);
         },
         set: function(items) {
           return ExtensionStorage.set(extension.id, items, context);
         },
-        remove: function(items) {
-          return ExtensionStorage.remove(extension.id, items);
+        remove: function(keys) {
+          return ExtensionStorage.remove(extension.id, keys);
         },
         clear: function() {
           return ExtensionStorage.clear(extension.id);
         },
       },
 
-      onChanged: new EventManager(context, "storage.local.onChanged", fire => {
-        let listener = changes => {
+      sync: {
+        get: function(spec) {
+          return ExtensionStorageSync.get(extension.id, spec, context);
+        },
+        set: function(items) {
+          return ExtensionStorageSync.set(extension.id, items, context);
+        },
+        remove: function(keys) {
+          return ExtensionStorageSync.remove(extension.id, keys, context);
+        },
+        clear: function() {
+          return ExtensionStorageSync.clear(extension.id, context);
+        },
+      },
+
+      onChanged: new EventManager(context, "storage.onChanged", fire => {
+        let listenerLocal = changes => {
           fire(changes, "local");
         };
+        let listenerSync = changes => {
+          fire(changes, "sync");
+        };
 
-        ExtensionStorage.addOnChangedListener(extension.id, listener);
+        ExtensionStorage.addOnChangedListener(extension.id, listenerLocal);
+        ExtensionStorageSync.addOnChangedListener(extension.id, listenerSync);
         return () => {
-          ExtensionStorage.removeOnChangedListener(extension.id, listener);
+          ExtensionStorage.removeOnChangedListener(extension.id, listenerLocal);
+          ExtensionStorageSync.removeOnChangedListener(extension.id, listenerSync);
         };
       }).api(),
     },
   };
 });
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -4,16 +4,17 @@
 # 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/.
 
 EXTRA_JS_MODULES += [
     'Extension.jsm',
     'ExtensionContent.jsm',
     'ExtensionManagement.jsm',
     'ExtensionStorage.jsm',
+    'ExtensionStorageSync.jsm',
     'ExtensionUtils.jsm',
     'MessageChannel.jsm',
     'NativeMessaging.jsm',
     'Schemas.jsm',
 ]
 
 DIRS += ['schemas']
 
--- a/toolkit/components/extensions/schemas/storage.json
+++ b/toolkit/components/extensions/schemas/storage.json
@@ -172,17 +172,16 @@
             "type": "string",
             "description": "The name of the storage area (<code>\"sync\"</code>, <code>\"local\"</code> or <code>\"managed\"</code>) the changes are for."
           }
         ]
       }
     ],
     "properties": {
       "sync": {
-        "unsupported": true,
         "$ref": "StorageArea",
         "description": "Items in the <code>sync</code> storage area are synced by the browser.",
         "properties": {
           "QUOTA_BYTES": {
             "value": 102400,
             "description": "The maximum total amount (in bytes) of data that can be stored in sync storage, as measured by the JSON stringification of every value plus every key's length. Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError)."
           },
           "QUOTA_BYTES_PER_ITEM": {
--- a/toolkit/components/extensions/test/mochitest/test_ext_storage.html
+++ b/toolkit/components/extensions/test/mochitest/test_ext_storage.html
@@ -8,189 +8,334 @@
   <script type="text/javascript" src="head.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
 </head>
 <body>
 
 <script type="text/javascript">
 "use strict";
 
-function backgroundScript() {
-  let storage = browser.storage.local;
-  function check(prop, value) {
-    return storage.get(null).then(data => {
-      browser.test.assertEq(value, data[prop], "null getter worked for " + prop);
-      return storage.get(prop);
-    }).then(data => {
-      browser.test.assertEq(value, data[prop], "string getter worked for " + prop);
-      return storage.get([prop]);
-    }).then(data => {
-      browser.test.assertEq(value, data[prop], "array getter worked for " + prop);
-      return storage.get({[prop]: undefined});
-    }).then(data => {
-      browser.test.assertEq(value, data[prop], "object getter worked for " + prop);
-    });
-  }
-
-  let globalChanges = {};
-
-  browser.storage.onChanged.addListener((changes, storage) => {
-    browser.test.assertEq("local", storage, "storage is local");
-    Object.assign(globalChanges, changes);
-  });
-
-  function checkChanges(changes) {
-    function checkSub(obj1, obj2) {
-      for (let prop in obj1) {
-        browser.test.assertEq(obj1[prop].oldValue, obj2[prop].oldValue);
-        browser.test.assertEq(obj1[prop].newValue, obj2[prop].newValue);
-      }
-    }
+const STORAGE_SYNC_PREF = "extension.storage.sync.enabled";
 
-    checkSub(changes, globalChanges);
-    checkSub(globalChanges, changes);
-    globalChanges = {};
-  }
-
-  /* eslint-disable dot-notation */
-
-  // Set some data and then test getters.
-  storage.set({"test-prop1": "value1", "test-prop2": "value2"}).then(() => {
-    checkChanges({"test-prop1": {newValue: "value1"}, "test-prop2": {newValue: "value2"}});
-    return check("test-prop1", "value1");
-  }).then(() => {
-    return check("test-prop2", "value2");
-  }).then(() => {
-    return storage.get({"test-prop1": undefined, "test-prop2": undefined, "other": "default"});
-  }).then(data => {
-    browser.test.assertEq("value1", data["test-prop1"], "prop1 correct");
-    browser.test.assertEq("value2", data["test-prop2"], "prop2 correct");
-    browser.test.assertEq("default", data["other"], "other correct");
-    return storage.get(["test-prop1", "test-prop2", "other"]);
-  }).then(data => {
-    browser.test.assertEq("value1", data["test-prop1"], "prop1 correct");
-    browser.test.assertEq("value2", data["test-prop2"], "prop2 correct");
-    browser.test.assertFalse("other" in data, "other correct");
-
-  // Remove data in various ways.
-  }).then(() => {
-    return storage.remove("test-prop1");
-  }).then(() => {
-    checkChanges({"test-prop1": {oldValue: "value1"}});
-    return storage.get(["test-prop1", "test-prop2"]);
-  }).then(data => {
-    browser.test.assertFalse("test-prop1" in data, "prop1 absent");
-    browser.test.assertTrue("test-prop2" in data, "prop2 present");
-
-    return storage.set({"test-prop1": "value1"});
-  }).then(() => {
-    checkChanges({"test-prop1": {newValue: "value1"}});
-    return storage.get(["test-prop1", "test-prop2"]);
+/**
+ * Utility function to ensure that all supported APIs for getting are
+ * tested.
+ */
+function checkGet(areaName, prop, value) {
+  let storage = browser.storage[areaName];
+  return storage.get(null).then(data => {
+    browser.test.assertEq(value, data[prop], `null getter worked for ${prop} in ${areaName}`);
+    return storage.get(prop);
   }).then(data => {
-    browser.test.assertEq("value1", data["test-prop1"], "prop1 correct");
-    browser.test.assertEq("value2", data["test-prop2"], "prop2 correct");
-  }).then(() => {
-    return storage.remove(["test-prop1", "test-prop2"]);
-  }).then(() => {
-    checkChanges({"test-prop1": {oldValue: "value1"}, "test-prop2": {oldValue: "value2"}});
-    return storage.get(["test-prop1", "test-prop2"]);
-  }).then(data => {
-    browser.test.assertFalse("test-prop1" in data, "prop1 absent");
-    browser.test.assertFalse("test-prop2" in data, "prop2 absent");
-
-  // test storage.clear
-  }).then(() => {
-    return storage.set({"test-prop1": "value1", "test-prop2": "value2"});
-  }).then(() => {
-    return storage.clear();
-  }).then(() => {
-    checkChanges({"test-prop1": {oldValue: "value1"}, "test-prop2": {oldValue: "value2"}});
-    return storage.get(["test-prop1", "test-prop2"]);
+    browser.test.assertEq(value, data[prop], `string getter worked for ${prop} in ${areaName}`);
+    return storage.get([prop]);
   }).then(data => {
-    browser.test.assertFalse("test-prop1" in data, "prop1 absent");
-    browser.test.assertFalse("test-prop2" in data, "prop2 absent");
-
-  // Test cache invalidation.
-  }).then(() => {
-    return storage.set({"test-prop1": "value1", "test-prop2": "value2"});
-  }).then(() => {
-    globalChanges = {};
-    browser.test.sendMessage("invalidate");
-    return new Promise(resolve => browser.test.onMessage.addListener(resolve));
-  }).then(() => {
-    return check("test-prop1", "value1");
-  }).then(() => {
-    return check("test-prop2", "value2");
-
-  // Make sure we can store complex JSON data.
-  }).then(() => {
-    return storage.set({
-      "test-prop1": {
-        str: "hello",
-        bool: true,
-        undef: undefined,
-        obj: {},
-        arr: [1, 2],
-        date: new Date(0),
-        regexp: /regexp/,
-        func: function func() {},
-        window,
-      },
-    });
-  }).then(() => {
-    return storage.set({"test-prop2": function func() {}});
-  }).then(() => {
-    browser.test.assertEq("value1", globalChanges["test-prop1"].oldValue, "oldValue correct");
-    browser.test.assertEq("object", typeof(globalChanges["test-prop1"].newValue), "newValue is obj");
-    globalChanges = {};
-    return storage.get({"test-prop1": undefined, "test-prop2": undefined});
+    browser.test.assertEq(value, data[prop], `array getter worked for ${prop} in ${areaName}`);
+    return storage.get({[prop]: undefined});
   }).then(data => {
-    let obj = data["test-prop1"];
-
-    browser.test.assertEq("hello", obj.str, "string part correct");
-    browser.test.assertEq(true, obj.bool, "bool part correct");
-    browser.test.assertEq(undefined, obj.undef, "undefined part correct");
-    browser.test.assertEq(undefined, obj.func, "function part correct");
-    browser.test.assertEq(undefined, obj.window, "window part correct");
-    browser.test.assertEq("1970-01-01T00:00:00.000Z", obj.date, "date part correct");
-    browser.test.assertEq("/regexp/", obj.regexp, "date part correct");
-    browser.test.assertEq("object", typeof(obj.obj), "object part correct");
-    browser.test.assertTrue(Array.isArray(obj.arr), "array part present");
-    browser.test.assertEq(1, obj.arr[0], "arr[0] part correct");
-    browser.test.assertEq(2, obj.arr[1], "arr[1] part correct");
-    browser.test.assertEq(2, obj.arr.length, "arr.length part correct");
-
-    obj = data["test-prop2"];
-
-    browser.test.assertEq("[object Object]", {}.toString.call(obj), "function serialized as a plain object");
-    browser.test.assertEq(0, Object.keys(obj).length, "function serialized as an empty object");
-  }).then(() => {
-    browser.test.notifyPass("storage");
-  }).catch(e => {
-    browser.test.fail(`Error: ${e} :: ${e.stack}`);
-    browser.test.notifyFail("storage");
+    browser.test.assertEq(value, data[prop], `object getter worked for ${prop} in ${areaName}`);
   });
 }
 
-let extensionData = {
-  background: "(" + backgroundScript.toString() + ")()",
-  manifest: {
-    permissions: ["storage"],
-  },
-};
+add_task(function* test_local_cache_invalidation() {
+  function background(checkGet) {
+    browser.test.onMessage.addListener(msg => {
+      if (msg === "set-initial") {
+        browser.storage.local.set({"test-prop1": "value1", "test-prop2": "value2"}).then(() => {
+          browser.test.sendMessage("set-initial-done");
+        });
+      } else if (msg === "check") {
+        checkGet("local", "test-prop1", "value1").then(() => {
+          return checkGet("local", "test-prop2", "value2");
+        }).then(() => {
+          browser.test.sendMessage("check-done");
+        });
+      }
+    });
+
+    browser.test.sendMessage("ready");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      permissions: ["storage"],
+    },
+    background: `(${background})(${checkGet})`,
+  });
+
+  yield extension.startup();
+  yield extension.awaitMessage("ready");
+
+  extension.sendMessage("set-initial");
+  yield extension.awaitMessage("set-initial-done");
+
+  SpecialPowers.invalidateExtensionStorageCache();
+
+  extension.sendMessage("check");
+  yield extension.awaitMessage("check-done");
+
+  yield extension.unload();
+});
+
+add_task(function* test_config_flag_needed() {
+  function background() {
+    let promises = [];
+    let apiTests = [
+      {method: "get", args: ["foo"], result: {}},
+      {method: "set", args: [{foo: "bar"}], result: undefined},
+      {method: "remove", args: ["foo"], result: undefined},
+      {method: "clear", args: [], result: undefined},
+    ];
+    apiTests.forEach(testDef => {
+      promises.push(new Promise((resolve, reject) => {
+        browser.storage.sync[testDef.method].apply(undefined, testDef.args).then(
+          () => reject("didn't fail with extension.storage.sync.enabled = false"),
+          error => {
+            browser.test.assertEq("Please set extension.storage.sync.enabled to " +
+                                  "true in about:config", error.message,
+                                  `storage.sync.${testDef.method} is behind a flag`);
+            resolve();
+          });
+      }));
+    });
+
+    Promise.all(promises).then(() => browser.test.notifyPass("flag needed"));
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      permissions: ["storage"],
+    },
+    background: `(${background})(${checkGet})`,
+  });
+
+  yield extension.startup();
+  yield extension.awaitFinish("flag needed");
+  yield extension.unload();
+});
+
+add_task(function* test_reloading_extensions_works() {
+  // Just some random extension ID that we can re-use
+  const extensionId = "my-extension-id@1";
+
+  function loadExtension() {
+    function background() {
+      browser.storage.sync.set({"a": "b"}).then(() => {
+        browser.test.notifyPass("set-works");
+      });
+    }
+
+    return ExtensionTestUtils.loadExtension({
+      manifest: {
+        permissions: ["storage"],
+      },
+      background: `(${background})()`,
+    }, extensionId);
+  }
+
+  SpecialPowers.setBoolPref(STORAGE_SYNC_PREF, true);
+  SimpleTest.registerCleanupFunction(() => {
+    SpecialPowers.clearUserPref(STORAGE_SYNC_PREF);
+  });
+
+  let extension1 = loadExtension();
+
+  yield extension1.startup();
+  yield extension1.awaitFinish("set-works");
+  yield extension1.unload();
+
+  let extension2 = loadExtension();
+
+  yield extension2.startup();
+  yield extension2.awaitFinish("set-works");
+  yield extension2.unload();
+
+  SpecialPowers.clearUserPref(STORAGE_SYNC_PREF);
+});
 
 add_task(function* test_backgroundScript() {
-  let extension = ExtensionTestUtils.loadExtension(extensionData);
+  function backgroundScript(checkGet) {
+    let globalChanges = {};
+    let expectedAreaName;
+
+    browser.storage.onChanged.addListener((changes, areaName) => {
+      browser.test.assertEq(expectedAreaName, areaName,
+        "Expected area name received by listener");
+      Object.assign(globalChanges, changes);
+    });
+
+    function checkChanges(areaName, changes, message) {
+      function checkSub(obj1, obj2) {
+        for (let prop in obj1) {
+          browser.test.assertTrue(obj1[prop] !== undefined && obj2[prop] !== undefined,
+                                  `checkChanges ${areaName} ${prop} is missing (${message})`);
+          browser.test.assertEq(obj1[prop].oldValue, obj2[prop].oldValue,
+                                `checkChanges ${areaName} ${prop} old (${message})`);
+          browser.test.assertEq(obj1[prop].newValue, obj2[prop].newValue,
+                                `checkChanges ${areaName} ${prop} new (${message})`);
+        }
+      }
+
+      checkSub(changes, globalChanges);
+      checkSub(globalChanges, changes);
+      globalChanges = {};
+    }
 
-  yield extension.startup();
+    /* eslint-disable dot-notation */
+    function runTests(areaName) {
+      expectedAreaName = areaName;
+      let storage = browser.storage[areaName];
+      // Set some data and then test getters.
+      return storage.set({"test-prop1": "value1", "test-prop2": "value2"}).then(() => {
+        checkChanges(areaName,
+          {"test-prop1": {newValue: "value1"}, "test-prop2": {newValue: "value2"}},
+          "set (a)");
+        return checkGet(areaName, "test-prop1", "value1");
+      }).then(() => {
+        return checkGet(areaName, "test-prop2", "value2");
+      }).then(() => {
+        return storage.get({"test-prop1": undefined, "test-prop2": undefined, "other": "default"});
+      }).then(data => {
+        browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (a)");
+        browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (a)");
+        browser.test.assertEq("default", data["other"], "other correct");
+        return storage.get(["test-prop1", "test-prop2", "other"]);
+      }).then(data => {
+        browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (b)");
+        browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (b)");
+        browser.test.assertFalse("other" in data, "other correct");
+
+        // Remove data in various ways.
+      }).then(() => {
+        return storage.remove("test-prop1");
+      }).then(() => {
+        checkChanges(areaName, {"test-prop1": {oldValue: "value1"}}, "remove string");
+        return storage.get(["test-prop1", "test-prop2"]);
+      }).then(data => {
+        browser.test.assertFalse("test-prop1" in data, "prop1 absent (remove string)");
+        browser.test.assertTrue("test-prop2" in data, "prop2 present (remove string)");
+
+        return storage.set({"test-prop1": "value1"});
+      }).then(() => {
+        checkChanges(areaName, {"test-prop1": {newValue: "value1"}}, "set (c)");
+        return storage.get(["test-prop1", "test-prop2"]);
+      }).then(data => {
+        browser.test.assertEq(data["test-prop1"], "value1", "prop1 correct (c)");
+        browser.test.assertEq(data["test-prop2"], "value2", "prop2 correct (c)");
+      }).then(() => {
+        return storage.remove(["test-prop1", "test-prop2"]);
+      }).then(() => {
+        checkChanges(areaName,
+          {"test-prop1": {oldValue: "value1"}, "test-prop2": {oldValue: "value2"}},
+          "remove array");
+        return storage.get(["test-prop1", "test-prop2"]);
+      }).then(data => {
+        browser.test.assertFalse("test-prop1" in data, "prop1 absent (remove array)");
+        browser.test.assertFalse("test-prop2" in data, "prop2 absent (remove array)");
 
-  yield extension.awaitMessage("invalidate");
-  SpecialPowers.invalidateExtensionStorageCache();
-  extension.sendMessage("invalidated");
+        // test storage.clear
+      }).then(() => {
+        return storage.set({"test-prop1": "value1", "test-prop2": "value2"});
+      }).then(() => {
+        return storage.clear();
+      }).then(() => {
+        checkChanges(areaName,
+          {"test-prop1": {oldValue: "value1"}, "test-prop2": {oldValue: "value2"}},
+          "clear");
+        return storage.get(["test-prop1", "test-prop2"]);
+      }).then(data => {
+        browser.test.assertFalse("test-prop1" in data, "prop1 absent (clear)");
+        browser.test.assertFalse("test-prop2" in data, "prop2 absent (clear)");
+
+        // Make sure we can store complex JSON data.
+      }).then(() => {
+        // known previous values
+        return storage.set({"test-prop1": "value1", "test-prop2": "value2"});
+      }).then(() => {
+        return storage.set({
+          "test-prop1": {
+            str: "hello",
+            bool: true,
+            undef: undefined,
+            obj: {},
+            arr: [1, 2],
+            date: new Date(0),
+            regexp: /regexp/,
+            func: function func() {},
+            window,
+          },
+        });
+      }).then(() => {
+        return storage.set({"test-prop2": function func() {}});
+      }).then(() => {
+        browser.test.assertEq("value1", globalChanges["test-prop1"].oldValue, "oldValue correct");
+        browser.test.assertEq("object", typeof(globalChanges["test-prop1"].newValue), "newValue is obj");
+        globalChanges = {};
+        return storage.get({"test-prop1": undefined, "test-prop2": undefined});
+      }).then(data => {
+        let obj = data["test-prop1"];
 
-  yield extension.awaitFinish("storage");
+        browser.test.assertEq("hello", obj.str, "string part correct");
+        browser.test.assertEq(true, obj.bool, "bool part correct");
+        browser.test.assertEq(undefined, obj.undef, "undefined part correct");
+        browser.test.assertEq(undefined, obj.func, "function part correct");
+        browser.test.assertEq(undefined, obj.window, "window part correct");
+        browser.test.assertEq("1970-01-01T00:00:00.000Z", obj.date, "date part correct");
+        browser.test.assertEq("/regexp/", obj.regexp, "regexp part correct");
+        browser.test.assertEq("object", typeof(obj.obj), "object part correct");
+        browser.test.assertTrue(Array.isArray(obj.arr), "array part present");
+        browser.test.assertEq(1, obj.arr[0], "arr[0] part correct");
+        browser.test.assertEq(2, obj.arr[1], "arr[1] part correct");
+        browser.test.assertEq(2, obj.arr.length, "arr.length part correct");
+
+        obj = data["test-prop2"];
+
+        browser.test.assertEq("[object Object]", {}.toString.call(obj), "function serialized as a plain object");
+        browser.test.assertEq(0, Object.keys(obj).length, "function serialized as an empty object");
+      }).catch(e => {
+        browser.test.fail(`Error: ${e} :: ${e.stack}`);
+        browser.test.notifyFail("storage");
+      });
+    }
+
+    browser.test.onMessage.addListener(msg => {
+      let promise;
+      if (msg === "test-local") {
+        promise = runTests("local");
+      } else if (msg === "test-sync") {
+        promise = runTests("sync");
+      }
+      promise.then(() => browser.test.sendMessage("test-finished"));
+    });
+
+    browser.test.sendMessage("ready");
+  }
+
+  let extensionData = {
+    background: `(${backgroundScript})(${checkGet})`,
+    manifest: {
+      permissions: ["storage"],
+    },
+  };
+
+  SpecialPowers.setBoolPref(STORAGE_SYNC_PREF, true);
+  SimpleTest.registerCleanupFunction(() => {
+    SpecialPowers.clearUserPref(STORAGE_SYNC_PREF);
+  });
+
+  let extension = ExtensionTestUtils.loadExtension(extensionData);
+  yield extension.startup();
+  yield extension.awaitMessage("ready");
+
+  extension.sendMessage("test-local");
+  yield extension.awaitMessage("test-finished");
+
+  extension.sendMessage("test-sync");
+  yield extension.awaitMessage("test-finished");
+
+  SpecialPowers.clearUserPref(STORAGE_SYNC_PREF);
   yield extension.unload();
 });
 
 </script>
 
 </body>
 </html>
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_storage_sync.js
@@ -0,0 +1,15 @@
+"use strict";
+
+const {keyToId, idToKey} = Cu.import("resource://gre/modules/ExtensionStorageSync.jsm");
+
+function run_test() {
+  equal(keyToId("foo"), "key-foo");
+  equal(keyToId("my-new-key"), "key-my_2D_new_2D_key");
+  equal(keyToId(""), "key-");
+  equal(keyToId("Kinto's fancy_string"), "key-Kinto_27_s_20_fancy_5F_string");
+
+  const KEYS = ["foo", "my-new-key", "", "Kinto's fancy_string"];
+  for(let key of KEYS) {
+    equal(idToKey(keyToId(key)), key);
+  }
+}
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -8,11 +8,12 @@ skip-if = toolkit == 'gonk' || appname =
 [test_csp_validator.js]
 [test_locale_data.js]
 [test_locale_converter.js]
 [test_ext_contexts.js]
 [test_ext_json_parser.js]
 [test_ext_manifest_content_security_policy.js]
 [test_ext_manifest_incognito.js]
 [test_ext_schemas.js]
+[test_storage_sync.js]
 [test_getAPILevelForWindow.js]
 [test_native_messaging.js]
 skip-if = os == "android"