diff --git a/build-system/config.js b/build-system/config.js index 626edc499ca2..17d60ffd4f7c 100644 --- a/build-system/config.js +++ b/build-system/config.js @@ -139,7 +139,7 @@ module.exports = { // This does match dist.3p/current, so we run presubmit checks on the // built 3p binary. This is done, so we make sure our special 3p checks // run against the entire transitive closure of deps. - '!{node_modules,build,dist,dist.tools,' + + '!{node_modules,build,examples.build,dist,dist.tools,' + 'dist.3p/[0-9]*,dist.3p/current-min}/**/*.*', '!validator/node_modules/**/*.*', '!build-system/tasks/presubmit-checks.js', diff --git a/build-system/tasks/presubmit-checks.js b/build-system/tasks/presubmit-checks.js index f2c70fffe5cc..ed13a677fef8 100644 --- a/build-system/tasks/presubmit-checks.js +++ b/build-system/tasks/presubmit-checks.js @@ -75,6 +75,13 @@ var forbiddenTerms = { 'extensions/amp-analytics/0.1/amp-analytics.js', ], }, + 'installStorageService': { + message: privateServiceFactory, + whitelist: [ + 'extensions/amp-analytics/0.1/amp-analytics.js', + 'src/service/storage-impl.js', + ], + }, 'installViewerService': { message: privateServiceFactory, whitelist: [ @@ -111,6 +118,14 @@ var forbiddenTerms = { 'src/service/standard-actions-impl.js', ], }, + 'sendMessage': { + message: privateServiceFactory, + whitelist: [ + 'src/service/viewer-impl.js', + 'src/service/storage-impl.js', + 'examples/viewer-integr-messaging.js', + ], + }, // Privacy sensitive 'cidFor': { message: requiresReviewPrivacy, @@ -169,10 +184,17 @@ var forbiddenTerms = { ] }, 'eval\\(': '', + 'storageFor': { + message: requiresReviewPrivacy, + whitelist: [ + 'src/storage.js', + ], + }, 'localStorage': { message: requiresReviewPrivacy, whitelist: [ 'src/service/cid-impl.js', + 'src/service/storage-impl.js', ], }, 'sessionStorage': requiresReviewPrivacy, diff --git a/extensions/amp-analytics/0.1/amp-analytics.js b/extensions/amp-analytics/0.1/amp-analytics.js index fdd926d48bd4..adf92814c0ad 100644 --- a/extensions/amp-analytics/0.1/amp-analytics.js +++ b/extensions/amp-analytics/0.1/amp-analytics.js @@ -14,20 +14,21 @@ * limitations under the License. */ +import {ANALYTICS_CONFIG} from './vendors'; +import {addListener} from './instrumentation'; import {assertHttpsUrl} from '../../../src/url'; +import {expandTemplate} from '../../../src/string'; import {installCidService} from '../../../src/service/cid-impl'; +import {installStorageService} from '../../../src/service/storage-impl'; +import {isArray, isObject} from '../../../src/types'; import {log} from '../../../src/log'; +import {sendRequest} from './transport'; import {urlReplacementsFor} from '../../../src/url-replacements'; -import {expandTemplate} from '../../../src/string'; import {xhrFor} from '../../../src/xhr'; -import {isArray, isObject} from '../../../src/types'; - -import {addListener} from './instrumentation'; -import {sendRequest} from './transport'; -import {ANALYTICS_CONFIG} from './vendors'; installCidService(AMP.win); +installStorageService(AMP.win); export class AmpAnalytics extends AMP.BaseElement { diff --git a/src/service/storage-impl.js b/src/service/storage-impl.js new file mode 100644 index 000000000000..794994380884 --- /dev/null +++ b/src/service/storage-impl.js @@ -0,0 +1,381 @@ +/** + * Copyright 2016 The AMP HTML Authors. All Rights Reserved. + * + * 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. + */ + +import {assert} from '../asserts'; +import {getService} from '../service'; +import {getSourceOrigin} from '../url'; +import {isExperimentOn} from '../experiments'; +import {log} from '../log'; +import {timer} from '../timer'; +import {viewerFor} from '../viewer'; + +/** @const */ +const EXPERIMENT = 'amp-storage'; + +/** @const */ +const TAG = 'Storage'; + +/** @const */ +const MAX_VALUES_PER_ORIGIN = 8; + + +/** + * The storage API. This is an API equivalent to the Web LocalStorage API but + * extended to all AMP embedding scenarios. + * + * The storage is done per source origin. See `get`, `set` and `remove` methods + * for more info. + * + * Requires "amp-storage" experiment. + * + * @see https://html.spec.whatwg.org/multipage/webstorage.html + * @private Visible for testing only. + */ +export class Storage { + + /** + * @param {!Window} win + * @param {!Viewer} viewer + * @param {!StorageBindingDef} binding + */ + constructor(win, viewer, binding) { + /** @const {!Window} */ + this.win = win; + + /** @private @const {!Viewer} */ + this.viewer_ = viewer; + + /** @private @const {!StorageBindingDef} */ + this.binding_ = binding; + + /** @const @private {string} */ + this.origin_ = getSourceOrigin(this.win.location); + + /** @const @private {boolean} */ + this.isExperimentOn_ = isExperimentOn(this.win, EXPERIMENT); + + /** @private @const {!Promise} */ + this.whenStarted_ = this.isExperimentOn_ ? + Promise.resolve() : + Promise.reject(`Enable experiment ${EXPERIMENT}`); + + /** @private {?Promise} */ + this.storePromise_ = null; + } + + /** + * @return {!Storage} + * @private + */ + start_() { + if (!this.isExperimentOn_) { + log.info(TAG, 'Storage experiment is off: ', EXPERIMENT); + return this; + } + this.listenToBroadcasts_(); + return this; + } + + /** + * Returns the promise that yields the value of the property for the specified + * key. + * @param {string} name + * @return {!Promise<*>} + * @override + */ + get(name) { + return this.getStore_().then(store => store.get(name)); + } + + /** + * Saves the value of the specified property. Returns the promise that's + * resolved when the operation completes. + * @param {string} name + * @param {*} value + * @return {!Promise} + * @override + */ + set(name, value) { + assert(typeof value == 'boolean', 'Only boolean values accepted'); + return this.saveStore_(store => store.set(name, value)); + } + + /** + * Removes the specified property. Returns the promise that's resolved when + * the operation completes. + * @param {string} name + * @return {!Promise} + * @override + */ + remove(name) { + return this.saveStore_(store => store.remove(name)); + } + + /** + * @return {!Promise} + * @private + */ + getStore_() { + if (!this.storePromise_) { + this.storePromise_ = this.whenStarted_ + .then(() => this.binding_.loadBlob(this.origin_)) + .then(blob => blob ? JSON.parse(atob(blob)) : {}) + .catch(reason => { + log.error(TAG, 'Failed to load store: ', reason); + return {}; + }) + .then(obj => new Store(obj)); + } + return this.storePromise_; + } + + /** + * @param {function(!Store)} mutator + * @return {!Promise} + * @private + */ + saveStore_(mutator) { + return this.getStore_() + .then(store => { + mutator(store); + const blob = btoa(JSON.stringify(store.obj)); + return this.binding_.saveBlob(this.origin_, blob); + }) + .then(this.broadcastReset_.bind(this)); + } + + /** @private */ + listenToBroadcasts_() { + this.viewer_.onBroadcast(message => { + if (message['type'] == 'amp-storage-reset' && + message['origin'] == this.origin_) { + log.fine(TAG, 'Received reset message'); + this.storePromise_ = null; + } + }); + } + + /** @private */ + broadcastReset_() { + log.fine(TAG, 'Broadcasted reset message'); + this.viewer_.broadcast({ + 'type': 'amp-storage-reset', + 'origin': this.origin_ + }); + } +} + + +/** + * The implementation of store logic for get, set and remove. + * + * The structure of the store is equivalent to the following typedef: + * ``` + * { + * vv: !Object + * } + * ``` + * + * @private Visible for testing only. + */ +export class Store { + /** + * @param {!JSONObject} obj + * @param {number=} opt_maxValues + */ + constructor(obj, opt_maxValues) { + /** @const {!JSONObject} */ + this.obj = obj; + + /** @private @const {number} */ + this.maxValues_ = opt_maxValues || MAX_VALUES_PER_ORIGIN; + + /** @private @const {!Object} */ + this.values_ = obj['vv'] || {}; + if (!obj['vv']) { + obj['vv'] = this.values_; + } + } + + /** + * @param {string} name + * @return {*|undefined} + * @private + */ + get(name) { + // The structure is {key: {v: *, t: time}} + const item = this.values_[name]; + return item ? item['v'] : undefined; + } + + /** + * @param {string} name + * @param {*} value + * @private + */ + set(name, value) { + // The structure is {key: {v: *, t: time}} + if (this.values_[name] !== undefined) { + const item = this.values_[name]; + item['v'] = value; + item['t'] = timer.now(); + } else { + this.values_[name] = {'v': value, 't': timer.now()}; + } + + // Purge old values. + const keys = Object.keys(this.values_); + if (keys.length > this.maxValues_) { + let minTime = Infinity; + let minKey = null; + for (let i = 0; i < keys.length; i++) { + const item = this.values_[keys[i]]; + if (item['t'] < minTime) { + minKey = keys[i]; + minTime = item['t']; + } + } + if (minKey) { + delete this.values_[minKey]; + } + } + } + + /** + * @param {string} name + * @private + */ + remove(name) { + // The structure is {key: {v: *, t: time}} + delete this.values_[name]; + } +} + + +/** + * A binding provides the specific implementation of storage technology. + * @interface + */ +class StorageBindingDef { + + /** + * Returns the promise that yields the store blob for the specified origin. + * @param {string} unusedOrigin + * @return {!Promise} + */ + loadBlob(unusedOrigin) {} + + /** + * Saves the store blob for the specified origin and returns the promise + * that's resolved when the operation completes. + * @param {string} unusedOrigin + * @param {string} unusedBlob + * @return {!Promise} + */ + saveBlob(unusedOrigin, unusedBlob) {} +} + + +/** + * Storage implementation using Web LocalStorage API. + * @implements {StorageBindingDef} + * @private Visible for testing only. + */ +export class LocalStorageBinding { + + /** + * @param {!Window} win + */ + constructor(win) { + /** @const {!Window} */ + this.win = win; + } + + /** + * @param {string} origin + * @return {string} + * @private + */ + getKey_(origin) { + return `amp-store:${origin}`; + } + + /** @override */ + loadBlob(origin) { + return new Promise(resolve => { + resolve(this.win.localStorage.getItem(this.getKey_(origin))); + }); + } + + /** @override */ + saveBlob(origin, blob) { + return new Promise(resolve => { + this.win.localStorage.setItem(this.getKey_(origin), blob); + resolve(); + }); + } +} + + +/** + * Storage implementation delegated to the Viewer. + * @implements {StorageBindingDef} + * @private Visible for testing only. + */ +export class ViewerStorageBinding { + + /** + * @param {!Viewer} viewer + */ + constructor(viewer) { + /** @private @const {!Viewer} */ + this.viewer_ = viewer; + } + + /** @override */ + loadBlob(origin) { + return this.viewer_.sendMessage('loadStore', { + 'origin': origin + }, true).then(response => response['blob']); + } + + /** @override */ + saveBlob(origin, blob) { + return this.viewer_.sendMessage('saveStore', { + 'origin': origin, + 'blob': blob + }, true); + } +} + + +/** + * @param {!Window} window + * @return {!Storage} + */ +export function installStorageService(window) { + return getService(window, 'storage', () => { + const viewer = viewerFor(window); + const overrideStorage = parseInt(viewer.getParam('storage'), 10); + const binding = overrideStorage ? + new ViewerStorageBinding(viewer) : + new LocalStorageBinding(window); + return new Storage(window, viewer, binding).start_(); + }); +}; diff --git a/src/service/viewer-impl.js b/src/service/viewer-impl.js index 8545b0873020..fac189e24786 100644 --- a/src/service/viewer-impl.js +++ b/src/service/viewer-impl.js @@ -568,6 +568,17 @@ export class Viewer { } } + /** + * Sends the message to the viewer. This is a restricted API. + * @param {string} eventType + * @param {*} data + * @param {boolean} awaitResponse + * @return {!Promise<*>|undefined} + */ + sendMessage(eventType, data, awaitResponse) { + return this.sendMessage_(eventType, data, awaitResponse); + } + /** * @param {string} eventType * @param {*} data diff --git a/src/storage.js b/src/storage.js new file mode 100644 index 000000000000..c3e2e57e9297 --- /dev/null +++ b/src/storage.js @@ -0,0 +1,26 @@ +/** + * Copyright 2016 The AMP HTML Authors. All Rights Reserved. + * + * 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. + */ + +import {getElementService} from './custom-element'; + + +/** + * @param {!Window} window + * @return {!Storage} + */ +export function storageFor(window) { + return getElementService(window, 'storage', 'amp-analytics'); +}; diff --git a/test/functional/test-runtime.js b/test/functional/test-runtime.js index f9e89aa3afb8..4c28a6b353c3 100644 --- a/test/functional/test-runtime.js +++ b/test/functional/test-runtime.js @@ -15,6 +15,7 @@ */ import {adopt} from '../../src/runtime'; +import {parseUrl} from '../../src/url'; import * as sinon from 'sinon'; describe('runtime', () => { @@ -34,6 +35,7 @@ describe('runtime', () => { history: {}, navigator: {}, setTimeout: () => {}, + location: parseUrl('https://acme.com/document1'), }; }); diff --git a/test/functional/test-storage.js b/test/functional/test-storage.js new file mode 100644 index 000000000000..c9c32e3cbe58 --- /dev/null +++ b/test/functional/test-storage.js @@ -0,0 +1,550 @@ +/** + * Copyright 2015 The AMP HTML Authors. All Rights Reserved. + * + * 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. + */ + +import {Storage, Store, LocalStorageBinding, ViewerStorageBinding} from + '../../src/service/storage-impl'; +import {all} from '../../src/promise'; +import * as sinon from 'sinon'; + + +describe('Storage', () => { + let sandbox; + let storage; + let binding; + let bindingMock; + let viewer; + let viewerMock; + let windowApi; + let viewerBroadcastHandler; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + + viewerBroadcastHandler = undefined; + viewer = { + onBroadcast: handler => { + viewerBroadcastHandler = handler; + }, + broadcast: () => {} + }; + viewerMock = sandbox.mock(viewer); + + windowApi = { + document: { + cookie: 'AMP_EXP=amp-storage' + }, + location: 'https://acme.com/document1', + }; + + binding = { + loadBlob: () => {}, + saveBlob: () => {}, + }; + bindingMock = sandbox.mock(binding); + + storage = new Storage(windowApi, viewer, binding); + storage.start_(); + }); + + afterEach(() => { + sandbox.restore(); + sandbox = null; + }); + + function expectStorage(keyValues) { + const list = []; + for (const k in keyValues) { + list.push(storage.get(k).then(value => { + const expectedValue = keyValues[k]; + expect(value).to.equal(expectedValue, `For "${k}"`); + })); + } + return all(list); + } + + it('should initialize with experiment', () => { + expect(viewerBroadcastHandler).to.exist; + }); + + it('should not initialize without experiment', () => { + viewerBroadcastHandler = undefined; + windowApi.document.cookie = ''; + new Storage(windowApi, viewer, binding).start_(); + expect(viewerBroadcastHandler).to.not.exist; + }); + + it('should configure store correctly', () => { + const store1 = new Store({}); + store1.set('key1', 'value1'); + store1.set('key2', 'value2'); + bindingMock.expects('loadBlob') + .withExactArgs('https://acme.com') + .returns(btoa(JSON.stringify(store1.obj))) + .once(); + return storage.get('key1').then(() => { + return storage.storePromise_; + }).then(store => { + expect(store.maxValues_).to.equal(8); + }); + }); + + it('should get the value first time and reuse store', () => { + const store1 = new Store({}); + store1.set('key1', 'value1'); + store1.set('key2', 'value2'); + bindingMock.expects('loadBlob') + .withExactArgs('https://acme.com') + .returns(btoa(JSON.stringify(store1.obj))) + .once(); + expect(storage.storePromise_).to.not.exist; + const promise = storage.get('key1'); + return promise.then(value => { + expect(value).to.equal('value1'); + const store1Promise = storage.storePromise_; + expect(store1Promise).to.exist; + + // Repeat. + return storage.get('key2').then(value2 => { + expect(value2).to.equal('value2'); + expect(storage.storePromise_).to.equal(store1Promise); + }); + }); + }); + + it('should get the value from first ever request and reuse store', () => { + bindingMock.expects('loadBlob') + .withExactArgs('https://acme.com') + .returns(null) + .once(); + expect(storage.storePromise_).to.not.exist; + const promise = storage.get('key1'); + return promise.then(value => { + expect(value).to.be.undefined; + const store1Promise = storage.storePromise_; + expect(store1Promise).to.exist; + + // Repeat. + return storage.get('key2').then(value2 => { + expect(value2).to.be.undefined; + expect(storage.storePromise_).to.equal(store1Promise); + }); + }); + }); + + it('should recover from binding failure', () => { + bindingMock.expects('loadBlob') + .withExactArgs('https://acme.com') + .returns(Promise.reject('intentional')) + .once(); + expect(storage.storePromise_).to.not.exist; + const promise = storage.get('key1'); + return promise.then(value => { + expect(value).to.be.undefined; + expect(storage.storePromise_).to.exist; + }); + }); + + it('should recover from binding error', () => { + bindingMock.expects('loadBlob') + .withExactArgs('https://acme.com') + .returns(Promise.resolve('UNKNOWN FORMAT')) + .once(); + expect(storage.storePromise_).to.not.exist; + const promise = storage.get('key1'); + return promise.then(value => { + expect(value).to.be.undefined; + expect(storage.storePromise_).to.exist; + }); + }); + + it('should save the value first time and reuse store', () => { + const store1 = new Store({}); + store1.set('key1', 'value1'); + store1.set('key2', 'value2'); + bindingMock.expects('loadBlob') + .withExactArgs('https://acme.com') + .returns(btoa(JSON.stringify(store1.obj))) + .once(); + bindingMock.expects('saveBlob') + .withExactArgs('https://acme.com', sinon.match(arg => { + const store2 = new Store(JSON.parse(atob(arg))); + return (store2.get('key1') !== undefined && + store2.get('key2') !== undefined); + })) + .returns(Promise.resolve()) + .twice(); + viewerMock.expects('broadcast') + .withExactArgs(sinon.match(arg => { + return (arg['type'] == 'amp-storage-reset' && + arg['origin'] == 'https://acme.com'); + })) + .twice(); + expect(storage.storePromise_).to.not.exist; + const promise = storage.set('key1', true); + return promise.then(() => { + const store1Promise = storage.storePromise_; + expect(store1Promise).to.exist; + + // Repeat. + return storage.set('key2', true).then(() => { + expect(storage.storePromise_).to.equal(store1Promise); + }); + }).then(() => { + return expectStorage({ + 'key1': true, + 'key2': true, + }); + }); + }); + + it('should remove the key first time and reuse store', () => { + const store1 = new Store({}); + store1.set('key1', 'value1'); + store1.set('key2', 'value2'); + bindingMock.expects('loadBlob') + .withExactArgs('https://acme.com') + .returns(btoa(JSON.stringify(store1.obj))) + .once(); + bindingMock.expects('saveBlob') + .withExactArgs('https://acme.com', sinon.match(arg => { + const store2 = new Store(JSON.parse(atob(arg))); + return (store2.get('key1') === undefined); + })) + .returns(Promise.resolve()) + .twice(); + viewerMock.expects('broadcast') + .withExactArgs(sinon.match(arg => { + return (arg['type'] == 'amp-storage-reset' && + arg['origin'] == 'https://acme.com'); + })) + .twice(); + expect(storage.storePromise_).to.not.exist; + const promise = storage.remove('key1'); + return promise.then(() => { + const store1Promise = storage.storePromise_; + expect(store1Promise).to.exist; + + // Repeat. + return storage.remove('key2').then(() => { + expect(storage.storePromise_).to.equal(store1Promise); + }); + }).then(() => { + return expectStorage({ + 'key1': undefined, + 'key2': undefined, + }); + }); + }); + + it('should react to reset messages', () => { + const store1 = new Store({}); + store1.set('key1', 'value1'); + bindingMock.expects('loadBlob') + .withExactArgs('https://acme.com') + .returns(btoa(JSON.stringify(store1.obj))) + .twice(); + return storage.get('key1').then(value => { + expect(value).to.equal('value1'); + const store1Promise = storage.storePromise_; + expect(store1Promise).to.exist; + + // Issue broadcast event. + viewerBroadcastHandler({ + 'type': 'amp-storage-reset', + 'origin': 'https://acme.com' + }); + expect(storage.storePromise_).to.not.exist; + return storage.get('key1').then(value => { + expect(value).to.equal('value1'); + expect(storage.storePromise_).to.exist; + }); + }); + }); + + it('should ignore unrelated reset messages', () => { + const store1 = new Store({}); + store1.set('key1', 'value1'); + bindingMock.expects('loadBlob') + .withExactArgs('https://acme.com') + .returns(btoa(JSON.stringify(store1.obj))) + .twice(); + return storage.get('key1').then(value => { + expect(value).to.equal('value1'); + const store1Promise = storage.storePromise_; + expect(store1Promise).to.exist; + + // Issue broadcast event. + viewerBroadcastHandler({ + 'type': 'amp-storage-reset', + 'origin': 'OTHER' + }); + expect(storage.storePromise_).to.exist; + }); + }); +}); + + +describe('Store', () => { + let sandbox; + let clock; + let store; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + clock = sandbox.useFakeTimers(); + store = new Store({}, 2); + }); + + afterEach(() => { + clock.restore(); + clock = null; + sandbox.restore(); + sandbox = null; + }); + + it('should get undefined with empty store', () => { + expect(store.get('key1')).to.be.undefined; + expect(Object.keys(store.values_).length).to.equal(0); + expect(store.values_).to.deep.equal({}); + }); + + it('should set a new value with timestamp', () => { + store.set('key2', 'value2'); + clock.tick(101); + store.set('key1', 'value1'); + expect(store.get('key1')).to.equal('value1'); + expect(Object.keys(store.values_).length).to.equal(2); + expect(store.values_['key2']['t']).to.equal(0); + expect(store.values_['key1']['t']).to.equal(101); + expect(store.values_).to.deep.equal({ + 'key2': {v: 'value2', t: 0}, + 'key1': {v: 'value1', t: 101} + }); + }); + + it('should overwrite a value with new timestamp', () => { + store.set('key1', 'value1'); + store.set('key2', 'value2'); + clock.tick(101); + store.set('key1', 'value1b'); + expect(store.get('key1')).to.equal('value1b'); + expect(Object.keys(store.values_).length).to.equal(2); + expect(store.values_['key1']['t']).to.equal(101); + expect(store.values_['key2']['t']).to.equal(0); + expect(store.values_).to.deep.equal({ + 'key1': {v: 'value1b', t: 101}, + 'key2': {v: 'value2', t: 0} + }); + }); + + it('should remove a value', () => { + store.set('key1', 'value1'); + store.set('key2', 'value2'); + clock.tick(101); + expect(Object.keys(store.values_).length).to.equal(2); + store.remove('key1'); + expect(store.get('key1')).to.be.undefined; + expect(store.get('key2')).to.be.equal('value2'); + expect(Object.keys(store.values_).length).to.equal(1); + expect(store.values_['key2']['t']).to.equal(0); + expect(store.values_).to.deep.equal({ + 'key2': {v: 'value2', t: 0} + }); + }); + + it('should store limited amount of values', () => { + clock.tick(1); + store.set('k1', 1); + expect(Object.keys(store.values_).length).to.equal(1); + + clock.tick(1); + store.set('k2', 2); + expect(Object.keys(store.values_).length).to.equal(2); + + // The oldest (k2) will be removed. + clock.tick(1); + store.set('k1', 4); + store.set('k3', 3); + expect(Object.keys(store.values_).length).to.equal(2); + expect(store.get('k3')).to.equal(3); + expect(store.get('k1')).to.equal(4); + expect(store.get('k2')).to.be.undefined; + + // The new oldest (k1) will be removed + clock.tick(1); + store.set('k4', 4); + expect(Object.keys(store.values_).length).to.equal(2); + expect(store.get('k4')).to.equal(4); + expect(store.get('k3')).to.equal(3); + expect(store.get('k1')).to.be.undefined; + expect(store.get('k2')).to.be.undefined; + }); +}); + + +describe('LocalStorageBinding', () => { + let sandbox; + let windowApi; + let localStorageMock; + let binding; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + windowApi = { + localStorage: { + getItem: () => {}, + setItem: () => {}, + } + }; + localStorageMock = sandbox.mock(windowApi.localStorage); + binding = new LocalStorageBinding(windowApi); + }); + + afterEach(() => { + sandbox.restore(); + sandbox = null; + }); + + it('should load store when available', () => { + localStorageMock.expects('getItem') + .withExactArgs('amp-store:https://acme.com') + .returns('BLOB1') + .once(); + return binding.loadBlob('https://acme.com').then(blob => { + expect(blob).to.equal('BLOB1'); + }); + }); + + it('should load default store when not yet available', () => { + localStorageMock.expects('getItem') + .withExactArgs('amp-store:https://acme.com') + .returns(undefined) + .once(); + return binding.loadBlob('https://acme.com').then(blob => { + expect(blob).to.not.exist; + }); + }); + + it('should reject on local storage failure', () => { + localStorageMock.expects('getItem') + .withExactArgs('amp-store:https://acme.com') + .throws(new Error('unknown')) + .once(); + return binding.loadBlob('https://acme.com') + .then(() => 'SUCCESS', () => 'ERROR').then(res => { + expect(res).to.equal('ERROR'); + }); + }); + + it('should save store', () => { + localStorageMock.expects('setItem') + .withExactArgs('amp-store:https://acme.com', 'BLOB1') + .once(); + return binding.saveBlob('https://acme.com', 'BLOB1'); + }); + + it('should reject on save store failure', () => { + localStorageMock.expects('setItem') + .withExactArgs('amp-store:https://acme.com', 'BLOB1') + .throws(new Error('unknown')) + .once(); + return binding.saveBlob('https://acme.com', 'BLOB1') + .then(() => 'SUCCESS', () => 'ERROR').then(res => { + expect(res).to.equal('ERROR'); + }); + }); +}); + + +describe('ViewerStorageBinding', () => { + let sandbox; + let viewer; + let viewerMock; + let binding; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + viewer = { + sendMessage: () => {} + }; + viewerMock = sandbox.mock(viewer); + binding = new ViewerStorageBinding(viewer); + }); + + afterEach(() => { + sandbox.restore(); + sandbox = null; + }); + + it('should load store from viewer', () => { + viewerMock.expects('sendMessage') + .withExactArgs('loadStore', sinon.match(arg => { + return (arg['origin'] == 'https://acme.com'); + }), true) + .returns(Promise.resolve({'blob': 'BLOB1'})) + .once(); + return binding.loadBlob('https://acme.com').then(blob => { + expect(blob).to.equal('BLOB1'); + }); + }); + + it('should load default store when not yet available', () => { + viewerMock.expects('sendMessage') + .withExactArgs('loadStore', sinon.match(arg => { + return (arg['origin'] == 'https://acme.com'); + }), true) + .returns(Promise.resolve({})) + .once(); + return binding.loadBlob('https://acme.com').then(blob => { + expect(blob).to.not.exist; + }); + }); + + it('should reject on viewer failure', () => { + viewerMock.expects('sendMessage') + .withExactArgs('loadStore', sinon.match(arg => { + return (arg['origin'] == 'https://acme.com'); + }), true) + .returns(Promise.reject('unknown')) + .once(); + return binding.loadBlob('https://acme.com') + .then(() => 'SUCCESS', () => 'ERROR').then(res => { + expect(res).to.equal('ERROR'); + }); + }); + + it('should save store', () => { + viewerMock.expects('sendMessage') + .withExactArgs('saveStore', sinon.match(arg => { + return (arg['origin'] == 'https://acme.com' && + arg['blob'] == 'BLOB1'); + }), true) + .returns(Promise.resolve()) + .once(); + return binding.saveBlob('https://acme.com', 'BLOB1'); + }); + + it('should reject on save store failure', () => { + viewerMock.expects('sendMessage') + .withExactArgs('saveStore', sinon.match(() => true), true) + .returns(Promise.reject('unknown')) + .once(); + return binding.saveBlob('https://acme.com', 'BLOB1') + .then(() => 'SUCCESS', () => 'ERROR').then(res => { + expect(res).to.equal('ERROR'); + }); + }); +});