diff --git a/build/conformance.textproto b/build/conformance.textproto index c91013695..e17083f5a 100644 --- a/build/conformance.textproto +++ b/build/conformance.textproto @@ -49,6 +49,9 @@ requirement { java_class: 'com.google.javascript.jscomp.ConformanceRules$BanUnknownThis' error_message: 'Failed to infer type of "this"; ' 'please be explicit or use bind!' + # Until we can get this rule updated for ES6 static methods + # (https://github.com/google/closure-compiler/issues/2880): + whitelist_regexp: 'test/test/util/canned_idb.js' } diff --git a/lib/util/string_utils.js b/lib/util/string_utils.js index 7a5d423e7..2cc58fe29 100644 --- a/lib/util/string_utils.js +++ b/lib/util/string_utils.js @@ -46,7 +46,7 @@ shaka.util.StringUtils.fromUTF8 = function(data) { } // http://stackoverflow.com/a/13691499 - let utf8 = shaka.util.StringUtils.fromCharCode_(uint8); + let utf8 = shaka.util.StringUtils.fromCharCode(uint8); // This converts each character in the string to an escape sequence. If the // character is in the ASCII range, it is not converted; otherwise it is // converted to a URI escape sequence. @@ -107,7 +107,7 @@ shaka.util.StringUtils.fromUTF16 = function(data, littleEndian, opt_noThrow) { for (let i = 0; i < length; i++) { arr[i] = dataView.getUint16(i * 2, littleEndian); } - return shaka.util.StringUtils.fromCharCode_(arr); + return shaka.util.StringUtils.fromCharCode(arr); }; @@ -185,15 +185,18 @@ shaka.util.StringUtils.toUTF8 = function(str) { /** * Creates a new string from the given array of char codes. * - * @param {!TypedArray} args + * Using String.fromCharCode.apply is risky because you can trigger stack errors + * on very large arrays. This breaks up the array into several pieces to avoid + * this. + * + * @param {!TypedArray} array * @return {string} - * @private */ -shaka.util.StringUtils.fromCharCode_ = function(args) { +shaka.util.StringUtils.fromCharCode = function(array) { let max = 16000; let ret = ''; - for (let i = 0; i < args.length; i += max) { - let subArray = args.subarray(i, i + max); + for (let i = 0; i < array.length; i += max) { + let subArray = array.subarray(i, i + max); ret += String.fromCharCode.apply(null, subArray); } diff --git a/lib/util/uint8array_utils.js b/lib/util/uint8array_utils.js index 7b7e9f48b..74e24f5a5 100644 --- a/lib/util/uint8array_utils.js +++ b/lib/util/uint8array_utils.js @@ -17,6 +17,8 @@ goog.provide('shaka.util.Uint8ArrayUtils'); +goog.require('shaka.util.StringUtils'); + /** * @namespace shaka.util.Uint8ArrayUtils @@ -36,7 +38,7 @@ goog.provide('shaka.util.Uint8ArrayUtils'); */ shaka.util.Uint8ArrayUtils.toBase64 = function(arr, opt_padding) { // btoa expects a "raw string" where each character is interpreted as a byte. - let bytes = String.fromCharCode.apply(null, arr); + let bytes = shaka.util.StringUtils.fromCharCode(arr); let padding = (opt_padding == undefined) ? true : opt_padding; let base64 = window.btoa(bytes).replace(/\+/g, '-').replace(/\//g, '_'); return padding ? base64 : base64.replace(/=*$/, ''); diff --git a/test/test/assets/db-dump-v1.json b/test/test/assets/db-dump-v1.json new file mode 100644 index 000000000..98abb8a31 --- /dev/null +++ b/test/test/assets/db-dump-v1.json @@ -0,0 +1 @@ +{"version":1,"stores":{"manifest":{"parameters":{"keyPath":"key","autoIncrement":false},"data":[{"value":{"key":0,"originalManifestUri":"//storage.googleapis.com/shaka-demo-assets/heliocentrism/heliocentrism.mpd","duration":4.904831,"size":456654,"expiration":null,"periods":[{"startTime":0,"streams":[{"id":5,"primary":false,"presentationTimeOffset":0,"contentType":"video","mimeType":"video/mp4","codecs":"avc1.4d401f","frameRate":29.97,"language":"und","label":null,"width":640,"height":480,"initSegmentUri":"offline:0/5/1","encrypted":false,"keyId":null,"segments":[{"startTime":0,"endTime":2.06874,"uri":"offline:0/5/0"}],"variantIds":[13]}]},{"startTime":2.06874,"streams":[{"id":24,"primary":false,"presentationTimeOffset":0,"contentType":"video","mimeType":"video/mp4","codecs":"avc1.4d401f","frameRate":29.97,"language":"und","label":null,"width":640,"height":480,"initSegmentUri":"offline:0/24/3","encrypted":false,"keyId":null,"segments":[{"startTime":0,"endTime":2.13539,"uri":"offline:0/24/2"}],"variantIds":[32]}]},{"startTime":4.20413,"streams":[{"id":35,"primary":false,"presentationTimeOffset":0,"contentType":"video","mimeType":"video/webm","codecs":"vp9","frameRate":30.303030303030305,"language":"und","label":null,"width":320,"height":240,"initSegmentUri":"offline:0/35/5","encrypted":false,"keyId":null,"segments":[{"startTime":0,"endTime":0.700701,"uri":"offline:0/35/4"}],"variantIds":[39]}]}],"sessionIds":[],"drmInfo":null,"appMetadata":{"name":"[OFFLINE] Heliocentrism (multicodec, multiperiod)"}}}]},"segment":{"parameters":{"keyPath":"key","autoIncrement":false},"data":[{"value":{"key":0,"data":{"__type__":"ArrayBuffer","__value__":""},"manifestKey":0,"streamNumber":5,"segmentNumber":0}},{"value":{"key":1,"data":{"__type__":"ArrayBuffer","__value__":""},"manifestKey":0,"streamNumber":5,"segmentNumber":-1}},{"value":{"key":2,"data":{"__type__":"ArrayBuffer","__value__":""},"manifestKey":0,"streamNumber":24,"segmentNumber":2}},{"value":{"key":3,"data":{"__type__":"ArrayBuffer","__value__":""},"manifestKey":0,"streamNumber":24,"segmentNumber":-1}},{"value":{"key":4,"data":{"__type__":"ArrayBuffer","__value__":""},"manifestKey":0,"streamNumber":35,"segmentNumber":4}},{"value":{"key":5,"data":{"__type__":"ArrayBuffer","__value__":""},"manifestKey":0,"streamNumber":35,"segmentNumber":-1}}]}}} diff --git a/test/test/assets/db-dump-v2-broken.json b/test/test/assets/db-dump-v2-broken.json new file mode 100644 index 000000000..44ddbaea1 --- /dev/null +++ b/test/test/assets/db-dump-v2-broken.json @@ -0,0 +1 @@ +{"version":2,"stores":{"manifest":{"parameters":{"keyPath":"key","autoIncrement":false},"data":[]},"segment":{"parameters":{"keyPath":"key","autoIncrement":false},"data":[]},"manifest-v2":{"parameters":{"keyPath":null,"autoIncrement":false},"data":[{"value":{"originalManifestUri":"//storage.googleapis.com/shaka-demo-assets/heliocentrism/heliocentrism.mpd","duration":4.904831,"size":456654,"expiration":null,"periods":[{"startTime":0,"streams":[{"id":5,"primary":false,"presentationTimeOffset":0,"contentType":"video","mimeType":"video/mp4","codecs":"avc1.4d401f","frameRate":29.97,"language":"und","label":null,"width":640,"height":480,"initSegmentKey":1,"encrypted":false,"keyId":null,"segments":[{"startTime":0,"endTime":2.06874,"dataKey":0}],"variantIds":[13]}]},{"startTime":2.06874,"streams":[{"id":24,"primary":false,"presentationTimeOffset":0,"contentType":"video","mimeType":"video/mp4","codecs":"avc1.4d401f","frameRate":29.97,"language":"und","label":null,"width":640,"height":480,"initSegmentKey":3,"encrypted":false,"keyId":null,"segments":[{"startTime":0,"endTime":2.13539,"dataKey":2}],"variantIds":[32]}]},{"startTime":4.20413,"streams":[{"id":35,"primary":false,"presentationTimeOffset":0,"contentType":"video","mimeType":"video/webm","codecs":"vp9","frameRate":30.303030303030305,"language":"und","label":null,"width":320,"height":240,"initSegmentKey":5,"encrypted":false,"keyId":null,"segments":[{"startTime":0,"endTime":0.700701,"dataKey":4}],"variantIds":[39]}]}],"sessionIds":[],"drmInfo":null,"appMetadata":{"name":"[OFFLINE] Heliocentrism (multicodec, multiperiod)"}},"key":0}]},"segment-v2":{"parameters":{"keyPath":null,"autoIncrement":false},"data":[{"value":{"data":{"__type__":"ArrayBuffer","__value__":""}},"key":0},{"value":{"data":{"__type__":"ArrayBuffer","__value__":""}},"key":1},{"value":{"data":{"__type__":"ArrayBuffer","__value__":""}},"key":2},{"value":{"data":{"__type__":"ArrayBuffer","__value__":""}},"key":3},{"value":{"data":{"__type__":"ArrayBuffer","__value__":""}},"key":4},{"value":{"data":{"__type__":"ArrayBuffer","__value__":""}},"key":5}]}}} diff --git a/test/test/assets/db-dump-v2-clean.json b/test/test/assets/db-dump-v2-clean.json new file mode 100644 index 000000000..463ecfbda --- /dev/null +++ b/test/test/assets/db-dump-v2-clean.json @@ -0,0 +1 @@ +{"version":2,"stores":{"manifest-v2":{"parameters":{"keyPath":null,"autoIncrement":true},"data":[{"key":1,"value":{"originalManifestUri":"//storage.googleapis.com/shaka-demo-assets/heliocentrism/heliocentrism.mpd","duration":4.904831,"size":456654,"expiration":null,"periods":[{"startTime":0,"streams":[{"id":5,"primary":false,"presentationTimeOffset":0,"contentType":"video","mimeType":"video/mp4","codecs":"avc1.4d401f","frameRate":29.97,"language":"und","label":null,"width":640,"height":480,"initSegmentKey":1,"encrypted":false,"keyId":null,"segments":[{"startTime":0,"endTime":2.06874,"dataKey":3}],"variantIds":[13]}]},{"startTime":2.06874,"streams":[{"id":24,"primary":false,"presentationTimeOffset":0,"contentType":"video","mimeType":"video/mp4","codecs":"avc1.4d401f","frameRate":29.97,"language":"und","label":null,"width":640,"height":480,"initSegmentKey":2,"encrypted":false,"keyId":null,"segments":[{"startTime":0,"endTime":2.13539,"dataKey":5}],"variantIds":[32]}]},{"startTime":4.20413,"streams":[{"id":35,"primary":false,"presentationTimeOffset":0,"contentType":"video","mimeType":"video/webm","codecs":"vp9","frameRate":30.303030303030305,"language":"und","label":null,"width":320,"height":240,"initSegmentKey":4,"encrypted":false,"keyId":null,"segments":[{"startTime":0,"endTime":0.700701,"dataKey":6}],"variantIds":[39]}]}],"sessionIds":[],"drmInfo":null,"appMetadata":{"name":"[OFFLINE] Heliocentrism (multicodec, multiperiod)"}}}]},"segment-v2":{"parameters":{"keyPath":null,"autoIncrement":true},"data":[{"key":1,"value":{"data":{"__type__":"ArrayBuffer","__value__":""}}},{"key":2,"value":{"data":{"__type__":"ArrayBuffer","__value__":""}}},{"key":3,"value":{"data":{"__type__":"ArrayBuffer","__value__":""}}},{"key":4,"value":{"data":{"__type__":"ArrayBuffer","__value__":""}}},{"key":5,"value":{"data":{"__type__":"ArrayBuffer","__value__":""}}},{"key":6,"value":{"data":{"__type__":"ArrayBuffer","__value__":""}}}]}}} diff --git a/test/test/assets/db-dump-v3.json b/test/test/assets/db-dump-v3.json new file mode 100644 index 000000000..2730d9a9d --- /dev/null +++ b/test/test/assets/db-dump-v3.json @@ -0,0 +1 @@ +{"version":3,"stores":{"manifest-v3":{"parameters":{"keyPath":null,"autoIncrement":true},"data":[{"key":1,"value":{"originalManifestUri":"//storage.googleapis.com/shaka-demo-assets/heliocentrism/heliocentrism.mpd","duration":4.904831,"size":456654,"expiration":null,"periods":[{"startTime":0,"streams":[{"id":5,"primary":false,"presentationTimeOffset":0,"contentType":"video","mimeType":"video/mp4","codecs":"avc1.4d401f","frameRate":29.97,"language":"und","label":null,"width":640,"height":480,"initSegmentKey":1,"encrypted":false,"keyId":null,"segments":[{"startTime":0,"endTime":2.06874,"dataKey":3}],"variantIds":[13]}]},{"startTime":2.06874,"streams":[{"id":24,"primary":false,"presentationTimeOffset":0,"contentType":"video","mimeType":"video/mp4","codecs":"avc1.4d401f","frameRate":29.97,"language":"und","label":null,"width":640,"height":480,"initSegmentKey":2,"encrypted":false,"keyId":null,"segments":[{"startTime":0,"endTime":2.13539,"dataKey":5}],"variantIds":[32]}]},{"startTime":4.20413,"streams":[{"id":35,"primary":false,"presentationTimeOffset":0,"contentType":"video","mimeType":"video/webm","codecs":"vp9","frameRate":30.303030303030305,"language":"und","label":null,"width":320,"height":240,"initSegmentKey":4,"encrypted":false,"keyId":null,"segments":[{"startTime":0,"endTime":0.700701,"dataKey":6}],"variantIds":[39]}]}],"sessionIds":[],"drmInfo":null,"appMetadata":{"name":"[OFFLINE] Heliocentrism (multicodec, multiperiod)"}}}]},"segment-v3":{"parameters":{"keyPath":null,"autoIncrement":true},"data":[{"key":1,"value":{"data":{"__type__":"ArrayBuffer","__value__":""}}},{"key":2,"value":{"data":{"__type__":"ArrayBuffer","__value__":""}}},{"key":3,"value":{"data":{"__type__":"ArrayBuffer","__value__":""}}},{"key":4,"value":{"data":{"__type__":"ArrayBuffer","__value__":""}}},{"key":5,"value":{"data":{"__type__":"ArrayBuffer","__value__":""}}},{"key":6,"value":{"data":{"__type__":"ArrayBuffer","__value__":""}}}]}}} diff --git a/test/test/util/canned_idb.js b/test/test/util/canned_idb.js new file mode 100644 index 000000000..a75686163 --- /dev/null +++ b/test/test/util/canned_idb.js @@ -0,0 +1,398 @@ +/** + * @license + * Copyright 2016 Google Inc. + * + * 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. + */ + +goog.provide('shaka.test.CannedIDB'); + +/** + * A testing utility that can be used to dump and restore entire IndexedDB + * databases. This can be inserted into a running app to snapshot databases + * for testing. + * + * @example + * shaka.test = shaka.test || {}; + * s = document.createElement('script'); + * s.src = '/shaka/test/test/util/canned_idb.js'; + * document.head.appendChild(s); + * dump = await shaka.test.CannedIDB.dumpJSON('shaka_offline_db', true); + */ +shaka.test.CannedIDB = class { + /** + * @param {string} name The name of the database to dump. + * @param {boolean=} dummyArrayBuffers If true, replace array buffer data with + * dummy data, as you might want for generating unit test data. Defaults to + * false. + * @return {!Promise.} A JSON string that can be used to recreate the + * database later in a call to restoreJSON(). + */ + static async dumpJSON(name, dummyArrayBuffers) { + const savedDatabase = await this.dump(name, dummyArrayBuffers); + const replacer = this.replacer_.bind(null, dummyArrayBuffers); + return JSON.stringify(savedDatabase, replacer); + } + + /** + * @param {string} name The name of the database to dump. + * @return {!Promise.} An object that can + * be used to recreate the database later in a call to restore(). + */ + static async dump(name) { + // Open the database, which should exist already. + const db = await this.openDatabase_(name); + + try { + const savedDatabase = { + version: db.version, + stores: {}, + }; + + // For each store, dump the store parameters and data. + const dumpOperations = []; + for (const storeName of db.objectStoreNames) { + dumpOperations.push(this.dumpStore_(db, storeName, savedDatabase)); + } + await Promise.all(dumpOperations); + + return savedDatabase; + } finally { + // Make sure the DB gets closed no matter what. + db.close(); + } + } + + /** + * @param {string} name The name of the database to restore. + * @param {string} savedDatabaseJson A JSON string containing the database + * definition and the data to populate it with. + * @param {boolean=} wipeDatabase If true, wipe the database before loading + * the saved data. If false, add stores and data, but keep any existing + * stores and data. Defaults to true. + * @return {!Promise} Resolved when the operation is complete. + */ + static async restoreJSON(name, savedDatabaseJson, wipeDatabase) { + const savedDatabase = JSON.parse(savedDatabaseJson, this.reviver_); + await this.restore(name, savedDatabase, wipeDatabase); + } + + /** + * @param {string} name The name of the database to restore. + * @param {shaka.test.CannedIDB.SavedDatabase} savedDatabase An object + * containing the database definition and the data to populate it with. + * @param {boolean=} wipeDatabase If true, wipe the database before loading + * the saved data. If false, add stores and data, but keep any existing + * stores and data. Defaults to true. + * @return {!Promise} Resolved when the operation is complete. + */ + static async restore(name, savedDatabase, wipeDatabase) { + wipeDatabase = (wipeDatabase == undefined) ? true : wipeDatabase; + + if (wipeDatabase) { + // Wipe out any existing data. + await this.deleteDatabase_(name); + } + + // Create a new DB, or open an existing one, and add the stores we need. + const db = await this.createDatabase_(name, savedDatabase); + + try { + // Populate it with data. + await this.populateDatabase_(db, savedDatabase); + } finally { + // Make sure the DB gets closed no matter what. + db.close(); + } + } + + /** + * A replacer callback for JSON.stringify. It creates special objects to + * represent types that can't be directly stringified into JSON, such as + * ArrayBuffer. Should be used with bind() to supply the dummyArrayBuffers + * argument. + * + * @param {boolean} dummyArrayBuffers + * @param {string} key + * @param {?} value + * @return {?} + * @private + */ + static replacer_(dummyArrayBuffers, key, value) { + if (value instanceof ArrayBuffer) { + /** @type {string} */ + let data; + if (dummyArrayBuffers) { + data = ''; + } else { + data = shaka.util.Uint8ArrayUtils.toBase64(new Uint8Array(value)); + } + return { + __type__: 'ArrayBuffer', + __value__: data, + }; + } + return value; + } + + /** + * A reviver callback for JSON.parse. It recognizes special objects from + * replacer_ and turns them back into their original format. + * + * @param {string} key + * @param {?} value + * @return {?} + * @private + */ + static reviver_(key, value) { + if (value && value.__type__ == 'ArrayBuffer') { + return shaka.util.Uint8ArrayUtils.fromBase64(value.__value__).buffer; + } + return value; + } + + /** + * @param {string} name The name of the database to open. + * @return {!Promise.} Resolved when the named DB has been + * opened. + * @private + */ + static openDatabase_(name) { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name); + + request.onupgradeneeded = (event) => { + reject(new Error('DB did not exist!')); + const transaction = event.target.transaction; + transaction.abort(); + }; + + request.onsuccess = (event) => { + /** @type {IDBDatabase} */ + const db = event.target.result; + resolve(db); + }; + + request.onerror = (event) => { + event.preventDefault(); + reject(request.error); + }; + }); + } + + /** + * @param {IDBDatabase} db An open database connection. + * @param {string} name The store name to dump. + * @param {shaka.test.CannedIDB.SavedDatabase} savedDatabase An object where + * we write the store parameters and the data from the named store. + * @return {!Promise} Resolved when the store has been written to + * savedDatabase.stores. + * @private + */ + static dumpStore_(db, name, savedDatabase) { + return new Promise((resolve, reject) => { + const transactionType = 'readonly'; + const transaction = db.transaction([name], transactionType); + const store = transaction.objectStore(name); + shaka.log.debug('Dumping store', name); + + /** @type {shaka.test.CannedIDB.SavedStore} */ + const savedStore = { + parameters: { + keyPath: store.keyPath, + autoIncrement: store.autoIncrement, + }, + data: [], + }; + + const request = store.openCursor(); + + request.onsuccess = (event) => { + /** @type {IDBCursorWithValue} */ + const cursor = event.target.result; + if (!cursor) { + // No more data. + return; + } + + // Only store the key if there is no explicit keyPath for this store. + const data = {value: cursor.value}; + if (!store.keyPath) { + data.key = cursor.key; + } + savedStore.data.push(data); + + cursor.continue(); + }; + + transaction.oncomplete = (event) => { + shaka.log.debug('Dumped', savedStore.data.length, 'entries from store', + name); + savedDatabase.stores[name] = savedStore; + resolve(); + }; + + transaction.onerror = (event) => { + reject(event.error); + event.preventDefault(); + }; + }); + } + + /** + * @param {string} name The name of the database to delete. + * @return {!Promise} Resolved when the named DB has been deleted. + * @private + */ + static deleteDatabase_(name) { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(name); + + request.onsuccess = (event) => { + resolve(); + }; + + request.onerror = (event) => { + event.preventDefault(); + resolve(); + }; + }); + } + + /** + * Creates a database. Does not populate it. + * + * @param {string} name The name of the database to create. + * @param {shaka.test.CannedIDB.SavedDatabase} savedDatabase An object + * containing the database definition and the data to populate it with. + * @return {!Promise.} Resolved when the named DB has been + * created. + * @private + */ + static createDatabase_(name, savedDatabase) { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, savedDatabase.version); + + request.onupgradeneeded = (event) => { + shaka.log.debug('DB upgrade from', event.oldVersion, 'to', + savedDatabase.version); + const transaction = event.target.transaction; + const db = transaction.db; + + // We will ignore existing stores, so first make a map of them. + const existingStoreMap = {}; + for (const storeName of db.objectStoreNames) { + existingStoreMap[storeName] = true; + } + + for (const storeName in savedDatabase.stores) { + if (storeName in existingStoreMap) { + shaka.log.debug('Ignoring existing store', storeName); + } else { + shaka.log.debug('Creating store', storeName); + const storeInfo = savedDatabase.stores[storeName]; + db.createObjectStore(storeName, storeInfo.parameters); + } + } + + // If there isn't an oncomplete on transaction, we seem not to get a + // call to request.onsuccess. Perhaps the browser blocks the + // transaction or request until there is an oncomplete handler. + transaction.oncomplete = (event) => {}; + }; + + request.onsuccess = (event) => { + /** @type {IDBDatabase} */ + const db = event.target.result; + resolve(db); + }; + + request.onerror = (event) => { + event.preventDefault(); + reject(request.error); + }; + }); + } + + /** + * @param {IDBDatabase} db An open database connection. + * @param {shaka.test.CannedIDB.SavedDatabase} savedDatabase An object + * containing the database definition and the data to populate it with. + * @return {!Promise} Resolved when the named DB has been populated with data. + * @private + */ + static populateDatabase_(db, savedDatabase) { + return new Promise((resolve, reject) => { + const transactionType = 'readwrite'; + const storeNames = Object.keys(savedDatabase.stores); + const transaction = db.transaction(storeNames, transactionType); + + for (const storeName in savedDatabase.stores) { + const store = transaction.objectStore(storeName); + const storeInfo = savedDatabase.stores[storeName]; + + shaka.log.debug('Populating store', storeName, 'with', + storeInfo.data.length, 'entries'); + storeInfo.data.forEach((item) => { + // If this store uses an explicit keyPath, we can't specify a key. + if (storeInfo.parameters.keyPath) { + store.add(item.value); + } else { + store.add(item.value, item.key); + } + }); + } + + transaction.oncomplete = (event) => { + resolve(); + }; + + transaction.onerror = (event) => { + reject(event.error); + event.preventDefault(); + }; + }); + } +}; + +/** + * @typedef {{ + * keyPath: ?, + * autoIncrement: boolean, + * }} + */ +shaka.test.CannedIDB.SavedStoreParameters; + +/** + * @typedef {{ + * key: ?, + * value: ?, + * }} + */ +shaka.test.CannedIDB.SavedStoreDataItem; + +/** + * @typedef {{ + * parameters: shaka.test.CannedIDB.SavedStoreParameters, + * data: !Array., + * }} + */ +shaka.test.CannedIDB.SavedStore; + +/** + * @typedef {{ + * version: number, + * stores: !Object., + * }} + */ +shaka.test.CannedIDB.SavedDatabase;