зеркало из https://github.com/mozilla/gecko-dev.git
Backed out changeset 396333163897 (bug 1253740) for failing xpcshell /test_ext_storage.js on Android and Linux. r=backout on a CLOSED TREE
This commit is contained in:
Родитель
900199872a
Коммит
4fdd9389f6
|
@ -5444,9 +5444,6 @@ pref("webextensions.tests", false);
|
||||||
// 16MB default non-parseable upload limit for requestBody.raw.bytes
|
// 16MB default non-parseable upload limit for requestBody.raw.bytes
|
||||||
pref("webextensions.webRequest.requestBodyMaxRawBytes", 16777216);
|
pref("webextensions.webRequest.requestBodyMaxRawBytes", 16777216);
|
||||||
|
|
||||||
// This functionality is still experimental
|
|
||||||
pref("webextensions.storage.sync.enabled", false);
|
|
||||||
|
|
||||||
// Allow customization of the fallback directory for file uploads
|
// Allow customization of the fallback directory for file uploads
|
||||||
pref("dom.input.fallbackUploadDir", "");
|
pref("dom.input.fallbackUploadDir", "");
|
||||||
|
|
||||||
|
|
|
@ -1,337 +0,0 @@
|
||||||
/* 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 global = this;
|
|
||||||
|
|
||||||
const STORAGE_SYNC_ENABLED_PREF = "webextensions.storage.sync.enabled";
|
|
||||||
|
|
||||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
|
||||||
const {
|
|
||||||
runSafeSyncWithoutClone,
|
|
||||||
} = Cu.import("resource://gre/modules/ExtensionUtils.jsm");
|
|
||||||
|
|
||||||
XPCOMUtils.defineLazyModuleGetter(this, "AppsUtils",
|
|
||||||
"resource://gre/modules/AppsUtils.jsm");
|
|
||||||
XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage",
|
|
||||||
"resource://gre/modules/ExtensionStorage.jsm");
|
|
||||||
XPCOMUtils.defineLazyModuleGetter(this, "loadKinto",
|
|
||||||
"resource://services-common/kinto-offline-client.js");
|
|
||||||
XPCOMUtils.defineLazyModuleGetter(this, "Task",
|
|
||||||
"resource://gre/modules/Task.jsm");
|
|
||||||
XPCOMUtils.defineLazyPreferenceGetter(this, "prefPermitsStorageSync",
|
|
||||||
STORAGE_SYNC_ENABLED_PREF, false);
|
|
||||||
|
|
||||||
/* globals prefPermitsStorageSync */
|
|
||||||
|
|
||||||
// Map of Extensions to Promise<Collections>.
|
|
||||||
const collectionPromises = new Map();
|
|
||||||
// Map of Extensions to Set<Contexts> to track contexts that are still
|
|
||||||
// "live" and could still use this collection.
|
|
||||||
const extensionContexts = new WeakMap();
|
|
||||||
|
|
||||||
// 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]+_)*$/;
|
|
||||||
|
|
||||||
if (!id.startsWith("key-")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const unprefixed = id.slice(4);
|
|
||||||
// Verify that the ID is the correct format.
|
|
||||||
if (!ESCAPED_ID_FORMAT.test(unprefixed)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return unprefixed.replace(/_([0-9A-F]+)_/g, unescapeNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a KintoBase object, suitable for using in Firefox.
|
|
||||||
*
|
|
||||||
* This centralizes the logic used to create Kinto instances, which
|
|
||||||
* we will need to do in several places.
|
|
||||||
*
|
|
||||||
* @returns {Kinto}
|
|
||||||
*/
|
|
||||||
function makeKinto() {
|
|
||||||
const Kinto = loadKinto();
|
|
||||||
return new Kinto({
|
|
||||||
adapter: Kinto.adapters.FirefoxAdapter,
|
|
||||||
adapterOptions: {path: "storage-sync.sqlite"},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Actually for-real close the collection associated with a
|
|
||||||
* collection.
|
|
||||||
*
|
|
||||||
* @param {Extension} extension
|
|
||||||
* The extension whose uses are all over.
|
|
||||||
* @returns {Promise<()>} Promise that resolves when everything is clean.
|
|
||||||
*/
|
|
||||||
function closeExtensionCollection(extension) {
|
|
||||||
const collectionPromise = collectionPromises.get(extension);
|
|
||||||
if (!collectionPromise) {
|
|
||||||
Cu.reportError(new Error(`Internal error: trying to close extension ${extension.id}` +
|
|
||||||
"that doesn't have a collection"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
collectionPromises.delete(extension);
|
|
||||||
return collectionPromise.then(coll => {
|
|
||||||
return coll.db.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up now that one context is no longer using this extension's collection.
|
|
||||||
*
|
|
||||||
* @param {Extension} extension
|
|
||||||
* The extension whose context just ended.
|
|
||||||
* @param {Context} context
|
|
||||||
* The context that just ended.
|
|
||||||
* @returns {Promise<()>} Promise that resolves when everything is clean.
|
|
||||||
*/
|
|
||||||
function cleanUpForContext(extension, context) {
|
|
||||||
const contexts = extensionContexts.get(extension);
|
|
||||||
if (!contexts) {
|
|
||||||
Cu.reportError(new Error(`Internal error: cannot find any contexts for extension ${extension.id}`));
|
|
||||||
// Try to shut down cleanly anyhow?
|
|
||||||
return closeExtensionCollection(extension);
|
|
||||||
}
|
|
||||||
contexts.delete(context);
|
|
||||||
if (contexts.size === 0) {
|
|
||||||
// Nobody else is using this collection. Clean up.
|
|
||||||
extensionContexts.delete(extension);
|
|
||||||
return closeExtensionCollection(extension);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a promise that produces the Collection for an extension.
|
|
||||||
*
|
|
||||||
* @param {Extension} extension
|
|
||||||
* The extension whose collection needs to
|
|
||||||
* be opened.
|
|
||||||
* @param {Context} context
|
|
||||||
* The context for this extension. The Collection
|
|
||||||
* will shut down automatically when all contexts
|
|
||||||
* close.
|
|
||||||
* @returns {Promise<Collection>}
|
|
||||||
*/
|
|
||||||
const openCollection = Task.async(function* (extension, 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 = extension.id;
|
|
||||||
// TODO: implement sync process
|
|
||||||
const db = makeKinto();
|
|
||||||
const coll = db.collection(collectionId, {
|
|
||||||
idSchema: storageSyncIdSchema,
|
|
||||||
});
|
|
||||||
yield coll.db.open();
|
|
||||||
return coll;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.ExtensionStorageSync = {
|
|
||||||
listeners: new WeakMap(),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the collection for an extension, consulting a cache to
|
|
||||||
* save time.
|
|
||||||
*
|
|
||||||
* @param {Extension} extension
|
|
||||||
* The extension for which we are seeking
|
|
||||||
* a collection.
|
|
||||||
* @param {Context} context
|
|
||||||
* The context of the extension, so that we can
|
|
||||||
* clean up the collection when the extension ends.
|
|
||||||
* @returns {Promise<Collection>}
|
|
||||||
*/
|
|
||||||
getCollection(extension, context) {
|
|
||||||
if (prefPermitsStorageSync !== true) {
|
|
||||||
return Promise.reject({message: `Please set ${STORAGE_SYNC_ENABLED_PREF} to true in about:config`});
|
|
||||||
}
|
|
||||||
if (!collectionPromises.has(extension)) {
|
|
||||||
const collectionPromise = openCollection(extension, context);
|
|
||||||
collectionPromises.set(extension, collectionPromise);
|
|
||||||
collectionPromise.catch(Cu.reportError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register that the extension and context are in use.
|
|
||||||
if (!extensionContexts.has(extension)) {
|
|
||||||
extensionContexts.set(extension, new Set());
|
|
||||||
}
|
|
||||||
const contexts = extensionContexts.get(extension);
|
|
||||||
if (!contexts.has(context)) {
|
|
||||||
// New context. Register it and make sure it cleans itself up
|
|
||||||
// when it closes.
|
|
||||||
contexts.add(context);
|
|
||||||
context.callOnClose({
|
|
||||||
close: () => cleanUpForContext(extension, context),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return collectionPromises.get(extension);
|
|
||||||
},
|
|
||||||
|
|
||||||
set: Task.async(function* (extension, items, context) {
|
|
||||||
const coll = yield this.getCollection(extension, context);
|
|
||||||
const keys = Object.keys(items);
|
|
||||||
const ids = keys.map(keyToId);
|
|
||||||
const changes = yield coll.execute(txn => {
|
|
||||||
let changes = {};
|
|
||||||
for (let [i, key] of keys.entries()) {
|
|
||||||
const id = ids[i];
|
|
||||||
let item = items[key];
|
|
||||||
let {oldRecord} = txn.upsert({
|
|
||||||
id,
|
|
||||||
key,
|
|
||||||
data: item,
|
|
||||||
});
|
|
||||||
changes[key] = {
|
|
||||||
newValue: item,
|
|
||||||
};
|
|
||||||
if (oldRecord && oldRecord.data) {
|
|
||||||
// Extract the "data" field from the old record, which
|
|
||||||
// represents the value part of the key-value store
|
|
||||||
changes[key].oldValue = oldRecord.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changes;
|
|
||||||
}, {preloadIds: ids});
|
|
||||||
this.notifyListeners(extension, changes);
|
|
||||||
}),
|
|
||||||
|
|
||||||
remove: Task.async(function* (extension, keys, context) {
|
|
||||||
const coll = yield this.getCollection(extension, context);
|
|
||||||
keys = [].concat(keys);
|
|
||||||
const ids = keys.map(keyToId);
|
|
||||||
let changes = {};
|
|
||||||
yield coll.execute(txn => {
|
|
||||||
for (let [i, key] of keys.entries()) {
|
|
||||||
const id = ids[i];
|
|
||||||
const res = txn.deleteAny(id);
|
|
||||||
if (res.deleted) {
|
|
||||||
changes[key] = {
|
|
||||||
oldValue: res.data.data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changes;
|
|
||||||
}, {preloadIds: ids});
|
|
||||||
if (Object.keys(changes).length > 0) {
|
|
||||||
this.notifyListeners(extension, changes);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
clear: Task.async(function* (extension, 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.
|
|
||||||
const coll = yield this.getCollection(extension, context);
|
|
||||||
const res = yield coll.list();
|
|
||||||
const records = res.data;
|
|
||||||
const keys = records.map(record => record.key);
|
|
||||||
yield this.remove(extension, keys, context);
|
|
||||||
}),
|
|
||||||
|
|
||||||
get: Task.async(function* (extension, spec, context) {
|
|
||||||
const coll = yield this.getCollection(extension, context);
|
|
||||||
let keys, records;
|
|
||||||
if (spec === null) {
|
|
||||||
records = {};
|
|
||||||
const res = yield coll.list();
|
|
||||||
for (let record of res.data) {
|
|
||||||
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 = Cu.cloneInto(spec, global);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let key of keys) {
|
|
||||||
const res = yield coll.getAny(keyToId(key));
|
|
||||||
if (res.data && res.data._status != "deleted") {
|
|
||||||
records[res.data.key] = res.data.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return records;
|
|
||||||
}),
|
|
||||||
|
|
||||||
addOnChangedListener(extension, listener) {
|
|
||||||
let listeners = this.listeners.get(extension) || new Set();
|
|
||||||
listeners.add(listener);
|
|
||||||
this.listeners.set(extension, listeners);
|
|
||||||
},
|
|
||||||
|
|
||||||
removeOnChangedListener(extension, listener) {
|
|
||||||
let listeners = this.listeners.get(extension);
|
|
||||||
listeners.delete(listener);
|
|
||||||
if (listeners.size == 0) {
|
|
||||||
this.listeners.delete(extension);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
notifyListeners(extension, changes) {
|
|
||||||
let listeners = this.listeners.get(extension) || new Set();
|
|
||||||
if (listeners) {
|
|
||||||
for (let listener of listeners) {
|
|
||||||
runSafeSyncWithoutClone(listener, changes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -41,21 +41,6 @@ function storageApiFactory(context) {
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
sync: {
|
|
||||||
get: function(keys) {
|
|
||||||
keys = sanitize(keys);
|
|
||||||
return context.childManager.callParentAsyncFunction("storage.sync.get", [
|
|
||||||
keys,
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
set: function(items) {
|
|
||||||
items = sanitize(items);
|
|
||||||
return context.childManager.callParentAsyncFunction("storage.sync.set", [
|
|
||||||
items,
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,6 @@ var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
||||||
|
|
||||||
XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage",
|
XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage",
|
||||||
"resource://gre/modules/ExtensionStorage.jsm");
|
"resource://gre/modules/ExtensionStorage.jsm");
|
||||||
XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorageSync",
|
|
||||||
"resource://gre/modules/ExtensionStorageSync.jsm");
|
|
||||||
|
|
||||||
Cu.import("resource://gre/modules/ExtensionUtils.jsm");
|
Cu.import("resource://gre/modules/ExtensionUtils.jsm");
|
||||||
var {
|
var {
|
||||||
|
@ -17,48 +15,28 @@ function storageApiFactory(context) {
|
||||||
return {
|
return {
|
||||||
storage: {
|
storage: {
|
||||||
local: {
|
local: {
|
||||||
get: function(spec) {
|
get: function(keys) {
|
||||||
return ExtensionStorage.get(extension.id, spec);
|
return ExtensionStorage.get(extension.id, keys);
|
||||||
},
|
},
|
||||||
set: function(items) {
|
set: function(items) {
|
||||||
return ExtensionStorage.set(extension.id, items, context);
|
return ExtensionStorage.set(extension.id, items, context);
|
||||||
},
|
},
|
||||||
remove: function(keys) {
|
remove: function(items) {
|
||||||
return ExtensionStorage.remove(extension.id, keys);
|
return ExtensionStorage.remove(extension.id, items);
|
||||||
},
|
},
|
||||||
clear: function() {
|
clear: function() {
|
||||||
return ExtensionStorage.clear(extension.id);
|
return ExtensionStorage.clear(extension.id);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
sync: {
|
onChanged: new EventManager(context, "storage.local.onChanged", fire => {
|
||||||
get: function(spec) {
|
let listener = changes => {
|
||||||
return ExtensionStorageSync.get(extension, spec, context);
|
|
||||||
},
|
|
||||||
set: function(items) {
|
|
||||||
return ExtensionStorageSync.set(extension, items, context);
|
|
||||||
},
|
|
||||||
remove: function(keys) {
|
|
||||||
return ExtensionStorageSync.remove(extension, keys, context);
|
|
||||||
},
|
|
||||||
clear: function() {
|
|
||||||
return ExtensionStorageSync.clear(extension, context);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
onChanged: new EventManager(context, "storage.onChanged", fire => {
|
|
||||||
let listenerLocal = changes => {
|
|
||||||
fire(changes, "local");
|
fire(changes, "local");
|
||||||
};
|
};
|
||||||
let listenerSync = changes => {
|
|
||||||
fire(changes, "sync");
|
|
||||||
};
|
|
||||||
|
|
||||||
ExtensionStorage.addOnChangedListener(extension.id, listenerLocal);
|
ExtensionStorage.addOnChangedListener(extension.id, listener);
|
||||||
ExtensionStorageSync.addOnChangedListener(extension, listenerSync);
|
|
||||||
return () => {
|
return () => {
|
||||||
ExtensionStorage.removeOnChangedListener(extension.id, listenerLocal);
|
ExtensionStorage.removeOnChangedListener(extension.id, listener);
|
||||||
ExtensionStorageSync.removeOnChangedListener(extension, listenerSync);
|
|
||||||
};
|
};
|
||||||
}).api(),
|
}).api(),
|
||||||
},
|
},
|
||||||
|
|
|
@ -11,7 +11,6 @@ EXTRA_JS_MODULES += [
|
||||||
'ExtensionContent.jsm',
|
'ExtensionContent.jsm',
|
||||||
'ExtensionManagement.jsm',
|
'ExtensionManagement.jsm',
|
||||||
'ExtensionStorage.jsm',
|
'ExtensionStorage.jsm',
|
||||||
'ExtensionStorageSync.jsm',
|
|
||||||
'ExtensionUtils.jsm',
|
'ExtensionUtils.jsm',
|
||||||
'LegacyExtensionsUtils.jsm',
|
'LegacyExtensionsUtils.jsm',
|
||||||
'MessageChannel.jsm',
|
'MessageChannel.jsm',
|
||||||
|
|
|
@ -179,6 +179,7 @@
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"sync": {
|
"sync": {
|
||||||
|
"unsupported": true,
|
||||||
"$ref": "StorageArea",
|
"$ref": "StorageArea",
|
||||||
"description": "Items in the <code>sync</code> storage area are synced by the browser.",
|
"description": "Items in the <code>sync</code> storage area are synced by the browser.",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
@ -2,276 +2,119 @@
|
||||||
/* vim: set sts=2 sw=2 et tw=80: */
|
/* vim: set sts=2 sw=2 et tw=80: */
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const STORAGE_SYNC_PREF = "webextensions.storage.sync.enabled";
|
function backgroundScript() {
|
||||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
let storage = browser.storage.local;
|
||||||
|
function check(prop, value) {
|
||||||
/**
|
|
||||||
* Utility function to ensure that all supported APIs for getting are
|
|
||||||
* tested.
|
|
||||||
*
|
|
||||||
* @param {string} areaName
|
|
||||||
* either "local" or "sync" according to what we want to test
|
|
||||||
* @param {string} prop
|
|
||||||
* "key" to look up using the storage API
|
|
||||||
* @param {Object} value
|
|
||||||
* "value" to compare against
|
|
||||||
* @returns {Promise}
|
|
||||||
*/
|
|
||||||
function checkGet(areaName, prop, value) {
|
|
||||||
let storage = browser.storage[areaName];
|
|
||||||
return storage.get(null).then(data => {
|
return storage.get(null).then(data => {
|
||||||
browser.test.assertEq(value, data[prop], `null getter worked for ${prop} in ${areaName}`);
|
browser.test.assertEq(value, data[prop], "null getter worked for " + prop);
|
||||||
return storage.get(prop);
|
return storage.get(prop);
|
||||||
}).then(data => {
|
}).then(data => {
|
||||||
browser.test.assertEq(value, data[prop], `string getter worked for ${prop} in ${areaName}`);
|
browser.test.assertEq(value, data[prop], "string getter worked for " + prop);
|
||||||
return storage.get([prop]);
|
return storage.get([prop]);
|
||||||
}).then(data => {
|
}).then(data => {
|
||||||
browser.test.assertEq(value, data[prop], `array getter worked for ${prop} in ${areaName}`);
|
browser.test.assertEq(value, data[prop], "array getter worked for " + prop);
|
||||||
return storage.get({[prop]: undefined});
|
return storage.get({[prop]: undefined});
|
||||||
}).then(data => {
|
}).then(data => {
|
||||||
browser.test.assertEq(value, data[prop], `object getter worked for ${prop} in ${areaName}`);
|
browser.test.assertEq(value, data[prop], "object getter worked for " + prop);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
Services.obs.notifyObservers(null, "extension-invalidate-storage-cache", "");
|
|
||||||
|
|
||||||
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"]},
|
|
||||||
{method: "set", args: [{foo: "bar"}]},
|
|
||||||
{method: "remove", args: ["foo"]},
|
|
||||||
{method: "clear", args: []},
|
|
||||||
];
|
|
||||||
apiTests.forEach(testDef => {
|
|
||||||
const test = browser.storage.sync[testDef.method](...testDef.args).then(() => {
|
|
||||||
browser.test.fail("didn't fail with extension.storage.sync.enabled = false");
|
|
||||||
return Promise.reject();
|
|
||||||
}).catch(error => {
|
|
||||||
browser.test.assertEq("Please set webextensions.storage.sync.enabled to " +
|
|
||||||
"true in about:config", error.message,
|
|
||||||
`storage.sync.${testDef.method} is behind a flag`);
|
|
||||||
return Promise.resolve();
|
|
||||||
});
|
|
||||||
promises.push(test);
|
|
||||||
});
|
|
||||||
|
|
||||||
Promise.all(promises).then(() => browser.test.notifyPass("flag needed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
ok(!Preferences.get(STORAGE_SYNC_PREF));
|
|
||||||
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({
|
let globalChanges = {};
|
||||||
manifest: {
|
|
||||||
permissions: ["storage"],
|
|
||||||
},
|
|
||||||
background: `(${background})()`,
|
|
||||||
}, extensionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
Preferences.set(STORAGE_SYNC_PREF, true);
|
browser.storage.onChanged.addListener((changes, storage) => {
|
||||||
|
browser.test.assertEq("local", storage, "storage is local");
|
||||||
let extension1 = loadExtension();
|
Object.assign(globalChanges, changes);
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
Preferences.reset(STORAGE_SYNC_PREF);
|
|
||||||
});
|
|
||||||
|
|
||||||
do_register_cleanup(() => {
|
|
||||||
Preferences.reset(STORAGE_SYNC_PREF);
|
|
||||||
});
|
|
||||||
|
|
||||||
add_task(function* test_backgroundScript() {
|
|
||||||
function backgroundScript(checkGet) {
|
|
||||||
let globalChanges, gResolve;
|
|
||||||
function clearGlobalChanges() {
|
|
||||||
globalChanges = new Promise(resolve => { gResolve = resolve; });
|
|
||||||
}
|
|
||||||
clearGlobalChanges();
|
|
||||||
let expectedAreaName;
|
|
||||||
|
|
||||||
browser.storage.onChanged.addListener((changes, areaName) => {
|
|
||||||
browser.test.assertEq(expectedAreaName, areaName,
|
|
||||||
"Expected area name received by listener");
|
|
||||||
gResolve(changes);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function checkChanges(areaName, changes, message) {
|
function checkChanges(changes) {
|
||||||
function checkSub(obj1, obj2) {
|
function checkSub(obj1, obj2) {
|
||||||
for (let prop in obj1) {
|
for (let prop in obj1) {
|
||||||
browser.test.assertTrue(obj1[prop] !== undefined,
|
browser.test.assertEq(obj1[prop].oldValue, obj2[prop].oldValue);
|
||||||
`checkChanges ${areaName} ${prop} is missing (${message})`);
|
browser.test.assertEq(obj1[prop].newValue, obj2[prop].newValue);
|
||||||
browser.test.assertTrue(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})`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return globalChanges.then(recentChanges => {
|
checkSub(changes, globalChanges);
|
||||||
checkSub(changes, recentChanges);
|
checkSub(globalChanges, changes);
|
||||||
checkSub(recentChanges, changes);
|
globalChanges = {};
|
||||||
clearGlobalChanges();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable dot-notation */
|
/* eslint-disable dot-notation */
|
||||||
function runTests(areaName) {
|
|
||||||
expectedAreaName = areaName;
|
|
||||||
let storage = browser.storage[areaName];
|
|
||||||
// Set some data and then test getters.
|
// Set some data and then test getters.
|
||||||
return storage.set({"test-prop1": "value1", "test-prop2": "value2"}).then(() => {
|
storage.set({"test-prop1": "value1", "test-prop2": "value2"}).then(() => {
|
||||||
return checkChanges(areaName,
|
checkChanges({"test-prop1": {newValue: "value1"}, "test-prop2": {newValue: "value2"}});
|
||||||
{"test-prop1": {newValue: "value1"}, "test-prop2": {newValue: "value2"}},
|
return check("test-prop1", "value1");
|
||||||
"set (a)");
|
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
return checkGet(areaName, "test-prop1", "value1");
|
return check("test-prop2", "value2");
|
||||||
}).then(() => {
|
|
||||||
return checkGet(areaName, "test-prop2", "value2");
|
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
return storage.get({"test-prop1": undefined, "test-prop2": undefined, "other": "default"});
|
return storage.get({"test-prop1": undefined, "test-prop2": undefined, "other": "default"});
|
||||||
}).then(data => {
|
}).then(data => {
|
||||||
browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (a)");
|
browser.test.assertEq("value1", data["test-prop1"], "prop1 correct");
|
||||||
browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (a)");
|
browser.test.assertEq("value2", data["test-prop2"], "prop2 correct");
|
||||||
browser.test.assertEq("default", data["other"], "other correct");
|
browser.test.assertEq("default", data["other"], "other correct");
|
||||||
return storage.get(["test-prop1", "test-prop2", "other"]);
|
return storage.get(["test-prop1", "test-prop2", "other"]);
|
||||||
}).then(data => {
|
}).then(data => {
|
||||||
browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (b)");
|
browser.test.assertEq("value1", data["test-prop1"], "prop1 correct");
|
||||||
browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (b)");
|
browser.test.assertEq("value2", data["test-prop2"], "prop2 correct");
|
||||||
browser.test.assertFalse("other" in data, "other correct");
|
browser.test.assertFalse("other" in data, "other correct");
|
||||||
|
|
||||||
// Remove data in various ways.
|
// Remove data in various ways.
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
return storage.remove("test-prop1");
|
return storage.remove("test-prop1");
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
return checkChanges(areaName, {"test-prop1": {oldValue: "value1"}}, "remove string");
|
checkChanges({"test-prop1": {oldValue: "value1"}});
|
||||||
}).then(() => {
|
|
||||||
return storage.get(["test-prop1", "test-prop2"]);
|
return storage.get(["test-prop1", "test-prop2"]);
|
||||||
}).then(data => {
|
}).then(data => {
|
||||||
browser.test.assertFalse("test-prop1" in data, "prop1 absent (remove string)");
|
browser.test.assertFalse("test-prop1" in data, "prop1 absent");
|
||||||
browser.test.assertTrue("test-prop2" in data, "prop2 present (remove string)");
|
browser.test.assertTrue("test-prop2" in data, "prop2 present");
|
||||||
|
|
||||||
return storage.set({"test-prop1": "value1"});
|
return storage.set({"test-prop1": "value1"});
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
return checkChanges(areaName, {"test-prop1": {newValue: "value1"}}, "set (c)");
|
checkChanges({"test-prop1": {newValue: "value1"}});
|
||||||
}).then(() => {
|
|
||||||
return storage.get(["test-prop1", "test-prop2"]);
|
return storage.get(["test-prop1", "test-prop2"]);
|
||||||
}).then(data => {
|
}).then(data => {
|
||||||
browser.test.assertEq(data["test-prop1"], "value1", "prop1 correct (c)");
|
browser.test.assertEq("value1", data["test-prop1"], "prop1 correct");
|
||||||
browser.test.assertEq(data["test-prop2"], "value2", "prop2 correct (c)");
|
browser.test.assertEq("value2", data["test-prop2"], "prop2 correct");
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
return storage.remove(["test-prop1", "test-prop2"]);
|
return storage.remove(["test-prop1", "test-prop2"]);
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
return checkChanges(areaName,
|
checkChanges({"test-prop1": {oldValue: "value1"}, "test-prop2": {oldValue: "value2"}});
|
||||||
{"test-prop1": {oldValue: "value1"}, "test-prop2": {oldValue: "value2"}},
|
|
||||||
"remove array");
|
|
||||||
}).then(() => {
|
|
||||||
return storage.get(["test-prop1", "test-prop2"]);
|
return storage.get(["test-prop1", "test-prop2"]);
|
||||||
}).then(data => {
|
}).then(data => {
|
||||||
browser.test.assertFalse("test-prop1" in data, "prop1 absent (remove array)");
|
browser.test.assertFalse("test-prop1" in data, "prop1 absent");
|
||||||
browser.test.assertFalse("test-prop2" in data, "prop2 absent (remove array)");
|
browser.test.assertFalse("test-prop2" in data, "prop2 absent");
|
||||||
|
|
||||||
// test storage.clear
|
// test storage.clear
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
return storage.set({"test-prop1": "value1", "test-prop2": "value2"});
|
return storage.set({"test-prop1": "value1", "test-prop2": "value2"});
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
// Make sure that set() handler happened before we clear the
|
|
||||||
// promise again.
|
|
||||||
return globalChanges;
|
|
||||||
}).then(() => {
|
|
||||||
clearGlobalChanges();
|
|
||||||
return storage.clear();
|
return storage.clear();
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
return checkChanges(areaName,
|
checkChanges({"test-prop1": {oldValue: "value1"}, "test-prop2": {oldValue: "value2"}});
|
||||||
{"test-prop1": {oldValue: "value1"}, "test-prop2": {oldValue: "value2"}},
|
|
||||||
"clear");
|
|
||||||
}).then(() => {
|
|
||||||
return storage.get(["test-prop1", "test-prop2"]);
|
return storage.get(["test-prop1", "test-prop2"]);
|
||||||
}).then(data => {
|
}).then(data => {
|
||||||
browser.test.assertFalse("test-prop1" in data, "prop1 absent (clear)");
|
browser.test.assertFalse("test-prop1" in data, "prop1 absent");
|
||||||
browser.test.assertFalse("test-prop2" in data, "prop2 absent (clear)");
|
browser.test.assertFalse("test-prop2" in data, "prop2 absent");
|
||||||
|
|
||||||
|
// Test cache invalidation.
|
||||||
|
}).then(() => {
|
||||||
|
return storage.set({"test-prop1": "value1", "test-prop2": "value2"});
|
||||||
|
}).then(() => {
|
||||||
|
globalChanges = {};
|
||||||
|
// Schedule sendMessage after onMessage because the other end immediately
|
||||||
|
// sends a message.
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
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.
|
// Make sure we can store complex JSON data.
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
// known previous values
|
|
||||||
return storage.set({"test-prop1": "value1", "test-prop2": "value2"});
|
|
||||||
}).then(() => {
|
|
||||||
// Make sure the set() handler landed.
|
|
||||||
return globalChanges;
|
|
||||||
}).then(() => {
|
|
||||||
clearGlobalChanges();
|
|
||||||
return storage.set({
|
return storage.set({
|
||||||
"test-prop1": {
|
"test-prop1": {
|
||||||
str: "hello",
|
str: "hello",
|
||||||
|
@ -288,10 +131,10 @@ add_task(function* test_backgroundScript() {
|
||||||
});
|
});
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
return storage.set({"test-prop2": function func() {}});
|
return storage.set({"test-prop2": function func() {}});
|
||||||
}).then(() => globalChanges).then(recentChanges => {
|
}).then(() => {
|
||||||
browser.test.assertEq("value1", recentChanges["test-prop1"].oldValue, "oldValue correct");
|
browser.test.assertEq("value1", globalChanges["test-prop1"].oldValue, "oldValue correct");
|
||||||
browser.test.assertEq("object", typeof(recentChanges["test-prop1"].newValue), "newValue is obj");
|
browser.test.assertEq("object", typeof(globalChanges["test-prop1"].newValue), "newValue is obj");
|
||||||
clearGlobalChanges();
|
globalChanges = {};
|
||||||
return storage.get({"test-prop1": undefined, "test-prop2": undefined});
|
return storage.get({"test-prop1": undefined, "test-prop2": undefined});
|
||||||
}).then(data => {
|
}).then(data => {
|
||||||
let obj = data["test-prop1"];
|
let obj = data["test-prop1"];
|
||||||
|
@ -303,7 +146,7 @@ add_task(function* test_backgroundScript() {
|
||||||
browser.test.assertEq(undefined, obj.func, "function part correct");
|
browser.test.assertEq(undefined, obj.func, "function part correct");
|
||||||
browser.test.assertEq(undefined, obj.window, "window 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("1970-01-01T00:00:00.000Z", obj.date, "date part correct");
|
||||||
browser.test.assertEq("/regexp/", obj.regexp, "regexp part correct");
|
browser.test.assertEq("/regexp/", obj.regexp, "date part correct");
|
||||||
browser.test.assertEq("object", typeof(obj.obj), "object part correct");
|
browser.test.assertEq("object", typeof(obj.obj), "object part correct");
|
||||||
browser.test.assertTrue(Array.isArray(obj.arr), "array part present");
|
browser.test.assertTrue(Array.isArray(obj.arr), "array part present");
|
||||||
browser.test.assertEq(1, obj.arr[0], "arr[0] part correct");
|
browser.test.assertEq(1, obj.arr[0], "arr[0] part correct");
|
||||||
|
@ -314,44 +157,32 @@ add_task(function* test_backgroundScript() {
|
||||||
|
|
||||||
browser.test.assertEq("[object Object]", {}.toString.call(obj), "function serialized as a plain object");
|
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");
|
browser.test.assertEq(0, Object.keys(obj).length, "function serialized as an empty object");
|
||||||
|
}).then(() => {
|
||||||
|
browser.test.notifyPass("storage");
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
browser.test.fail(`Error: ${e} :: ${e.stack}`);
|
browser.test.fail(`Error: ${e} :: ${e.stack}`);
|
||||||
browser.test.notifyFail("storage");
|
browser.test.notifyFail("storage");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
browser.test.onMessage.addListener(msg => {
|
let extensionData = {
|
||||||
let promise;
|
background: backgroundScript,
|
||||||
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: {
|
manifest: {
|
||||||
permissions: ["storage"],
|
permissions: ["storage"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
Preferences.set(STORAGE_SYNC_PREF, true);
|
|
||||||
|
|
||||||
|
add_task(function* test_backgroundScript() {
|
||||||
let extension = ExtensionTestUtils.loadExtension(extensionData);
|
let extension = ExtensionTestUtils.loadExtension(extensionData);
|
||||||
|
|
||||||
yield extension.startup();
|
yield extension.startup();
|
||||||
yield extension.awaitMessage("ready");
|
|
||||||
|
|
||||||
extension.sendMessage("test-local");
|
yield extension.awaitMessage("invalidate");
|
||||||
yield extension.awaitMessage("test-finished");
|
|
||||||
|
|
||||||
extension.sendMessage("test-sync");
|
Services.obs.notifyObservers(null, "extension-invalidate-storage-cache", "");
|
||||||
yield extension.awaitMessage("test-finished");
|
|
||||||
|
|
||||||
Preferences.reset(STORAGE_SYNC_PREF);
|
extension.sendMessage("invalidated");
|
||||||
|
|
||||||
|
yield extension.awaitFinish("storage");
|
||||||
yield extension.unload();
|
yield extension.unload();
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,31 +0,0 @@
|
||||||
/* Any copyright is dedicated to the Public Domain.
|
|
||||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const {keyToId, idToKey} = Cu.import("resource://gre/modules/ExtensionStorageSync.jsm");
|
|
||||||
|
|
||||||
add_task(function* test_key_to_id() {
|
|
||||||
equal(keyToId("foo"), "key-foo");
|
|
||||||
equal(keyToId("my-new-key"), "key-my_2D_new_2D_key");
|
|
||||||
equal(keyToId(""), "key-");
|
|
||||||
equal(keyToId("™"), "key-_2122_");
|
|
||||||
equal(keyToId("\b"), "key-_8_");
|
|
||||||
equal(keyToId("abc\ndef"), "key-abc_A_def");
|
|
||||||
equal(keyToId("Kinto's fancy_string"), "key-Kinto_27_s_20_fancy_5F_string");
|
|
||||||
|
|
||||||
const KEYS = ["foo", "my-new-key", "", "Kinto's fancy_string", "™", "\b"];
|
|
||||||
for (let key of KEYS) {
|
|
||||||
equal(idToKey(keyToId(key)), key);
|
|
||||||
}
|
|
||||||
|
|
||||||
equal(idToKey("hi"), null);
|
|
||||||
equal(idToKey("-key-hi"), null);
|
|
||||||
equal(idToKey("key--abcd"), null);
|
|
||||||
equal(idToKey("key-%"), null);
|
|
||||||
equal(idToKey("key-_HI"), null);
|
|
||||||
equal(idToKey("key-_HI_"), null);
|
|
||||||
equal(idToKey("key-"), "");
|
|
||||||
equal(idToKey("key-1"), "1");
|
|
||||||
equal(idToKey("key-_2D_"), "-");
|
|
||||||
});
|
|
|
@ -57,7 +57,6 @@ skip-if = release_or_beta
|
||||||
[test_ext_schemas_allowed_contexts.js]
|
[test_ext_schemas_allowed_contexts.js]
|
||||||
[test_ext_simple.js]
|
[test_ext_simple.js]
|
||||||
[test_ext_storage.js]
|
[test_ext_storage.js]
|
||||||
[test_ext_storage_sync.js]
|
|
||||||
[test_ext_topSites.js]
|
[test_ext_topSites.js]
|
||||||
skip-if = os == "android"
|
skip-if = os == "android"
|
||||||
[test_getAPILevelForWindow.js]
|
[test_getAPILevelForWindow.js]
|
||||||
|
|
Загрузка…
Ссылка в новой задаче