Bug 820834 - Abstract about:home storage and make it async-ready.

r=gavin
This commit is contained in:
Marco Bonardo 2013-02-27 18:57:48 +01:00
parent 4c60c029bd
commit 5987d821e9
3 changed files with 232 additions and 77 deletions

View File

@ -86,7 +86,7 @@ let gObserver = new MutationObserver(function (mutations) {
if (mutation.attributeName == "searchEngineURL") {
gObserver.disconnect();
setupSearchEngine();
loadSnippets();
ensureSnippetsMapThen(loadSnippets);
return;
}
}
@ -100,6 +100,69 @@ window.addEventListener("load", function () {
window.addEventListener("resize", fitToWidth);
});
// This object has the same interface as Map and is used to store and retrieve
// the snippets data. It is lazily initialized by ensureSnippetsMapThen(), so
// be sure its callback returned before trying to use it.
let gSnippetsMap;
let gSnippetsMapCallbacks = [];
/**
* Ensure the snippets map is properly initialized.
*
* @param aCallback
* Invoked once the map has been initialized, gets the map as argument.
* @note Snippets should never directly manage the underlying storage, since
* it may change inadvertently.
*/
function ensureSnippetsMapThen(aCallback)
{
if (gSnippetsMap) {
aCallback(gSnippetsMap);
return;
}
// Handle multiple requests during the async initialization.
gSnippetsMapCallbacks.push(aCallback);
if (gSnippetsMapCallbacks.length > 1) {
// We are already updating, the callbacks will be invoked when done.
return;
}
// TODO (bug 789348): use a real asynchronous storage here. This setTimeout
// is done just to catch bugs with the asynchronous behavior.
setTimeout(function() {
// Populate the cache from the persistent storage.
let cache = new Map();
for (let key of [ "snippets-last-update",
"snippets" ]) {
cache.set(key, localStorage[key]);
}
gSnippetsMap = Object.freeze({
get: function (aKey) cache.get(aKey),
set: function (aKey, aValue) {
localStorage[aKey] = aValue;
return cache.set(aKey, aValue);
},
has: function(aKey) cache.has(aKey),
delete: function(aKey) {
delete localStorage[aKey];
return cache.delete(aKey);
},
clear: function() {
localStorage.clear();
return cache.clear();
},
get size() cache.size
});
for (let callback of gSnippetsMapCallbacks) {
callback(gSnippetsMap);
}
gSnippetsMapCallbacks.length = 0;
}, 0);
}
function onSearchSubmit(aEvent)
{
let searchTerms = document.getElementById("searchText").value;
@ -157,13 +220,21 @@ function setupSearchEngine()
}
/**
* Update the local snippets from the remote storage, then show them through
* showSnippets.
*/
function loadSnippets()
{
if (!gSnippetsMap)
throw new Error("Snippets map has not properly been initialized");
// Check last snippets update.
let lastUpdate = localStorage["snippets-last-update"];
let lastUpdate = gSnippetsMap.get("snippets-last-update");
let updateURL = document.documentElement.getAttribute("snippetsURL");
if (updateURL && (!lastUpdate ||
Date.now() - lastUpdate > SNIPPETS_UPDATE_INTERVAL_MS)) {
let shouldUpdate = !lastUpdate ||
Date.now() - lastUpdate > SNIPPETS_UPDATE_INTERVAL_MS;
if (updateURL && shouldUpdate) {
// Try to update from network.
let xhr = new XMLHttpRequest();
try {
@ -174,14 +245,14 @@ function loadSnippets()
}
// Even if fetching should fail we don't want to spam the server, thus
// set the last update time regardless its results. Will retry tomorrow.
localStorage["snippets-last-update"] = Date.now();
gSnippetsMap.set("snippets-last-update", Date.now());
xhr.onerror = function (event) {
showSnippets();
};
xhr.onload = function (event)
{
if (xhr.status == 200) {
localStorage["snippets"] = xhr.responseText;
gSnippetsMap.set("snippets", xhr.responseText);
}
showSnippets();
};
@ -191,10 +262,27 @@ function loadSnippets()
}
}
/**
* Shows locally cached remote snippets, or default ones when not available.
*
* @note: snippets should never invoke showSnippets(), or they may cause
* a "too much recursion" exception.
*/
let _snippetsShown = false;
function showSnippets()
{
if (!gSnippetsMap)
throw new Error("Snippets map has not properly been initialized");
if (_snippetsShown) {
// There's something wrong with the remote snippets, just in case fall back
// to the default snippets.
showDefaultSnippets();
throw new Error("showSnippets should never be invoked multiple times");
}
_snippetsShown = true;
let snippetsElt = document.getElementById("snippets");
let snippets = localStorage["snippets"];
let snippets = gSnippetsMap.get("snippets");
// If there are remotely fetched snippets, try to to show them.
if (snippets) {
// Injecting snippets can throw if they're invalid XML.
@ -214,7 +302,19 @@ function showSnippets()
}
}
// Show default snippets otherwise.
showDefaultSnippets();
}
/**
* Clear snippets element contents and show default snippets.
*/
function showDefaultSnippets()
{
// Clear eventual contents...
let snippetsElt = document.getElementById("snippets");
snippetsElt.innerHTML = "";
// ...then show default snippets.
let defaultSnippetsElt = document.getElementById("defaultSnippets");
let entries = defaultSnippetsElt.querySelectorAll("span");
// Choose a random snippet. Assume there is always at least one.

View File

@ -2,6 +2,11 @@
* http://creativecommons.org/publicdomain/zero/1.0/
*/
XPCOMUtils.defineLazyModuleGetter(this, "Promise",
"resource://gre/modules/commonjs/sdk/core/promise.js");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
"resource://gre/modules/Task.jsm");
registerCleanupFunction(function() {
// Ensure we don't pollute prefs for next tests.
try {
@ -22,19 +27,15 @@ let gTests = [
.getService(Ci.nsIObserver)
.observe(null, "cookie-changed", "cleared");
},
run: function ()
run: function (aSnippetsMap)
{
let storage = getStorage();
isnot(storage.getItem("snippets-last-update"), null);
executeSoon(runNextTest);
isnot(aSnippetsMap.get("snippets-last-update"), null);
}
},
{
desc: "Check default snippets are shown",
setup: function ()
{
},
setup: function () { },
run: function ()
{
let doc = gBrowser.selectedTab.linkedBrowser.contentDocument;
@ -42,19 +43,17 @@ let gTests = [
ok(snippetsElt, "Found snippets element")
is(snippetsElt.getElementsByTagName("span").length, 1,
"A default snippet is visible.");
executeSoon(runNextTest);
}
},
{
desc: "Check default snippets are shown if snippets are invalid xml",
setup: function ()
setup: function (aSnippetsMap)
{
let storage = getStorage();
// This must be some incorrect xhtml code.
storage.setItem("snippets", "<p><b></p></b>");
aSnippetsMap.set("snippets", "<p><b></p></b>");
},
run: function ()
run: function (aSnippetsMap)
{
let doc = gBrowser.selectedTab.linkedBrowser.contentDocument;
@ -62,16 +61,14 @@ let gTests = [
ok(snippetsElt, "Found snippets element");
is(snippetsElt.getElementsByTagName("span").length, 1,
"A default snippet is visible.");
let storage = getStorage();
storage.removeItem("snippets");
executeSoon(runNextTest);
aSnippetsMap.delete("snippets");
}
},
{
desc: "Check that search engine logo has alt text",
setup: function ()
{
},
setup: function () { },
run: function ()
{
let doc = gBrowser.selectedTab.linkedBrowser.contentDocument;
@ -85,27 +82,29 @@ let gTests = [
isnot(altText, "undefined",
"Search engine logo's alt text shouldn't be the string 'undefined'");
executeSoon(runNextTest);
}
},
{
desc: "Check that performing a search fires a search event.",
setup: function () { },
run: function () {
let deferred = Promise.defer();
let doc = gBrowser.contentDocument;
doc.addEventListener("AboutHomeSearchEvent", function onSearch(e) {
is(e.detail, doc.documentElement.getAttribute("searchEngineName"), "Detail is search engine name");
gBrowser.stop();
executeSoon(runNextTest);
deferred.resolve();
}, true, true);
doc.getElementById("searchText").value = "it works";
doc.getElementById("searchSubmit").click();
},
return deferred.promise;
}
},
{
desc: "Check that performing a search records to Firefox Health Report.",
setup: function () { },
@ -115,10 +114,10 @@ let gTests = [
cm.getCategoryEntry("healthreport-js-provider", "SearchesProvider");
} catch (ex) {
// Health Report disabled, or no SearchesProvider.
runNextTest();
return;
}
let deferred = Promise.defer();
let doc = gBrowser.contentDocument;
// We rely on the listener in browser.js being installed and fired before
@ -149,7 +148,7 @@ let gTests = [
// Note the search from the previous test.
is(day.get(field), 2, "Have searches recorded.");
executeSoon(runNextTest);
deferred.resolve();
});
});
@ -157,62 +156,118 @@ let gTests = [
doc.getElementById("searchText").value = "a search";
doc.getElementById("searchSubmit").click();
},
return deferred.promise;
}
},
];
function test()
{
waitForExplicitFinish();
// Ensure that by default we don't try to check for remote snippets since that
// could be tricky due to network bustages or slowness.
let storage = getStorage();
storage.setItem("snippets-last-update", Date.now());
storage.removeItem("snippets");
Task.spawn(function () {
for (let test of gTests) {
info(test.desc);
executeSoon(runNextTest);
}
let tab = yield promiseNewTabLoadEvent("about:home", "DOMContentLoaded");
function runNextTest()
{
while (gBrowser.tabs.length > 1) {
gBrowser.removeCurrentTab();
}
// Must wait for both the snippets map and the browser attributes, since
// can't guess the order they will happen.
// So, start listening now, but verify the promise is fulfilled only
// after the snippets map setup.
let promise = promiseBrowserAttributes(tab);
// Prepare the snippets map with default values, then run the test setup.
let snippetsMap = yield promiseSetupSnippetsMap(tab, test.setup);
// Ensure browser has set attributes already, or wait for them.
yield promise;
if (gTests.length) {
let test = gTests.shift();
info(test.desc);
test.setup();
let tab = gBrowser.selectedTab = gBrowser.addTab("about:home");
tab.linkedBrowser.addEventListener("load", function load(event) {
tab.linkedBrowser.removeEventListener("load", load, true);
yield test.run(snippetsMap);
gBrowser.removeCurrentTab();
}
let observer = new MutationObserver(function (mutations) {
for (let mutation of mutations) {
if (mutation.attributeName == "searchEngineURL") {
observer.disconnect();
executeSoon(test.run);
return;
}
}
});
let docElt = tab.linkedBrowser.contentDocument.documentElement;
observer.observe(docElt, { attributes: true });
}, true);
}
else {
finish();
}
});
}
function getStorage()
/**
* Creates a new tab and waits for a load event.
*
* @param aUrl
* The url to load in a new tab.
* @param aEvent
* The load event type to wait for. Defaults to "load".
* @return {Promise} resolved when the event is handled. Gets the new tab.
*/
function promiseNewTabLoadEvent(aUrl, aEventType="load")
{
let aboutHomeURI = Services.io.newURI("moz-safe-about:home", null, null);
let principal = Components.classes["@mozilla.org/scriptsecuritymanager;1"].
getService(Components.interfaces.nsIScriptSecurityManager).
getNoAppCodebasePrincipal(Services.io.newURI("about:home", null, null));
let dsm = Components.classes["@mozilla.org/dom/storagemanager;1"].
getService(Components.interfaces.nsIDOMStorageManager);
return dsm.getLocalStorageForPrincipal(principal, "");
};
let deferred = Promise.defer();
let tab = gBrowser.selectedTab = gBrowser.addTab(aUrl);
tab.linkedBrowser.addEventListener(aEventType, function load(event) {
tab.linkedBrowser.removeEventListener(aEventType, load, true);
deferred.resolve(tab);
}, true);
return deferred.promise;
}
/**
* Cleans up snippets and ensures that by default we don't try to check for
* remote snippets since that may cause network bustage or slowness.
*
* @param aTab
* The tab containing about:home.
* @param aSetupFn
* The setup function to be run.
* @return {Promise} resolved when the snippets are ready. Gets the snippets map.
*/
function promiseSetupSnippetsMap(aTab, aSetupFn)
{
let deferred = Promise.defer();
let cw = aTab.linkedBrowser.contentWindow.wrappedJSObject;
cw.ensureSnippetsMapThen(function (aSnippetsMap) {
// Don't try to update.
aSnippetsMap.set("snippets-last-update", Date.now());
// Clear snippets.
aSnippetsMap.delete("snippets");
aSetupFn(aSnippetsMap);
// Must be sure to continue after the page snippets map setup.
executeSoon(function() deferred.resolve(aSnippetsMap));
});
return deferred.promise;
}
/**
* Waits for the attributes being set by browser.js and overwrites snippetsURL
* to ensure we won't try to hit the network and we can force xhr to throw.
*
* @param aTab
* The tab containing about:home.
* @return {Promise} resolved when the attributes are ready.
*/
function promiseBrowserAttributes(aTab)
{
let deferred = Promise.defer();
let docElt = aTab.linkedBrowser.contentDocument.documentElement;
//docElt.setAttribute("snippetsURL", "nonexistent://test");
let observer = new MutationObserver(function (mutations) {
for (let mutation of mutations) {
if (mutation.attributeName == "snippetsURL" &&
docElt.getAttribute("snippetsURL") != "nonexistent://test") {
docElt.setAttribute("snippetsURL", "nonexistent://test");
}
// Now we just have to wait for the last attribute.
if (mutation.attributeName == "searchEngineURL") {
observer.disconnect();
// Must be sure to continue after the page mutation observer.
executeSoon(function() deferred.resolve());
break;
}
}
});
observer.observe(docElt, { attributes: true });
return deferred.promise;
}

View File

@ -13,7 +13,7 @@ Components.utils.import("resource://gre/modules/Services.jsm");
const SNIPPETS_URL_PREF = "browser.aboutHomeSnippets.updateUrl";
// Should be bumped up if the snippets content format changes.
const STARTPAGE_VERSION = 3;
const STARTPAGE_VERSION = 4;
this.AboutHomeUtils = new Object();