diff --git a/gulp-tasks/serve.js b/gulp-tasks/serve.js index ebf4b7260..7e408f0ac 100644 --- a/gulp-tasks/serve.js +++ b/gulp-tasks/serve.js @@ -16,21 +16,12 @@ /* eslint-disable no-console, valid-jsdoc */ const gulp = require('gulp'); -const path = require('path'); -const express = require('express'); -const serveIndex = require('serve-index'); -const serveStatic = require('serve-static'); +const testServer = require('../utils/test-server.js'); gulp.task('serve', (unusedCallback) => { - const app = express(); - const rootDirectory = global.projectOrStar === '*' ? - 'packages' : - path.join('packages', global.projectOrStar); - - app.use(serveStatic(rootDirectory)); - app.use(serveIndex(rootDirectory, {view: 'details'})); - app.listen(global.port, () => { - console.log(`Serving '${rootDirectory}' at ` + - `http://localhost:${global.port}/`); + return testServer.start('.', global.port) + .then((port) => { + console.log(`Primary Server http://localhost:${port}/`); + console.log(`Secondary Server http://localhost:${port + 1}/`); }); }); diff --git a/lib/idb-helper.js b/lib/idb-helper.js index 9700d79d2..fa2e77bd6 100755 --- a/lib/idb-helper.js +++ b/lib/idb-helper.js @@ -19,123 +19,139 @@ import idb from 'idb'; * A wrapper to store for an IDB connection to a particular ObjectStore. * * @private - * @class */ -function IDBHelper(name, version, storeName) { - if (name == undefined || version == undefined || storeName == undefined) { - throw Error('name, version, storeName must be passed to the constructor.'); +class IDBHelper { + constructor(name, version, storeName) { + if (name == undefined || version == undefined || storeName == undefined) { + throw Error('name, version, storeName must be passed to the ' + + 'constructor.'); + } + + this._name = name; + this._version = version; + this._storeName = storeName; } - this._name = name; - this._version = version; - this._storeName = storeName; -} + /** + * Returns a promise that resolves with an open connection to IndexedDB, + * either existing or newly opened. + * + * @private + * @return {Promise} + */ + _getDb() { + if (this._dbPromise) { + return this._dbPromise; + } -/** - * Returns a promise that resolves with an open connection to IndexedDB, either - * existing or newly opened. - * - * @private - * @return {Promise} - */ -IDBHelper.prototype._getDb = function() { - if (this._db) { - return Promise.resolve(this._db); + this._dbPromise = idb.open(this._name, this._version, (upgradeDB) => { + upgradeDB.createObjectStore(this._storeName); + }) + .then((db) => { + return db; + }); + + return this._dbPromise; } - return idb.open(this._name, this._version, (upgradeDB) => { - upgradeDB.createObjectStore(this._storeName); - }).then((db) => { - this._db = db; - return db; - }); -}; + close() { + if (!this._dbPromise) { + return; + } -/** - * Wrapper on top of the idb wrapper, which simplifies saving the key/value - * pair to the object store. - * Returns a Promise that fulfills when the transaction completes. - * - * @private - * @param {String} key - * @param {Object} value - * @return {Promise} - */ -IDBHelper.prototype.put = function(key, value) { - return this._getDb().then((db) => { - const tx = db.transaction(this._storeName, 'readwrite'); - const objectStore = tx.objectStore(this._storeName); - objectStore.put(value, key); - return tx.complete; - }); -}; + return this._dbPromise + .then((db) => { + db.close(); + this._dbPromise = null; + }); + } -/** - * Wrapper on top of the idb wrapper, which simplifies deleting an entry - * from the object store. - * Returns a Promise that fulfills when the transaction completes. - * - * @private - * @param {String} key - * @return {Promise} - */ -IDBHelper.prototype.delete = function(key) { - return this._getDb().then((db) => { - const tx = db.transaction(this._storeName, 'readwrite'); - const objectStore = tx.objectStore(this._storeName); - objectStore.delete(key); - return tx.complete; - }); -}; + /** + * Wrapper on top of the idb wrapper, which simplifies saving the key/value + * pair to the object store. + * Returns a Promise that fulfills when the transaction completes. + * + * @private + * @param {String} key + * @param {Object} value + * @return {Promise} + */ + put(key, value) { + return this._getDb().then((db) => { + const tx = db.transaction(this._storeName, 'readwrite'); + const objectStore = tx.objectStore(this._storeName); + objectStore.put(value, key); + return tx.complete; + }); + } -/** - * Wrapper on top of the idb wrapper, which simplifies getting a key's value - * from the object store. - * Returns a promise that fulfills with the value. - * - * @private - * @param {String} key - * @return {Promise} - */ -IDBHelper.prototype.get = function(key) { - return this._getDb().then((db) => { - return db.transaction(this._storeName) - .objectStore(this._storeName) - .get(key); - }); -}; + /** + * Wrapper on top of the idb wrapper, which simplifies deleting an entry + * from the object store. + * Returns a Promise that fulfills when the transaction completes. + * + * @private + * @param {String} key + * @return {Promise} + */ + delete(key) { + return this._getDb().then((db) => { + const tx = db.transaction(this._storeName, 'readwrite'); + const objectStore = tx.objectStore(this._storeName); + objectStore.delete(key); + return tx.complete; + }); + } -/** - * Wrapper on top of the idb wrapper, which simplifies getting all the values - * in an object store. - * Returns a promise that fulfills with all the values. - * - * @private - * @return {Promise>} - */ -IDBHelper.prototype.getAllValues = function() { - return this._getDb().then((db) => { - return db.transaction(this._storeName) - .objectStore(this._storeName) - .getAll(); - }); -}; + /** + * Wrapper on top of the idb wrapper, which simplifies getting a key's value + * from the object store. + * Returns a promise that fulfills with the value. + * + * @private + * @param {String} key + * @return {Promise} + */ + get(key) { + return this._getDb().then((db) => { + return db.transaction(this._storeName) + .objectStore(this._storeName) + .get(key); + }); + } -/** - * Wrapper on top of the idb wrapper, which simplifies getting all the keys - * in an object store. - * Returns a promise that fulfills with all the keys. - * - * @private - * @param {String} storeName - * @return {Promise>} - */ -IDBHelper.prototype.getAllKeys = function() { - return this._getDb().then((db) => { - return db.transaction(this._storeName) - .objectStore(this._storeName) - .getAllKeys(); - }); -}; + /** + * Wrapper on top of the idb wrapper, which simplifies getting all the values + * in an object store. + * Returns a promise that fulfills with all the values. + * + * @private + * @return {Promise>} + */ + getAllValues() { + return this._getDb().then((db) => { + return db.transaction(this._storeName) + .objectStore(this._storeName) + .getAll(); + }); + } + + /** + * Wrapper on top of the idb wrapper, which simplifies getting all the keys + * in an object store. + * Returns a promise that fulfills with all the keys. + * + * @private + * @param {String} storeName + * @return {Promise>} + */ + getAllKeys() { + return this._getDb().then((db) => { + return db.transaction(this._storeName) + .objectStore(this._storeName) + .getAllKeys(); + }); + } +} export default IDBHelper; diff --git a/packages/sw-appcache-behavior/test/automated-suite.js b/packages/sw-appcache-behavior/test/automated-suite.js index 85f5eca38..3a16e515f 100644 --- a/packages/sw-appcache-behavior/test/automated-suite.js +++ b/packages/sw-appcache-behavior/test/automated-suite.js @@ -27,8 +27,8 @@ const parseManifest = require('parse-appcache-manifest'); const path = require('path'); const promisify = require('promisify-node'); const seleniumAssistant = require('selenium-assistant'); -const swTestingHelpers = require('sw-testing-helpers'); const fs = require('fs'); +const testServer = require('../../../utils/test-server'); // Ensure the selenium drivers are added Node scripts path. require('geckodriver'); @@ -37,7 +37,6 @@ require('operadriver'); const fsePromise = promisify('fs-extra'); -const testServer = new swTestingHelpers.TestServer(); const expect = chai.expect; const TIMEOUT = 10 * 1000; const RETRIES = 3; @@ -93,7 +92,7 @@ const configureTestSuite = function(browser) { return generateInitialManifests() .then(() => { - return testServer.startServer('.'); + return testServer.start('.'); }) .then((portNumber) => { baseTestUrl = `http://localhost:${portNumber}/packages/sw-appcache-behavior/test/`; @@ -106,7 +105,7 @@ const configureTestSuite = function(browser) { after(function() { return seleniumAssistant.killWebDriver(globalDriverReference) .then(() => { - return testServer.killServer(); + return testServer.stop(); }) .then(() => { return fsePromise.remove(tempDirectory); @@ -285,6 +284,11 @@ seleniumAssistant.getAvailableBrowsers().forEach(function(browser) { case 'chrome': case 'firefox': case 'opera': + if (browser.getSeleniumBrowserId() === 'opera' && + browser.getVersionNumber() <= 43) { + console.log(`Skipping Opera <= 43 due to driver issues.`); + return; + } configureTestSuite(browser); break; default: diff --git a/packages/sw-lib/test/automated-test-suite.js b/packages/sw-lib/test/automated-test-suite.js index 722e0041a..074e057ee 100644 --- a/packages/sw-lib/test/automated-test-suite.js +++ b/packages/sw-lib/test/automated-test-suite.js @@ -15,7 +15,7 @@ const seleniumAssistant = require('selenium-assistant'); const swTestingHelpers = require('sw-testing-helpers'); -const testServer = new swTestingHelpers.TestServer(); +const testServer = require('../../../utils/test-server.js'); require('chromedriver'); require('operadriver'); @@ -34,14 +34,14 @@ const setupTestSuite = (assistantDriver) => { // Set up the web server before running any tests in this suite. before(function() { - return testServer.startServer('.').then((portNumber) => { + return testServer.start('.').then((portNumber) => { baseTestUrl = `http://localhost:${portNumber}/packages/sw-lib`; }); }); // Kill the web server once all tests are complete. after(function() { - return testServer.killServer(); + return testServer.stop(); }); afterEach(function() { diff --git a/packages/sw-lib/test/browser-unit/library-namespace.js b/packages/sw-lib/test/browser-unit/library-namespace.js index 975026a2b..75d65ef9c 100644 --- a/packages/sw-lib/test/browser-unit/library-namespace.js +++ b/packages/sw-lib/test/browser-unit/library-namespace.js @@ -17,6 +17,8 @@ describe('Test Behaviors of Loading the Script', function() { this.timeout(5 * 60 * 1000); it('should print an error when added to the window.', function() { + this.timeout(2000); + return new Promise((resolve, reject) => { window.onerror = (msg, url, lineNo, columnNo, error) => { window.onerror = null; diff --git a/packages/sw-lib/test/utils/dev-server.js b/packages/sw-lib/test/utils/dev-server.js deleted file mode 100644 index e538d1f92..000000000 --- a/packages/sw-lib/test/utils/dev-server.js +++ /dev/null @@ -1,8 +0,0 @@ -const swTestingHelpers = require('sw-testing-helpers'); -const path = require('path'); - -const testServer = new swTestingHelpers.TestServer(); -const serverPath = path.join(__dirname, '..', '..', '..', '..'); -testServer.startServer(serverPath, 8080).then((portNumber) => { - console.log(`http://localhost:${portNumber}/`); -}); diff --git a/packages/sw-offline-google-analytics/test/automated-suite.js b/packages/sw-offline-google-analytics/test/automated-suite.js index 4a8de978c..3af47bd48 100644 --- a/packages/sw-offline-google-analytics/test/automated-suite.js +++ b/packages/sw-offline-google-analytics/test/automated-suite.js @@ -24,7 +24,7 @@ const seleniumAssistant = require('selenium-assistant'); const swTestingHelpers = require('sw-testing-helpers'); -const testServer = new swTestingHelpers.TestServer(); +const testServer = require('../../../utils/test-server'); // Ensure the selenium drivers are added Node scripts path. require('geckodriver'); @@ -34,36 +34,36 @@ require('operadriver'); const TIMEOUT = 10 * 1000; const RETRIES = 3; -const configureTestSuite = function(browser) { +describe(`sw-offline-google-analytics Test Suite`, function() { + this.retries(RETRIES); + this.timeout(TIMEOUT); + let globalDriverReference = null; let baseTestUrl; - describe(`sw-offline-google-analytics Test Suite with (${browser.getPrettyName()} - ${browser.getVersionNumber()})`, function() { - this.retries(RETRIES); - this.timeout(TIMEOUT); - - // Set up the web server before running any tests in this suite. - before(function() { - return testServer.startServer('.').then((portNumber) => { - baseTestUrl = `http://localhost:${portNumber}/packages/sw-offline-google-analytics/test/`; - }); + // Set up the web server before running any tests in this suite. + before(function() { + return testServer.start('.').then((portNumber) => { + baseTestUrl = `http://localhost:${portNumber}/packages/sw-offline-google-analytics/test/`; }); + }); - // Kill the web server once all tests are complete. - after(function() { - return testServer.killServer(); - }); + // Kill the web server once all tests are complete. + after(function() { + return testServer.stop(); + }); - afterEach(function() { - this.timeout(6000); + afterEach(function() { + this.timeout(6000); - return seleniumAssistant.killWebDriver(globalDriverReference) - .then(() => { - globalDriverReference = null; - }); + return seleniumAssistant.killWebDriver(globalDriverReference) + .then(() => { + globalDriverReference = null; }); + }); - it('should pass all tests', function() { + const configureTestSuite = function(browser) { + it(`should pass all tests in (${browser.getPrettyName()} - ${browser.getVersionNumber()})`, function() { return browser.getSeleniumDriver() .then((driver) => { globalDriverReference = driver; @@ -87,24 +87,29 @@ const configureTestSuite = function(browser) { } }); }); + }; + + seleniumAssistant.getAvailableBrowsers().forEach(function(browser) { + // Blackliist browsers here if needed. + if (browser.getSeleniumBrowserId() === 'opera' && browser.getVersionNumber() === 41) { + console.warn('Skipping Opera version 41 due to operadriver error.'); + return; + } + + switch (browser.getSeleniumBrowserId()) { + case 'chrome': + case 'firefox': + case 'opera': + if (browser.getSeleniumBrowserId() === 'opera' && + browser.getVersionNumber() <= 43) { + console.log(`Skipping Opera <= 43 due to driver issues.`); + return; + } + configureTestSuite(browser); + break; + default: + console.warn(`Skipping ${browser.getPrettyName()}.`); + break; + } }); -}; - -seleniumAssistant.getAvailableBrowsers().forEach(function(browser) { - // Blackliist browsers here if needed. - if (browser.getSeleniumBrowserId() === 'opera' && browser.getVersionNumber() === 41) { - console.warn('Skipping Opera version 41 due to operadriver error.'); - return; - } - - switch (browser.getSeleniumBrowserId()) { - case 'chrome': - case 'firefox': - case 'opera': - configureTestSuite(browser); - break; - default: - console.warn(`Skipping ${browser.getPrettyName()}.`); - break; - } }); diff --git a/packages/sw-precaching/build.js b/packages/sw-precaching/build.js index 24449786f..9515a68af 100755 --- a/packages/sw-precaching/build.js +++ b/packages/sw-precaching/build.js @@ -14,6 +14,8 @@ */ const rollupBabel = require('rollup-plugin-babel'); const path = require('path'); +const resolve = require('rollup-plugin-node-resolve'); +const commonjs = require('rollup-plugin-commonjs'); const {buildJSBundle} = require('../../build-utils'); const pkg = require('./package.json'); @@ -29,6 +31,12 @@ module.exports = () => { plugins: ['transform-async-to-generator', 'external-helpers'], exclude: 'node_modules/**', }), + resolve({ + jsnext: true, + main: true, + browser: true, + }), + commonjs(), ], }, outputName: pkg.main, @@ -43,6 +51,12 @@ module.exports = () => { plugins: ['transform-async-to-generator', 'external-helpers'], exclude: 'node_modules/**', }), + resolve({ + jsnext: true, + main: true, + browser: true, + }), + commonjs(), ], }, outputName: pkg['jsnext:main'], diff --git a/packages/sw-precaching/demo/.eslintrc b/packages/sw-precaching/demo/.eslintrc deleted file mode 100644 index 7753f32e7..000000000 --- a/packages/sw-precaching/demo/.eslintrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rules": { - "no-console": 0 - } -} diff --git a/packages/sw-precaching/demo/index.html b/packages/sw-precaching/demo/index.html deleted file mode 100755 index 86aeea022..000000000 --- a/packages/sw-precaching/demo/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - Service Worker Routing Demo - - -

- Test. -

- - - - diff --git a/packages/sw-precaching/demo/service-worker.js b/packages/sw-precaching/demo/service-worker.js deleted file mode 100755 index 8bbce2928..000000000 --- a/packages/sw-precaching/demo/service-worker.js +++ /dev/null @@ -1,25 +0,0 @@ -/* eslint-env worker, serviceworker */ -/* global goog */ - -importScripts('../build/router.js'); - -// Have the service worker take control as soon as possible. -self.addEventListener('install', () => self.skipWaiting()); -self.addEventListener('activate', () => self.clients.claim()); - -const routes = [ - new goog.routing.Route({ - when: ({url}) => url.pathname.endsWith('.js'), - handler: ({event}) => { - console.log('JavaScript!'); - return fetch(event.request); - }, - }), -]; - -const defaultHandler = ({event}) => { - console.log('Default!'); - return fetch(event.request); -}; - -goog.routing.registerRoutes({routes, defaultHandler}); diff --git a/packages/sw-precaching/src/index.js b/packages/sw-precaching/src/index.js index cc74c75f1..c61b574a5 100644 --- a/packages/sw-precaching/src/index.js +++ b/packages/sw-precaching/src/index.js @@ -18,6 +18,17 @@ * * @module sw-precaching */ -export default function DO_NOT_USE() { +import ErrorFactory from './lib/error-factory'; +import RevisionedCacheManager from './lib/revisioned-cache-manager'; + +import assert from '../../../lib/assert.js'; + +if (!assert.isSWEnv()) { + // We are not running in a service worker, print error message + throw ErrorFactory.createError('not-in-sw'); } + +export { + RevisionedCacheManager, +}; diff --git a/packages/sw-precaching/src/lib/constants.js b/packages/sw-precaching/src/lib/constants.js index c850b315c..2e42c6b61 100644 --- a/packages/sw-precaching/src/lib/constants.js +++ b/packages/sw-precaching/src/lib/constants.js @@ -13,6 +13,14 @@ limitations under the License. */ -export const defaultCacheId = `sw-precaching`; -export const hashParamName = '_sw-precaching'; +export const cacheBustParamName = '_sw-precaching'; export const version = 'v1'; +export const dbName = 'sw-precaching'; +export const dbVersion = '1'; +export const dbStorename = 'asset-revisions'; + +let tmpCacheName = `sw-precaching-${version}`; +if (self && self.registration) { + tmpCacheName += `-${self.registration.scope}`; +} +export const defaultCacheName = tmpCacheName; diff --git a/packages/sw-precaching/src/lib/generate-precache-manifest.js b/packages/sw-precaching/src/lib/error-factory.js similarity index 57% rename from packages/sw-precaching/src/lib/generate-precache-manifest.js rename to packages/sw-precaching/src/lib/error-factory.js index e30b1660e..0385964e7 100644 --- a/packages/sw-precaching/src/lib/generate-precache-manifest.js +++ b/packages/sw-precaching/src/lib/error-factory.js @@ -13,9 +13,14 @@ limitations under the License. */ -import assert from '../../../../lib/assert'; +import ErrorFactory from '../../../../lib/error-factory'; -export default (configuration) => { - const {filePatterns} = configuration; - assert.isType(filePatterns, 'array'); +const errors = { + 'not-in-sw': 'sw-precaching must be loaded in your service worker file.', + 'invalid-file-manifest-entry': `File manifest entries must be either a ` + + `string with revision info in the path or an object with path and ` + + `revision and parameters.`, + 'bad-cache-bust': `The cache bust parameter must be a boolean.`, }; + +export default new ErrorFactory(errors); diff --git a/packages/sw-precaching/src/lib/install-handler.js b/packages/sw-precaching/src/lib/install-handler.js deleted file mode 100644 index c95c2c19e..000000000 --- a/packages/sw-precaching/src/lib/install-handler.js +++ /dev/null @@ -1,61 +0,0 @@ -/* - Copyright 2016 Google Inc. 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 '../../../../lib/assert'; -import {version, defaultCacheId} from './constants'; - -const setOfCachedUrls = (cache) => { - return cache.keys() - .then((requests) => requests.map((request) => request.url)) - .then((urls) => new Set(urls)); -}; - -const urlsToCacheKeys = (precacheConfig) => new Map( - /** urls.map(item => { - var relativeUrl = item[0]; - var hash = item[1]; - var absoluteUrl = new URL(relativeUrl, self.location); - var cacheKey = createCacheKey(absoluteUrl, hashParamName, hash, /a/); - return [absoluteUrl.toString(), cacheKey]; - })**/ -); - -export default ({assetsAndHahes, cacheId} = {}) => { - assert.isType(assetsAndHahes, 'array'); - - self.addEventListener('install', (event) => { - const cacheName = - `${cacheId || defaultCacheId}-${version}-${self.registration.scope}`; - - event.waitUntil( - caches.open(cacheName).then((cache) => { - return setOfCachedUrls(cache).then((cachedUrls) => { - return Promise.all( - Array.from(urlsToCacheKeys.values()).map(function(cacheKey) { - // If we don't have a key matching url in the cache already, - // add it. - if (!cachedUrls.has(cacheKey)) { - return cache.add( - new Request(cacheKey, {credentials: 'same-origin'})); - } - - return Promise.resolve(); - }) - ); - }); - }) - ); - }); -}; diff --git a/packages/sw-precaching/src/lib/revisioned-cache-manager.js b/packages/sw-precaching/src/lib/revisioned-cache-manager.js new file mode 100644 index 000000000..3510ffd71 --- /dev/null +++ b/packages/sw-precaching/src/lib/revisioned-cache-manager.js @@ -0,0 +1,266 @@ +/* + Copyright 2016 Google Inc. 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 '../../../../lib/assert'; +import ErrorFactory from './error-factory'; +import IDBHelper from '../../../../lib/idb-helper.js'; +import {defaultCacheName, dbName, dbVersion, dbStorename, cacheBustParamName} + from './constants'; + +/** + * RevisionedCacheManager manages efficient caching of a file manifest, + * only downloading assets that have a new file revision. This module will + * also manage Cache Busting for URL's by adding a search a parameter on to + * the end of requests for assets before caching them. + */ +class RevisionedCacheManager { + /** + * Constructing this object will register the require events for this + * module to work (i.e. install and activate events). + */ + constructor() { + this._eventsRegistered = false; + this._fileEntriesToCache = []; + this._idbHelper = new IDBHelper(dbName, dbVersion, dbStorename); + + this._registerEvents(); + } + + /** + * The method expects an array of file entries in the revisionedFiles + * parameter. Each fileEntry should be either a string or an object. + * + * A string file entry should be paths / URL's that are revisions in the + * name (i.e. '/example/hello.1234.txt'). + * + * A file entry can also be an object that *must* have a 'path' and + * 'revision' parameter. The revision cannot be an empty string. With These + * entries you can prevent cacheBusting by setting a 'cacheBust' parameter + * to false. (i.e. {path: '/exmaple/hello.txt', revision: '1234'} or + * {path: '/exmaple/hello.txt', revision: '1234', cacheBust: false}) + * + * @param {object} options + * @param {array} options.revisionedFiles This should a list of + * file entries. + */ + cache({revisionedFiles} = {}) { + assert.isInstance({revisionedFiles}, Array); + + const parsedFileList = revisionedFiles.map((revisionedFileEntry) => { + return this._validateFileEntry(revisionedFileEntry); + }); + + this._fileEntriesToCache = this._fileEntriesToCache.concat(parsedFileList); + } + + /** + * This method ensures that the file entry in the file maniest is valid and + * if the entry is a revisioned string path, it is converted to an object + * with the desired fields. + * @param {String | object} fileEntry Either a path for a file or an object + * with a `path`, `revision` and optional `cacheBust` parameter. + * @return {object} Returns a parsed version of the file entry with absolute + * URL, revision and a cacheBust value. + */ + _validateFileEntry(fileEntry) { + let parsedFileEntry = fileEntry; + if (typeof parsedFileEntry === 'string') { + parsedFileEntry = { + path: fileEntry, + revision: fileEntry, + cacheBust: false, + }; + } + + if (!parsedFileEntry || typeof parsedFileEntry !== 'object') { + throw ErrorFactory.createError('invalid-file-manifest-entry', + new Error('Invalid file entry: ' + + JSON.stringify(fileEntry))); + } + + if (typeof parsedFileEntry.path !== 'string') { + throw ErrorFactory.createError('invalid-file-manifest-entry', + new Error('Invalid path: ' + JSON.stringify(fileEntry))); + } + + try { + parsedFileEntry.path = + new URL(parsedFileEntry.path, location.origin).toString(); + } catch (err) { + throw ErrorFactory.createError('invalid-file-manifest-entry', + new Error('Unable to parse path as URL: ' + + JSON.stringify(fileEntry))); + } + + if (typeof parsedFileEntry.revision !== 'string' || + parsedFileEntry.revision.length == 0) { + throw ErrorFactory.createError('invalid-file-manifest-entry', + new Error('Invalid revision: ' + + JSON.stringify(fileEntry))); + } + + // Add cache bust if its not defined + if (typeof parsedFileEntry.cacheBust === 'undefined') { + parsedFileEntry.cacheBust = true; + } else if (typeof parsedFileEntry.cacheBust !== 'boolean') { + throw ErrorFactory.createError('invalid-file-manifest-entry', + new Error('Invalid cacheBust: ' + + JSON.stringify(fileEntry))); + } + + return parsedFileEntry; + } + + /** + * This method registers the service worker events. This should only + * be called once in the constructor and will do nothing if called + * multiple times. + */ + _registerEvents() { + if (this._eventsRegistered) { + // Only need to register events once. + return; + } + + this._eventsRegistered = true; + + self.addEventListener('install', (event) => { + if (this._fileEntriesToCache.length === 0) { + return; + } + + event.waitUntil( + this._performInstallStep() + ); + }); + } + + /** + * This method manages the actual install event to cache the revisioned + * assets. + * @param {String} cacheName The name to use for the cache + * @return {Promise} The promise resolves when all the desired assets are + * cached. + */ + async _performInstallStep(cacheName) { + cacheName = cacheName || defaultCacheName; + + let openCache = await caches.open(cacheName); + const cachePromises = this._fileEntriesToCache.map(async (fileEntry) => { + const isCached = await this._isAlreadyCached(fileEntry, openCache); + if (isCached) { + return; + } + + let requestUrl = this._cacheBustUrl(fileEntry); + const response = await fetch(requestUrl, { + credentials: 'same-origin', + }); + await openCache.put(fileEntry.path, response); + await this._putRevisionDetails(fileEntry.path, fileEntry.revision); + }); + + await Promise.all(cachePromises); + + const urlsCachedOnInstall = this._fileEntriesToCache + .map((fileEntry) => fileEntry.path); + const allCachedRequests = await openCache.keys(); + + const cacheDeletePromises = allCachedRequests.map((cachedRequest) => { + if (urlsCachedOnInstall.includes(cachedRequest.url)) { + return; + } + + return openCache.delete(cachedRequest); + }); + + await Promise.all(cacheDeletePromises); + + // Closed indexedDB now that we are done with the install step + this._close(); + } + + /** + * This method confirms with a fileEntry is already in the cache with the + * appropriate revision. + * If the revision is known, matching the requested `fileEntry.revision` and + * the cache entry exists for the `fileEntry.path` this method returns true. + * False otherwise. + * @param {Object} fileEntry A file entry with `path` and `revision` + * parameters. + * @param {Cache} openCache The cache to look for the asset in. + * @return {Promise} Returns true is the fileEntry is already + * cached, false otherwise. + */ + async _isAlreadyCached(fileEntry, openCache) { + const revisionDetails = await this._getRevisionDetails(fileEntry.path); + if (revisionDetails !== fileEntry.revision) { + return false; + } + + const cachedResponse = await openCache.match(fileEntry.path); + return cachedResponse ? true : false; + } + + /** + * This method gets the revision details for a given path. + * @param {String} path The path of an asset to look up. + * @return {Promise} Returns a string for the last revision or + * returns null if there is no revision information. + */ + _getRevisionDetails(path) { + return this._idbHelper.get(path); + } + + /** + * This method saves the revision details to indexedDB. + * @param {String} path The path for the asset. + * @param {String} revision The current revision for this asset path. + * @return {Promise} Promise that resolves once the data has been saved. + */ + _putRevisionDetails(path, revision) { + return this._idbHelper.put(path, revision); + } + + /** + * This method takes a file entry and if the `cacheBust` parameter is set to + * true, the cacheBust parameter will be added to the URL before making the + * request. The response will be cached with the absolute URL without + * the cache busting search param. + * @param {Object} fileEntry This is an object with `path`, `revision` and + * `cacheBust` parameters. + * @return {String} The final URL to make the request to then cache. + */ + _cacheBustUrl(fileEntry) { + if (fileEntry.cacheBust === false) { + return fileEntry.path; + } + + const parsedURL = new URL(fileEntry.path); + parsedURL.search += (parsedURL.search ? '&' : '') + + encodeURIComponent(cacheBustParamName) + '=' + + encodeURIComponent(fileEntry.revision); + return parsedURL.toString(); + } + + /** + * This method closes the indexdDB helper. + */ + _close() { + this._idbHelper.close(); + } +} + +export default RevisionedCacheManager; diff --git a/packages/sw-precaching/test/.eslintrc b/packages/sw-precaching/test/.eslintrc new file mode 100644 index 000000000..0c1ea1fec --- /dev/null +++ b/packages/sw-precaching/test/.eslintrc @@ -0,0 +1,10 @@ +{ + "rules": { + "no-console": 0, + "max-len": 0, + "no-invalid-this": 0, + }, + "env": { + "mocha": true + } +} diff --git a/packages/sw-precaching/test/automated-test-suite.js b/packages/sw-precaching/test/automated-test-suite.js new file mode 100644 index 000000000..71aedb5fd --- /dev/null +++ b/packages/sw-precaching/test/automated-test-suite.js @@ -0,0 +1,98 @@ +/* + Copyright 2016 Google Inc. 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. +*/ + +const seleniumAssistant = require('selenium-assistant'); +const swTestingHelpers = require('sw-testing-helpers'); +const testServer = require('../../../utils/test-server.js'); + +require('chromedriver'); +require('operadriver'); +require('geckodriver'); + +const RETRIES = 4; +const TIMEOUT = 10 * 1000; + +describe(`sw-precaching Browser Tests`, function() { + this.retries(RETRIES); + this.timeout(TIMEOUT); + + let globalDriverBrowser; + let baseTestUrl; + + // Set up the web server before running any tests in this suite. + before(function() { + return testServer.start('.') + .then((portNumber) => { + baseTestUrl = `http://localhost:${portNumber}/packages/sw-precaching`; + }); + }); + + // Kill the web server once all tests are complete. + after(function() { + return testServer.stop(); + }); + + afterEach(function() { + return seleniumAssistant.killWebDriver(globalDriverBrowser) + .then(() => { + globalDriverBrowser = null; + }); + }); + + const setupTestSuite = (assistantDriver) => { + it(`should pass all browser based unit tests in ${assistantDriver.getPrettyName()}`, function() { + return assistantDriver.getSeleniumDriver() + .then((driver) => { + globalDriverBrowser = driver; + }) + .then(() => { + return swTestingHelpers.mochaUtils.startWebDriverMochaTests( + assistantDriver.getPrettyName(), + globalDriverBrowser, + `${baseTestUrl}/test/browser-unit/` + ); + }) + .then((testResults) => { + if (testResults.failed.length > 0) { + const errorMessage = swTestingHelpers.mochaUtils.prettyPrintErrors( + assistantDriver.getPrettyName(), + testResults + ); + + throw new Error(errorMessage); + } + }); + }); + }; + + const availableBrowsers = seleniumAssistant.getAvailableBrowsers(); + availableBrowsers.forEach((browser) => { + switch(browser.getSeleniumBrowserId()) { + case 'chrome': + case 'firefox': + case 'opera': + if (browser.getSeleniumBrowserId() === 'opera' && + browser.getVersionNumber() <= 43) { + console.log(`Skipping Opera <= 43 due to driver issues.`); + return; + } + setupTestSuite(browser); + break; + default: + console.log(`Skipping tests for ${browser.getSeleniumBrowserId()}`); + break; + } + }); +}); diff --git a/packages/sw-precaching/test/browser-unit/cache-testing.js b/packages/sw-precaching/test/browser-unit/cache-testing.js new file mode 100644 index 000000000..1fa024b5f --- /dev/null +++ b/packages/sw-precaching/test/browser-unit/cache-testing.js @@ -0,0 +1,174 @@ +/* global goog, expect */ + +describe('sw-precaching Test Revisioned Caching', function() { + const deleteIndexedDB = () => { + return new Promise((resolve, reject) => { + // TODO: Move to constants + const req = indexedDB.deleteDatabase('sw-precaching'); + req.onsuccess = function() { + resolve(); + }; + req.onerror = function() { + reject(); + }; + req.onblocked = function() { + console.error('Database deletion is blocked.'); + }; + }); + }; + + beforeEach(function() { + return window.goog.swUtils.cleanState() + .then(deleteIndexedDB); + }); + + afterEach(function() { + /** return window.goog.swUtils.cleanState() + .then(deleteIndexedDB);**/ + }); + + const testFileSet = (iframe, fileSet) => { + return window.caches.keys() + .then((cacheNames) => { + cacheNames.length.should.equal(1); + return window.caches.open(cacheNames[0]); + }) + .then((cache) => { + return cache.keys(); + }) + .then((cachedResponses) => { + cachedResponses.length.should.equal(fileSet.length); + + fileSet.forEach((assetAndHash) => { + let matchingResponse = null; + cachedResponses.forEach((cachedResponse) => { + let desiredPath = assetAndHash; + if (typeof assetAndHash !== 'string') { + desiredPath = assetAndHash.path; + } + + if (cachedResponse.url.indexOf(desiredPath) !== -1) { + matchingResponse = cachedResponse; + return; + } + }); + + expect(matchingResponse).to.exist; + }); + }) + .then(() => { + const promises = fileSet.map((assetAndHash) => { + let url = assetAndHash; + if (typeof assetAndHash === 'object') { + url = assetAndHash.path; + } + + return iframe.contentWindow.fetch(url); + }); + return Promise.all(promises); + }) + .then((cachedResponses) => { + let responses = {}; + const promises = cachedResponses.map((cachedResponse) => { + return cachedResponse.text() + .then((bodyText) => { + responses[cachedResponse.url] = bodyText; + }); + }); + return Promise.all(promises) + .then(() => { + return responses; + }); + }); + }; + + const compareCachedAssets = function(beforeData, afterData) { + afterData.cacheList.forEach((afterAssetAndHash) => { + if (typeof assetAndHash === 'string') { + afterAssetAndHash = {path: afterAssetAndHash, revision: afterAssetAndHash}; + } + + let matchingBeforeAssetAndHash = null; + beforeData.cacheList.forEach((beforeAssetAndHash) => { + if (typeof beforeAssetAndHash === 'string') { + beforeAssetAndHash = {path: beforeAssetAndHash, revision: beforeAssetAndHash}; + } + + if (beforeAssetAndHash.path === afterAssetAndHash.path) { + matchingBeforeAssetAndHash = beforeAssetAndHash; + } + }); + + if (!matchingBeforeAssetAndHash) { + return; + } + + let pathToCheck = new URL(afterAssetAndHash.path, location.origin).toString(); + + const beforeResponseBody = beforeData.cachedResponses[pathToCheck]; + const afterResponseBody = afterData.cachedResponses[pathToCheck]; + + if (matchingBeforeAssetAndHash.revision === afterAssetAndHash.revision) { + // The request should be the same + beforeResponseBody.should.equal(afterResponseBody); + } else { + // The request should be different + beforeResponseBody.should.not.equal(afterResponseBody); + } + }); + }; + + it('should cache and fetch files', function() { + return window.goog.swUtils.activateSW('data/basic-cache/basic-cache-sw.js?' + location.search) + .then((iframe) => { + return testFileSet(iframe, goog.__TEST_DATA['set-1']['step-1']); + }) + .then((step1Responses) => { + return window.goog.swUtils.activateSW('data/basic-cache/basic-cache-sw-2.js' + location.search) + .then((iframe) => { + return testFileSet(iframe, goog.__TEST_DATA['set-1']['step-2']); + }) + .then((step2Responses) => { + compareCachedAssets({ + cacheList: goog.__TEST_DATA['set-1']['step-1'], + cachedResponses: step1Responses, + }, { + cacheList: goog.__TEST_DATA['set-1']['step-2'], + cachedResponses: step2Responses, + }); + }); + }); + }); + + it('should manage cache deletion', function() { + return window.goog.swUtils.activateSW('data/basic-cache/basic-cache-sw.js' + location.search) + .then((iframe) => { + return testFileSet(iframe, goog.__TEST_DATA['set-1']['step-1']); + }) + .then((step1Responses) => { + return window.goog.swUtils.clearAllCaches() + .then(() => { + return window.goog.swUtils.activateSW('data/basic-cache/basic-cache-sw-2.js' + location.search); + }) + .then((iframe) => { + return testFileSet(iframe, goog.__TEST_DATA['set-1']['step-2']); + }); + }); + }); + + it('should manage indexedDB deletion', function() { + return window.goog.swUtils.activateSW('data/basic-cache/basic-cache-sw.js' + location.search) + .then((iframe) => { + return testFileSet(iframe, goog.__TEST_DATA['set-1']['step-1']); + }) + .then((step1Responses) => { + return deleteIndexedDB() + .then(() => { + return window.goog.swUtils.activateSW('data/basic-cache/basic-cache-sw-2.js' + location.search); + }) + .then((iframe) => { + return testFileSet(iframe, goog.__TEST_DATA['set-1']['step-2']); + }); + }); + }); +}); diff --git a/packages/sw-precaching/test/browser-unit/data/basic-cache/basic-cache-sw-2.js b/packages/sw-precaching/test/browser-unit/data/basic-cache/basic-cache-sw-2.js new file mode 100644 index 000000000..73e252caf --- /dev/null +++ b/packages/sw-precaching/test/browser-unit/data/basic-cache/basic-cache-sw-2.js @@ -0,0 +1,13 @@ +/* global goog */ +importScripts('/packages/sw-precaching/test/browser-unit/data/test-data.js'); +importScripts('/packages/sw-precaching/build/sw-precaching.min.js'); +importScripts('/packages/sw-precaching/test/browser-unit/data/skip-and-claim.js'); + +const revisionedCacheManager = new goog.precaching.RevisionedCacheManager(); +revisionedCacheManager.cache({ + revisionedFiles: goog.__TEST_DATA['set-1']['step-2'], +}); + +self.addEventListener('fetch', (event) => { + event.respondWith(caches.match(event.request)); +}); diff --git a/packages/sw-precaching/test/browser-unit/data/basic-cache/basic-cache-sw.js b/packages/sw-precaching/test/browser-unit/data/basic-cache/basic-cache-sw.js new file mode 100644 index 000000000..bbcfc23c7 --- /dev/null +++ b/packages/sw-precaching/test/browser-unit/data/basic-cache/basic-cache-sw.js @@ -0,0 +1,13 @@ +/* global goog */ +importScripts('/packages/sw-precaching/test/browser-unit/data/test-data.js'); +importScripts('/packages/sw-precaching/build/sw-precaching.min.js'); +importScripts('/packages/sw-precaching/test/browser-unit/data/skip-and-claim.js'); + +const revisionedCacheManager = new goog.precaching.RevisionedCacheManager(); +revisionedCacheManager.cache({ + revisionedFiles: goog.__TEST_DATA['set-1']['step-1'], +}); + +self.addEventListener('fetch', (event) => { + event.respondWith(caches.match(event.request)); +}); diff --git a/packages/sw-precaching/test/browser-unit/data/skip-and-claim.js b/packages/sw-precaching/test/browser-unit/data/skip-and-claim.js new file mode 100644 index 000000000..40274ede7 --- /dev/null +++ b/packages/sw-precaching/test/browser-unit/data/skip-and-claim.js @@ -0,0 +1,7 @@ +self.addEventListener('install', function(event) { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener('activate', function(event) { + event.waitUntil(self.clients.claim()); +}); diff --git a/packages/sw-precaching/test/browser-unit/data/test-data.js b/packages/sw-precaching/test/browser-unit/data/test-data.js new file mode 100644 index 000000000..2704ef31d --- /dev/null +++ b/packages/sw-precaching/test/browser-unit/data/test-data.js @@ -0,0 +1,53 @@ +let secondaryServer = `${location.protocol}//${location.hostname}:${parseInt(location.port) + 1}`; + +const EXAMPLE_REVISIONED_FILES_SET_1_STEP_1 = []; +const EXAMPLE_REVISIONED_FILES_SET_1_STEP_2 = []; + +const revision1 = ['1234', '1234']; +const revision2 = ['5678', '1234']; + +let fileIndex = 0; + +const addNewEntry = (origin) => { + let echoPath = '/__echo/date'; + if (!origin) { + origin = ''; + } + + if (origin === secondaryServer) { + echoPath = '/__echo/date-with-cors'; + } + + for (let i = 0; i < revision1.length; i++) { + EXAMPLE_REVISIONED_FILES_SET_1_STEP_1.push(`${origin}${echoPath}/${fileIndex}.${revision1[i]}.txt`); + EXAMPLE_REVISIONED_FILES_SET_1_STEP_2.push(`${origin}${echoPath}/${fileIndex}.${revision2[i]}.txt`); + fileIndex++; + + EXAMPLE_REVISIONED_FILES_SET_1_STEP_1.push({ + path: `${origin}${echoPath}/${fileIndex}.txt`, + revision: revision1[i], + }); + EXAMPLE_REVISIONED_FILES_SET_1_STEP_2.push({ + path: `${origin}${echoPath}/${fileIndex}.txt`, + revision: revision2[i], + }); + fileIndex++; + } +}; + +// Add entries with relative path +addNewEntry(); + +// Add entries with absolute path for this origin +addNewEntry(location.origin); + +// Add entries with absolute path for a foreign origin +addNewEntry(secondaryServer); + +self.goog = self.goog || {}; +self.goog.__TEST_DATA = { + 'set-1': { + 'step-1': EXAMPLE_REVISIONED_FILES_SET_1_STEP_1, + 'step-2': EXAMPLE_REVISIONED_FILES_SET_1_STEP_2, + }, +}; diff --git a/packages/sw-precaching/test/browser-unit/index.html b/packages/sw-precaching/test/browser-unit/index.html new file mode 100644 index 000000000..73c7aacf4 --- /dev/null +++ b/packages/sw-precaching/test/browser-unit/index.html @@ -0,0 +1,79 @@ + + + + + SW Helpers Tests + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + diff --git a/packages/sw-precaching/test/browser-unit/library-namespace.js b/packages/sw-precaching/test/browser-unit/library-namespace.js new file mode 100644 index 000000000..eb99fb771 --- /dev/null +++ b/packages/sw-precaching/test/browser-unit/library-namespace.js @@ -0,0 +1,58 @@ +/* + Copyright 2016 Google Inc. 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. +*/ +describe('Test Behaviors of Loading the Script', function() { + this.timeout(60 * 1000); + + it('should print an error when added to the window.', function() { + this.timeout(2000); + + return new Promise((resolve, reject) => { + window.onerror = (msg, url, lineNo, columnNo, error) => { + window.onerror = null; + + if (error.name === 'not-in-sw') { + resolve(); + return true; + } else { + reject('Unexpected error received.'); + return false; + } + }; + + const scriptElement = document.createElement('script'); + scriptElement.src = '/packages/sw-precaching/build/sw-precaching.min.js'; + scriptElement.addEventListener('error', (event) => { + reject(); + }); + document.head.appendChild(scriptElement); + }); + }); + + const swUnitTests = [ + 'sw-unit/basic.js', + 'sw-unit/revisioned-cache-manager.js', + ]; + swUnitTests.forEach((swUnitTestPath) => { + it(`should perform ${swUnitTestPath} sw tests`, function() { + return window.goog.mochaUtils.startServiceWorkerMochaTests(swUnitTestPath) + .then((testResults) => { + if (testResults.failed.length > 0) { + const errorMessage = window.goog.mochaUtils.prettyPrintErrors(swUnitTestPath, testResults); + throw new Error(errorMessage); + } + }); + }); + }); +}); diff --git a/packages/sw-precaching/test/browser-unit/sw-unit/basic.js b/packages/sw-precaching/test/browser-unit/sw-unit/basic.js new file mode 100644 index 000000000..5ccc3e128 --- /dev/null +++ b/packages/sw-precaching/test/browser-unit/sw-unit/basic.js @@ -0,0 +1,25 @@ +importScripts('/node_modules/mocha/mocha.js'); +importScripts('/node_modules/chai/chai.js'); +importScripts('/node_modules/sw-testing-helpers/browser/mocha-utils.js'); + +importScripts('/packages/sw-precaching/build/sw-precaching.min.js'); + +/* global goog */ + +const expect = self.chai.expect; +self.chai.should(); +mocha.setup({ + ui: 'bdd', + reporter: null, +}); + +describe('Test Library Surface', function() { + it('should be accessible via goog.precaching', function() { + expect(goog.precaching).to.exist; + }); + + // TODO Test options + it('should have RevisionedCacheManager via goog.precaching', function() { + expect(goog.precaching.RevisionedCacheManager).to.exist; + }); +}); diff --git a/packages/sw-precaching/test/browser-unit/sw-unit/revisioned-cache-manager.js b/packages/sw-precaching/test/browser-unit/sw-unit/revisioned-cache-manager.js new file mode 100644 index 000000000..45e72952c --- /dev/null +++ b/packages/sw-precaching/test/browser-unit/sw-unit/revisioned-cache-manager.js @@ -0,0 +1,165 @@ +importScripts('/node_modules/mocha/mocha.js'); +importScripts('/node_modules/chai/chai.js'); +importScripts('/node_modules/sw-testing-helpers/browser/mocha-utils.js'); + +importScripts('/packages/sw-precaching/build/sw-precaching.min.js'); + +/* global goog */ + +const expect = self.chai.expect; +self.chai.should(); + +mocha.setup({ + ui: 'bdd', + reporter: null, +}); + +describe('Test RevisionedCacheManager', function() { + let revisionedCacheManager; + + before(function() { + revisionedCacheManager = new goog.precaching.RevisionedCacheManager(); + }); + + after(function() { + revisionedCacheManager._close(); + }); + + const badRevisionFileInputs = [ + undefined, + '', + '/example.js', + {}, + {path: 'hello'}, + {revision: '0987'}, + true, + false, + 123, + ]; + badRevisionFileInputs.forEach((badInput) => { + it(`should handle bad cache({revisionedFiles='${badInput}'}) input`, function() { + expect(() => { + revisionedCacheManager.cache({ + revisionedFiles: badInput, + }); + }).to.throw('instance of \'Array\''); + }); + + it(`should handle bad cache('${badInput}') input`, function() { + expect(() => { + revisionedCacheManager.cache(badInput); + }).to.throw('instance of \'Array\''); + }); + }); + + it(`should handle bad cache('[]') input`, function() { + expect(() => { + revisionedCacheManager.cache([]); + }).to.throw('instance of \'Array\''); + }); + + it(`should handle null / undefined inputs`, function() { + expect(() => { + revisionedCacheManager.cache({revisionedFiles: null}); + }).to.throw('instance of \'Array\''); + + expect(() => { + revisionedCacheManager.cache(null); + }).to.throw('null'); + }); + + const VALID_PATH_REL = '/__echo/date/example.txt'; + const VALID_PATH_ABS = `${location.origin}${VALID_PATH_REL}`; + const VALID_REVISION = '1234'; + + const badPaths = [ + null, + undefined, + false, + true, + 12345, + {}, + [], + ]; + + const badRevisions = [ + null, + undefined, + false, + true, + '', + 12345, + {}, + [], + ]; + + const badFileManifests = []; + badPaths.forEach((badPath) => { + badFileManifests.push([badPath]); + badFileManifests.push([{path: badPath, revision: VALID_REVISION}]); + }); + badRevisions.forEach((badRevision) => { + badFileManifests.push([{path: VALID_PATH_REL, revision: badRevision}]); + }); + + badFileManifests.forEach((badFileManifest) => { + it(`should throw an errror for a page file manifest entry '${JSON.stringify(badFileManifest)}'`, function() { + let caughtError; + try { + revisionedCacheManager.cache({revisionedFiles: badFileManifest}); + } catch (err) { + caughtError = err; + } + + if (!caughtError) { + throw new Error('Expected file manifest to cause an error.'); + } + + caughtError.name.should.equal('invalid-file-manifest-entry'); + }); + }); + + const badCacheBusts = [ + null, + '', + '1234sgdgh', + 12345, + {}, + [], + ]; + + badCacheBusts.forEach((badCacheBust) => { + it(`should be able to handle bad cache input '${JSON.stringify(badCacheBust)}'`, function() { + let caughtError; + try { + revisionedCacheManager.cache({revisionedFiles: [ + {path: VALID_PATH_REL, revision: VALID_REVISION, cacheBust: badCacheBust}, + ]}); + } catch (err) { + caughtError = err; + } + + if (!caughtError) { + throw new Error('Expected file manifest to cause an error.'); + } + + caughtError.name.should.equal('invalid-file-manifest-entry'); + }); + }); + + const goodManifestInputs = [ + VALID_PATH_REL, + {path: VALID_PATH_REL, revision: VALID_REVISION}, + {path: VALID_PATH_REL, revision: VALID_REVISION, cacheBust: true}, + {path: VALID_PATH_REL, revision: VALID_REVISION, cacheBust: false}, + VALID_PATH_ABS, + {path: VALID_PATH_ABS, revision: VALID_REVISION}, + {path: VALID_PATH_ABS, revision: VALID_REVISION, cacheBust: true}, + {path: VALID_PATH_ABS, revision: VALID_REVISION, cacheBust: false}, + ]; + goodManifestInputs.forEach((goodInput) => { + it(`should be able to handle good cache input '${JSON.stringify(goodInput)}'`, function() { + revisionedCacheManager.cache({revisionedFiles: [goodInput]}); + }); + }); +}); diff --git a/utils/server-instance.js b/utils/server-instance.js new file mode 100644 index 000000000..e6167d65a --- /dev/null +++ b/utils/server-instance.js @@ -0,0 +1,78 @@ +/* + Copyright 2016 Google Inc. 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. +*/ + +const path = require('path'); +const express = require('express'); +const serveIndex = require('serve-index'); +const serveStatic = require('serve-static'); + +class ServerInstance { + constructor() { + this._server = null; + + this._app = express(); + + // Test iframe is used by sw-testing-helpers to scope service workers + this._app.get('/test/iframe/:random', function(req, res) { + res.sendFile(path.join(__dirname, 'test-iframe.html')); + }); + + this._app.get('/__echo/filename/:file', function(req, res) { + res.setHeader('Cache-Control', 'max-age=' + (24 * 60 * 60)); + res.send(req.params.file); + }); + + this._app.get('/__echo/date/:file', function(req, res) { + res.setHeader('Cache-Control', 'max-age=' + (24 * 60 * 60)); + res.send(`${req.params.file}-${Date.now()}`); + }); + + this._app.get('/__echo/date-with-cors/:file', function(req, res) { + res.setHeader('Cache-Control', 'max-age=' + (24 * 60 * 60)); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.send(`${req.params.file}-${Date.now()}`); + }); + } + + start(rootDirectory, port) { + if (this._server) { + return Promise.reject(new Error('Server already started.')); + } + + this._app.use('/', express.static(rootDirectory, { + setHeaders: (res) => { + res.setHeader('Service-Worker-Allowed', '/'); + }, + })); + + this._app.use(serveStatic(rootDirectory)); + this._app.use(serveIndex(rootDirectory, {view: 'details'})); + + return new Promise((resolve, reject) => { + this._server = this._app.listen(port, 'localhost', () => { + resolve(this._server.address().port); + }); + }); + } + + stop() { + return new Promise((resolve) => { + this._server.close(resolve); + this._server = null; + }); + } +} + +module.exports = ServerInstance; diff --git a/utils/test-iframe.html b/utils/test-iframe.html new file mode 100644 index 000000000..47cac2610 --- /dev/null +++ b/utils/test-iframe.html @@ -0,0 +1,22 @@ + + + + + Test Iframe + + +

Test Iframe

+

I'm here to do nothing - promise :)

+ + diff --git a/utils/test-server.js b/utils/test-server.js new file mode 100644 index 000000000..13e926f4b --- /dev/null +++ b/utils/test-server.js @@ -0,0 +1,73 @@ +/* + Copyright 2016 Google Inc. 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. +*/ + +/** + * README: + * This test server is used in unit tests as well as + * by the 'gulp serve' task, so please make sure + * changes here work in all scenarios. + * + * There are two servers so tests can make requests to a foreign origin. + */ + +const ServerInstance = require('./server-instance'); + +let primaryServer; +let secondaryServer; + +module.exports = { + /** + * Calling start will create two servers. The primary port will be returned + * and the secondary server will be on the primary port + 1; + */ + start: (rootDirectory, port) => { + if (primaryServer || secondaryServer) { + return Promise.reject('Server already running'); + } + + primaryServer = new ServerInstance(); + secondaryServer = new ServerInstance(); + + return primaryServer.start(rootDirectory, port) + .then((primaryPort) => { + let secondaryPort = primaryPort + 1; + return secondaryServer.start(rootDirectory, secondaryPort) + .then((actualSecondaryPort) => { + if (actualSecondaryPort !== secondaryPort) { + return Promise.all([ + primaryServer.stop(), + secondaryServer.stop(), + ]) + .then(() => { + Promise.reject(new Error('Server could not get the required ' + + 'ports or primaryPort and primaryPort + 1.')); + }); + } else { + return primaryPort; + } + }); + }); + }, + stop: () => { + return Promise.all([ + primaryServer.stop(), + secondaryServer.stop(), + ]) + .then(() => { + primaryServer = null; + secondaryServer = null; + }); + }, +};