From b6be002f1a2c8bfa9c20a41e096718d76e9e2c84 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Tue, 19 Jun 2012 20:23:30 -0400 Subject: [PATCH 1/7] Added lscache and session auto-restore. Session auto-restore only works if the goggles are activated on the page within 5 minutes of data loss. Also, slightly modified lscache to prefix its localStorage keys with 'webxray-' to avoid potential conflicts w/ lscache on the existing page. --- config.json | 1 + src/lscache.js | 266 +++++++++++++++++++++++++++++++++++++++++++++++++ src/ui.js | 35 ++++++- 3 files changed, 299 insertions(+), 3 deletions(-) create mode 100644 src/lscache.js diff --git a/config.json b/config.json index fb96a0a..c45fc86 100755 --- a/config.json +++ b/config.json @@ -22,6 +22,7 @@ , "jquery/src/effects.js" , "jquery/src/offset.js" , "jquery/src/dimensions.js" + , "src/lscache.js" , "src/get-bookmarklet-url.js" , "src/localization.js" , "src/locale/*.js" diff --git a/src/lscache.js b/src/lscache.js new file mode 100644 index 0000000..a55b4e8 --- /dev/null +++ b/src/lscache.js @@ -0,0 +1,266 @@ +/** + * lscache library + * Copyright (c) 2011, Pamela Fox + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/*jshint undef:true, browser:true */ + +/** + * Creates a namespace for the lscache functions. + */ +var lscache = (function() { + // Prefix for all lscache keys + var CACHE_PREFIX = 'webxray-lscache-'; + + // Suffix for the key name on the expiration items in localStorage + var CACHE_SUFFIX = '-cacheexpiration'; + + // expiration date radix (set to Base-36 for most space savings) + var EXPIRY_RADIX = 10; + + // time resolution in minutes + var EXPIRY_UNITS = 60 * 1000; + + // ECMAScript max Date (epoch + 1e8 days) + var MAX_DATE = Math.floor(8.64e15/EXPIRY_UNITS); + + var cachedStorage; + var cachedJSON; + + // Determines if localStorage is supported in the browser; + // result is cached for better performance instead of being run each time. + // Feature detection is based on how Modernizr does it; + // it's not straightforward due to FF4 issues. + // It's not run at parse-time as it takes 200ms in Android. + function supportsStorage() { + var key = '__lscachetest__'; + var value = key; + + if (cachedStorage !== undefined) { + return cachedStorage; + } + + try { + setItem(key, value); + removeItem(key); + cachedStorage = true; + } catch (exc) { + cachedStorage = false; + } + return cachedStorage; + } + + // Determines if native JSON (de-)serialization is supported in the browser. + function supportsJSON() { + /*jshint eqnull:true */ + if (cachedJSON === undefined) { + cachedJSON = (window.JSON != null); + } + return cachedJSON; + } + + /** + * Returns the full string for the localStorage expiration item. + * @param {String} key + * @return {string} + */ + function expirationKey(key) { + return key + CACHE_SUFFIX; + } + + /** + * Returns the number of minutes since the epoch. + * @return {number} + */ + function currentTime() { + return Math.floor((new Date().getTime())/EXPIRY_UNITS); + } + + /** + * Wrapper functions for localStorage methods + */ + + function getItem(key) { + return localStorage.getItem(CACHE_PREFIX + key); + } + + function setItem(key, value) { + // Fix for iPad issue - sometimes throws QUOTA_EXCEEDED_ERR on setItem. + localStorage.removeItem(CACHE_PREFIX + key); + localStorage.setItem(CACHE_PREFIX + key, value); + } + + function removeItem(key) { + localStorage.removeItem(CACHE_PREFIX + key); + } + + return { + + /** + * Stores the value in localStorage. Expires after specified number of minutes. + * @param {string} key + * @param {Object|string} value + * @param {number} time + */ + set: function(key, value, time) { + if (!supportsStorage()) return; + + // If we don't get a string value, try to stringify + // In future, localStorage may properly support storing non-strings + // and this can be removed. + if (typeof value !== 'string') { + if (!supportsJSON()) return; + try { + value = JSON.stringify(value); + } catch (e) { + // Sometimes we can't stringify due to circular refs + // in complex objects, so we won't bother storing then. + return; + } + } + + try { + setItem(key, value); + } catch (e) { + if (e.name === 'QUOTA_EXCEEDED_ERR' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED') { + // If we exceeded the quota, then we will sort + // by the expire time, and then remove the N oldest + var storedKeys = []; + var storedKey; + for (var i = 0; i < localStorage.length; i++) { + storedKey = localStorage.key(i); + + if (storedKey.indexOf(CACHE_PREFIX) === 0 && storedKey.indexOf(CACHE_SUFFIX) < 0) { + var mainKey = storedKey.substr(CACHE_PREFIX.length); + var exprKey = expirationKey(mainKey); + var expiration = getItem(exprKey); + if (expiration) { + expiration = parseInt(expiration, EXPIRY_RADIX); + } else { + // TODO: Store date added for non-expiring items for smarter removal + expiration = MAX_DATE; + } + storedKeys.push({ + key: mainKey, + size: (getItem(mainKey)||'').length, + expiration: expiration + }); + } + } + // Sorts the keys with oldest expiration time last + storedKeys.sort(function(a, b) { return (b.expiration-a.expiration); }); + + var targetSize = (value||'').length; + while (storedKeys.length && targetSize > 0) { + storedKey = storedKeys.pop(); + removeItem(storedKey.key); + removeItem(expirationKey(storedKey.key)); + targetSize -= storedKey.size; + } + try { + setItem(key, value); + } catch (e) { + // value may be larger than total quota + return; + } + } else { + // If it was some other error, just give up. + return; + } + } + + // If a time is specified, store expiration info in localStorage + if (time) { + setItem(expirationKey(key), (currentTime() + time).toString(EXPIRY_RADIX)); + } else { + // In case they previously set a time, remove that info from localStorage. + removeItem(expirationKey(key)); + } + }, + + /** + * Retrieves specified value from localStorage, if not expired. + * @param {string} key + * @return {string|Object} + */ + get: function(key) { + if (!supportsStorage()) return null; + + // Return the de-serialized item if not expired + var exprKey = expirationKey(key); + var expr = getItem(exprKey); + + if (expr) { + var expirationTime = parseInt(expr, EXPIRY_RADIX); + + // Check if we should actually kick item out of storage + if (currentTime() >= expirationTime) { + removeItem(key); + removeItem(exprKey); + return null; + } + } + + // Tries to de-serialize stored value if its an object, and returns the normal value otherwise. + var value = getItem(key); + if (!value || !supportsJSON()) { + return value; + } + + try { + // We can't tell if its JSON or a string, so we try to parse + return JSON.parse(value); + } catch (e) { + // If we can't parse, it's probably because it isn't an object + return value; + } + }, + + /** + * Removes a value from localStorage. + * Equivalent to 'delete' in memcache, but that's a keyword in JS. + * @param {string} key + */ + remove: function(key) { + if (!supportsStorage()) return null; + removeItem(key); + removeItem(expirationKey(key)); + }, + + /** + * Returns whether local storage is supported. + * Currently exposed for testing purposes. + * @return {boolean} + */ + supported: function() { + return supportsStorage(); + }, + + /** + * Flushes all lscache items and expiry markers without affecting rest of localStorage + */ + flush: function() { + if (!supportsStorage()) return; + + // Loop in reverse as removing items will change indices of tail + for (var i = localStorage.length-1; i >= 0 ; --i) { + var key = localStorage.key(i); + if (key.indexOf(CACHE_PREFIX) === 0) { + localStorage.removeItem(key); + } + } + } + }; +})(); \ No newline at end of file diff --git a/src/ui.js b/src/ui.js index 902ebd7..0b2aff1 100644 --- a/src/ui.js +++ b/src/ui.js @@ -12,9 +12,10 @@ // If the user has made changes to the page, we don't want them // to be able to navigate away from it without facing a modal // dialog. - function ModalUnloadBlocker(commandManager) { + function ModalUnloadBlocker(commandManager, cb) { function beforeUnload(event) { if (commandManager.canUndo()) { + cb(); event.preventDefault(); return jQuery.locale.get("input:unload-blocked"); } @@ -67,8 +68,21 @@ }); var touchToolbar = canBeTouched() ? jQuery.touchToolbar(input) : null; var indicator = jQuery.blurIndicator(input, window); - var modalUnloadBlocker = ModalUnloadBlocker(commandManager); - + var modalUnloadBlocker = ModalUnloadBlocker(commandManager, + saveRecording); + + function saveRecording() { + // Store emergency rescue data for 5 minutes. + var RECORDING_PERSIST_TIME = 5 * 60; + + if (commandManager.canUndo()) { + var recording = commandManager.getRecording(); + lscache.set("recording", JSON.parse(recording), + RECORDING_PERSIST_TIME); + } else + lscache.remove("recording"); + } + var self = jQuery.eventEmitter({ persistence: persistence, start: function() { @@ -78,10 +92,25 @@ focused.on('change', hud.onFocusChange); input.activate(); $(window).focus(); + if (!commandManager.canUndo()) { + // See if we can emergency-restore the user's previous session. + var recording = lscache.get("recording"); + if (recording) + try { + commandManager.playRecording(JSON.stringify(recording)); + } catch (e) { + // Corrupt recording, or page has changed in a way + // that we can't replay the recording, so get rid of it. + lscache.remove("recording"); + if (window.console && window.console.error) + console.error(e); + } + } }, unload: function() { if (!isUnloaded) { isUnloaded = true; + saveRecording(); focused.destroy(); focused = null; input.deactivate(); From 523da722d3ad556a5e783119eda41696ca689ff4 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Tue, 19 Jun 2012 20:33:19 -0400 Subject: [PATCH 2/7] Namespace emergency session data to current URL. This way the user's changes won't get mysteriously applied when they try to hack a different page on the same site. --- src/ui.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ui.js b/src/ui.js index 0b2aff1..34fa60e 100644 --- a/src/ui.js +++ b/src/ui.js @@ -70,6 +70,7 @@ var indicator = jQuery.blurIndicator(input, window); var modalUnloadBlocker = ModalUnloadBlocker(commandManager, saveRecording); + var RECORDING_KEY = "recording-" + window.location.href; function saveRecording() { // Store emergency rescue data for 5 minutes. @@ -77,10 +78,10 @@ if (commandManager.canUndo()) { var recording = commandManager.getRecording(); - lscache.set("recording", JSON.parse(recording), + lscache.set(RECORDING_KEY, JSON.parse(recording), RECORDING_PERSIST_TIME); } else - lscache.remove("recording"); + lscache.remove(RECORDING_KEY); } var self = jQuery.eventEmitter({ @@ -94,14 +95,14 @@ $(window).focus(); if (!commandManager.canUndo()) { // See if we can emergency-restore the user's previous session. - var recording = lscache.get("recording"); + var recording = lscache.get(RECORDING_KEY); if (recording) try { commandManager.playRecording(JSON.stringify(recording)); } catch (e) { // Corrupt recording, or page has changed in a way // that we can't replay the recording, so get rid of it. - lscache.remove("recording"); + lscache.remove(RECORDING_KEY); if (window.console && window.console.error) console.error(e); } From 903b3d0132dd612c82b4d382f9ceda6e611e5815 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Wed, 20 Jun 2012 10:37:03 -0400 Subject: [PATCH 3/7] Disable the modal-dialog-on-beforeunload behavior. Since we're saving the user's work before they navigate away and restoring it if they come back, the need for the modal dialog is obviated. --- src/ui.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ui.js b/src/ui.js index 34fa60e..eccc438 100644 --- a/src/ui.js +++ b/src/ui.js @@ -16,8 +16,13 @@ function beforeUnload(event) { if (commandManager.canUndo()) { cb(); - event.preventDefault(); - return jQuery.locale.get("input:unload-blocked"); + + // Since we are saving the user's work before they leave and + // auto-restoring it if they come back, don't bother them + // with a modal dialog. + + //event.preventDefault(); + //return jQuery.locale.get("input:unload-blocked"); } } From cbc66d526ca92c4ccad765824781ac475acbe86d Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Wed, 20 Jun 2012 17:46:24 -0400 Subject: [PATCH 4/7] add support for simplesauce CI. --- .simplesauce.json | 3 +++ test/index.html | 6 +++-- test/unit/uproot/uproot.js | 47 ++++++++++++++++++++------------------ 3 files changed, 32 insertions(+), 24 deletions(-) create mode 100644 .simplesauce.json diff --git a/.simplesauce.json b/.simplesauce.json new file mode 100644 index 0000000..940720f --- /dev/null +++ b/.simplesauce.json @@ -0,0 +1,3 @@ +{ + "testPath": "/static-files/test/" +} diff --git a/test/index.html b/test/index.html index a1bddb5..fd8c7ff 100644 --- a/test/index.html +++ b/test/index.html @@ -34,8 +34,10 @@ - - + diff --git a/test/unit/uproot/uproot.js b/test/unit/uproot/uproot.js index 6357f36..fc43789 100644 --- a/test/unit/uproot/uproot.js +++ b/test/unit/uproot/uproot.js @@ -11,29 +11,32 @@ module("uproot", { } }); -asyncTest("uprootIgnoringWebxray() works", function() { - var iframe = jQuery(""); - iframe.attr("src", "unit/uproot/source-pages/basic-page/"); - iframe.load(function() { - var window = iframe[0].contentWindow; - Webxray.whenLoaded(function(ui) { - ok(ui.jQuery.webxrayBuildMetadata.date, - "build date is " + ui.jQuery.webxrayBuildMetadata.date); - ok(ui.jQuery.webxrayBuildMetadata.commit && - ui.jQuery.webxrayBuildMetadata.commit != "unknown", - "build commit is " + ui.jQuery.webxrayBuildMetadata.commit); - ok(ui.jQuery(".webxray-base").length, - ".webxray-base in goggles-injected document"); - ui.jQuery(window.document).uprootIgnoringWebxray(function(html) { - ok(html.indexOf('webxray-base') == -1, - ".webxray-base not in goggles-injected uproot"); - start(); - }); - }, window); - window.location = Webxray.getBookmarkletURL("../../../../../"); +// Only run this test if we're not being served from a simple +// clone of the repository (i.e. simplesauce). +if (!location.search.match(/externalreporter=1/)) + asyncTest("uprootIgnoringWebxray() works", function() { + var iframe = jQuery(""); + iframe.attr("src", "unit/uproot/source-pages/basic-page/"); + iframe.load(function() { + var window = iframe[0].contentWindow; + Webxray.whenLoaded(function(ui) { + ok(ui.jQuery.webxrayBuildMetadata.date, + "build date is " + ui.jQuery.webxrayBuildMetadata.date); + ok(ui.jQuery.webxrayBuildMetadata.commit && + ui.jQuery.webxrayBuildMetadata.commit != "unknown", + "build commit is " + ui.jQuery.webxrayBuildMetadata.commit); + ok(ui.jQuery(".webxray-base").length, + ".webxray-base in goggles-injected document"); + ui.jQuery(window.document).uprootIgnoringWebxray(function(html) { + ok(html.indexOf('webxray-base') == -1, + ".webxray-base not in goggles-injected uproot"); + start(); + }); + }, window); + window.location = Webxray.getBookmarkletURL("../../../../../"); + }); + jQuery("#iframes").append(iframe).show(); }); - jQuery("#iframes").append(iframe).show(); -}); [ 'basic-page' From 7a5f4cea8266772d3ccf62c89acd7aaef5acc48b Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Thu, 21 Jun 2012 10:15:39 -0400 Subject: [PATCH 5/7] Fix uproot tests to work when there is a querystring arg in the URL. --- test/unit/uproot/uproot.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/unit/uproot/uproot.js b/test/unit/uproot/uproot.js index fc43789..819e57d 100644 --- a/test/unit/uproot/uproot.js +++ b/test/unit/uproot/uproot.js @@ -44,6 +44,7 @@ if (!location.search.match(/externalreporter=1/)) , 'complex-doctype' , 'no-doctype' ].forEach(function(name) { + var $ = jQuery; asyncTest(name, function() { var prefix = 'unit/uproot/'; var iframe = jQuery(""); @@ -53,7 +54,7 @@ if (!location.search.match(/externalreporter=1/)) function(expected) { var docElem = iframe.get(0).contentDocument.documentElement; var startHTML = docElem.innerHTML; - var baseURI = document.location.href + iframe.attr('src'); + var baseURI = $('').attr('href', iframe.attr('src'))[0].href; expected = expected.replace("{{ BASE_HREF }}", baseURI); iframe.uproot(function(actual) { equal(jQuery.trim(actual), jQuery.trim(expected), From 8c939f7ee5b88ceef5646a4e650348888c03a6f9 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Thu, 21 Jun 2012 10:32:34 -0400 Subject: [PATCH 6/7] Load the english locale from strings.json before running any tests. This ensures that even if the localization tests (which load the en locale) don't complete (or aren't run), other tests aren't broken. --- test/run-tests.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/run-tests.js b/test/run-tests.js index 47a2bb8..8aba04f 100644 --- a/test/run-tests.js +++ b/test/run-tests.js @@ -33,6 +33,14 @@ }); } + function loadEnLocale(cb) { + $.getJSON("../strings.json", function(strings) { + for (var namespace in strings) + jQuery.localization.extend("en", namespace, strings[namespace]); + cb(); + }); + } + $(window).ready(function() { $.getJSON("../config.json", function(obj) { var scripts = obj.compiledFileParts; @@ -45,7 +53,7 @@ window.jQuery.noConflict(); $.loadScripts(unitTests, "unit/", function(log) { makeTestModuleForLog("unit tests", log); - QUnit.start(); + loadEnLocale(function() { QUnit.start(); }); }); }); }); From dfa8d8706bdccd657b7d9611e66cdf951df43b9d Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Thu, 21 Jun 2012 10:34:05 -0400 Subject: [PATCH 7/7] Only run the localization test that loads en.js if not on simplesauce. --- test/unit/localization.js | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/test/unit/localization.js b/test/unit/localization.js index 428bfc3..bcf9c74 100644 --- a/test/unit/localization.js +++ b/test/unit/localization.js @@ -17,20 +17,23 @@ test("jQuery.fn.localize() works", function() { equal(div.find("p").text(), "baz"); }); -test("loadLocale() always triggers completion", function() { - jQuery.localization.loadLocale({ - path: "../src/locale/", - languages: ["en", "zz"], - complete: function(locale, loadResults) { - ok(locale && locale.languages, "locale object is passed through"); - equal(loadResults.length, 2); - deepEqual(loadResults[0], ["en", "success"]); - deepEqual(loadResults[1], ["zz", "error"]); - start(); - } +// Only run this test if we're not being served from a simple +// clone of the repository (i.e. simplesauce). +if (!location.search.match(/externalreporter=1/)) + test("loadLocale() always triggers completion", function() { + jQuery.localization.loadLocale({ + path: "../src/locale/", + languages: ["en", "zz"], + complete: function(locale, loadResults) { + ok(locale && locale.languages, "locale object is passed through"); + equal(loadResults.length, 2); + deepEqual(loadResults[0], ["en", "success"]); + deepEqual(loadResults[1], ["zz", "error"]); + start(); + } + }); + stop(); }); - stop(); -}); test("createLocale() inherits from non-region locales", function() { jQuery.localization.extend("en", "l10nTests", {