import { describe, it, beforeEach, beforeAll, afterEach } from "https://deno.land/std@0.183.0/testing/bdd.ts"; import { assert, assertThrows, assertRejects, assertEquals } from "https://deno.land/std@0.183.0/testing/asserts.ts"; import { assertSpyCall, assertSpyCalls, spy, } from "https://deno.land/std@0.183.0/testing/mock.ts"; /* * mocking the FetchEvent class * https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent */ class FetchEvent extends Event { request = null response = null constructor(request, init=null) { super('fetch') if (typeof request == "string") { if (request.indexOf('http') != 0) { request = window.location.origin + request } if (init == null) { request = new Request(request) } else { request = new Request(request, init) } } this.request = request } clientId = 'libresilient-tests' respondWith(a) { this.response = a } waitForResponse() { return new Promise(async (resolve, reject)=>{ while (this.response === null) { await new Promise(resolve => setTimeout(resolve, 1)) } resolve(this.response) }) } } beforeAll(async ()=>{ // default mocked response data let responseMockedData = { data: JSON.stringify({test: "success"}), type: "application/json", status: 200, statusText: "OK", headers: { 'Last-Modified': 'TestingLastModifiedHeader', 'ETag': 'TestingETagHeader' } } // get a Promise resolvint to a mocked Response object built based on supplied data window.getMockedResponse = (url, init, response_data={}) => { let rdata = { ...responseMockedData, ...response_data } let response = new Response( new Blob( [rdata.data], {type: rdata.type} ), { status: rdata.status, statusText: rdata.statusText, headers: rdata.headers }); // Response.url is read-only, so we have Object.defineProperty( response, "url", { value: url } ); return Promise.resolve(response); } // get a mocked fetch()-like function that returns a Promise resolving to the above window.getMockedFetch = (response_data={}) => { return (url, init)=>{ return window.getMockedResponse(url, init, response_data) } } /* * prototype of the plugin init object */ window.initPrototype = { name: 'cache' } /* * mocking our own ServiceWorker API, sigh! * https://github.com/denoland/deno/issues/5957#issuecomment-985494969 */ window.registration = { scope: "https://test.resilient.is/", unregister: ()=>{} } /* * mocking caches.match() * https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage/match#browser_compatibility */ caches.match = async (url) => { let cache = await caches.open('v1') return cache.match(url) } /* * mocking Event.waitUntil() * https://developer.mozilla.org/en-US/docs/Web/API/ExtendableEvent/waitUntil#browser_compatibility */ Event.prototype.waitUntil = async (promise) => { await promise } /* * mocking importScripts */ window.importScripts = (script) => { let plugin = script.split('/')[2] window .LibResilientPluginConstructors .set( plugin, window.LibResilientPluginConstructorsPrototype.get(plugin) ) } /* * mocking window.clients */ window.clients = { get: async (id) => { // always return the same client, we care only about postMessage() working // and getting the messages return { // that's the only thing we need // this allows us to spy on client.postMessage() calls issued by the service worker postMessage: window.clients.prototypePostMessage } }, // the actual spy function must be possible to reference // but we want spy data per test, so we set it properly in beforeEach() prototypePostMessage: null } // we need to be able to reliably wait for SW installation // which is triggered by an "install" Event window.sw_install_ran = false // override addEventListener in order to override the callback // and to keep track of event listeners that we need to remove in afterEach() window.event_listeners = new Array() window.addEventListenerOrig = window.addEventListener window.addEventListener = async (evtype, func) => { // normally we want the handler to be what it says on the packaging let handler = func // but for "install" type event… we actually want to wrap it such that // we can then await for it if (evtype == 'install') { handler = async (ev) => { let result = await func(ev); window.sw_install_ran = true; return result; } } // adding to the list of installed event listeners window.event_listeners.push([evtype, handler]) // we're done return await window.addEventListenerOrig(evtype, handler) } // wait for SW installation window.waitForSWInstall = () => { return new Promise(async (resolve, reject)=>{ while (!window.sw_install_ran) { await new Promise(resolve => setTimeout(resolve, 1)) } resolve(true) }) } // wait for caching of a URL, looped up to `tries` times window.waitForCacheAction = (url, action="add", tries=100) => { if (action != "add" && action != "remove") { throw new Error('waitForCacheAction()\'s action parameter can only be "add" or "remove".') } console.log('*** WAITING FOR CACHE ACTION:', action, '\n - url:', url) return new Promise(async (resolve, reject)=>{ // get the cache object let cache = await caches.open('v1') // try to match until we succeed, or run out of tries for (let i=0; i A "CacheResponseResource" resource (rid 7) was created during // > the test, but not cleaned up during the test. Close the resource // > before the end of the test. return resolve(await cache_result.text()) } // waiting for content to be removed from cache? } else if (action === "remove") { if (cache_result === undefined) { return resolve(undefined) } // as above, we need to "use" the resource await cache_result.text() } await new Promise(resolve => setTimeout(resolve, 1)) } return reject("Ran out of tries"); }) } /* * importScripts mock relies on all plugins being loaded here * TODO: automagically load the list from the plugins directory */ let plugins = [ "alt-fetch", "any-of", "basic-integrity", "cache", "dnslink-fetch", "dnslink-ipfs", "fetch", "gun-ipfs", "integrity-check", "ipns-ipfs", "redirect", "signed-integrity", ] await Promise.all( plugins.map(async (plugin)=>{ await import(`../../plugins/${plugin}/index.js`) }) ) window.LibResilientPluginConstructorsPrototype = window.LibResilientPluginConstructors window.LibResilientPluginConstructors = new Map() }) /** * we need to do all of this before each test in order to reset the fetch() use counter * and make sure window.init is clean and not modified by previous tests */ beforeEach(async ()=>{ window.fetch = spy(window.getMockedFetch()) window.init = { ...window.initPrototype } // clear the caches await caches .has('v1') .then(async (hasv1) => { if (hasv1) { await caches.delete('v1') } }) // make sure we're starting with a clean slate in LibResilientPluginConstructors window.LibResilientPluginConstructors = new Map() // keeping track of whether the SW got installed window.sw_install_ran = false // cleanup self.LibResilientConfig = null self.LibResilientPlugins = null // postMessage spy window.clients.prototypePostMessage = spy((msg)=>{console.log('*** got message', msg)}) }) /** * after each test we need to do a bit of cleanup * * specifically, since we need to load the service-worker.js module anew * we want to clean up any side-effects of having loaded it for the previous test * and any side-effects of the previous test itself */ afterEach(async ()=>{ while (window.event_listeners.length) { await window.removeEventListener(...window.event_listeners.pop()); } window.test_id += 1; }) describe('service-worker', async () => { // mocking window.location // https://developer.mozilla.org/en-US/docs/Web/API/Window/location window.location = { origin: "https://test.resilient.is/" } window.LibResilientPluginConstructors = new Map() window.LR = { log: (component, ...items)=>{ console.debug(component + ' :: ', ...items) } } window.fetch = null window.test_id = 0 it("should set-up LibResilientPlugins", async () => { // we cannot import the same module multiple times: // https://github.com/denoland/deno/issues/6946 // // ...so we have to use a query-param hack, sigh await import("../../service-worker.js?" + window.test_id); assert(self.LibResilientPlugins instanceof Array) }) it("should use default LibResilientConfig values when config.json is missing", async () => { let mock_response_data = { data: JSON.stringify({text: "success"}), status: 404, statusText: "Not Found" } window.fetch = spy(window.getMockedFetch(mock_response_data)) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) assertSpyCalls(self.fetch, 1) }) it("should use default LibResilientConfig values when config.json not valid JSON", async () => { let mock_response_data = { data: "Not JSON" } window.fetch = spy(window.getMockedFetch(mock_response_data)) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) assertSpyCalls(self.fetch, 1) }) it("should use default LibResilientConfig values when no valid 'plugins' field in config.json", async () => { let mock_response_data = { data: JSON.stringify({loggedComponents: ['service-worker', 'fetch'], plugins: 'not a valid array'}) } window.fetch = spy(window.getMockedFetch(mock_response_data)) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) assertSpyCalls(self.fetch, 1) }) it("should use default LibResilientConfig values when no valid 'loggedComponents' field in config.json", async () => { let mock_response_data = { data: JSON.stringify({loggedComponents: 'not a valid array', plugins: [{name: "fetch"}]}) } window.fetch = spy(window.getMockedFetch(mock_response_data)) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) assertSpyCalls(self.fetch, 1) }) it("should use default LibResilientConfig values when 'defaultPluginTimeout' field in config.json contains an invalid value", async () => { let mock_response_data = { data: JSON.stringify({loggedComponents: ['service-worker', 'fetch'], plugins: [{name: "fetch"}], defaultPluginTimeout: 'not an integer'}) } window.fetch = spy(window.getMockedFetch(mock_response_data)) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) assertSpyCalls(self.fetch, 1) }) it("should use config values from a valid fetched config.json file, caching it", async () => { let mock_response_data = { data: JSON.stringify({loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 5000}) } window.fetch = spy(window.getMockedFetch(mock_response_data)) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache']) assertSpyCalls(self.fetch, 1) // cacheConfigJSON() is called asynchronously in the Service Worker, // if we don't make sure that the caching has completed, we will get an error. // so we wait until config.json is cached, and use that to verify it is in fact cached assertEquals( await window.waitForCacheAction(window.location.origin + 'config.json'), mock_response_data.data ); }) it("should instantiate a complex tree of configured plugins", async () => { self.LibResilientConfig = { plugins: [{ name: 'plugin-1', uses: [{ name: 'plugin-2', uses: [{ name: 'plugin-3' }] },{ name: 'plugin-3' }] },{ name: 'plugin-2', uses: [{ name: 'plugin-3' }] },{ name: 'plugin-3', uses: [{ name: 'plugin-1' },{ name: 'plugin-2', uses: [{ name: 'plugin-1', uses: [{ name: 'plugin-4' }] }] }] },{ name: 'plugin-4' }], loggedComponents: ['service-worker'] } window.LibResilientPluginConstructors.set('plugin-1', (LRPC, config)=>{ return { name: 'plugin-1', description: 'Plugin Type 1', version: '0.0.1', fetch: (url)=>{return true}, uses: config.uses || [] } }) window.LibResilientPluginConstructors.set('plugin-2', (LRPC, config)=>{ return { name: 'plugin-2', description: 'Plugin Type 2', version: '0.0.1', fetch: (url)=>{return true}, uses: config.uses || [] } }) window.LibResilientPluginConstructors.set('plugin-3', (LRPC, config)=>{ return { name: 'plugin-3', description: 'Plugin Type 3', version: '0.0.1', fetch: (url)=>{return true}, uses: config.uses || [] } }) window.LibResilientPluginConstructors.set('plugin-4', (LRPC, config)=>{ return { name: 'plugin-4', description: 'Plugin Type 4', version: '0.0.1', fetch: (url)=>{return true}, uses: config.uses || [] } }) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() assertEquals(typeof self.LibResilientConfig, 'object') // basic stuff assertEquals(self.LibResilientPlugins.length, 4) assertEquals(self.LibResilientPlugins[0].name, 'plugin-1') assertEquals(self.LibResilientPlugins[1].name, 'plugin-2') assertEquals(self.LibResilientPlugins[2].name, 'plugin-3') assertEquals(self.LibResilientPlugins[3].name, 'plugin-4') // first plugin dependencies assertEquals(self.LibResilientPlugins[0].uses.length, 2) assertEquals(self.LibResilientPlugins[0].uses[0].name, 'plugin-2') assertEquals(self.LibResilientPlugins[0].uses[0].uses.length, 1) assertEquals(self.LibResilientPlugins[0].uses[0].uses[0].name, 'plugin-3') assertEquals(self.LibResilientPlugins[0].uses[0].uses[0].uses.length, 0) assertEquals(self.LibResilientPlugins[0].uses[1].name, 'plugin-3') assertEquals(self.LibResilientPlugins[0].uses[1].uses.length, 0) // second plugin dependencies assertEquals(self.LibResilientPlugins[1].uses.length, 1) assertEquals(self.LibResilientPlugins[1].uses[0].name, 'plugin-3') assertEquals(self.LibResilientPlugins[1].uses[0].uses.length, 0) // third plugin dependencies assertEquals(self.LibResilientPlugins[2].uses.length, 2) assertEquals(self.LibResilientPlugins[2].uses[0].name, 'plugin-1') assertEquals(self.LibResilientPlugins[2].uses[0].uses.length, 0) assertEquals(self.LibResilientPlugins[2].uses[1].name, 'plugin-2') assertEquals(self.LibResilientPlugins[2].uses[1].uses.length, 1) assertEquals(self.LibResilientPlugins[2].uses[1].uses[0].name, 'plugin-1') assertEquals(self.LibResilientPlugins[2].uses[1].uses[0].uses.length, 1) assertEquals(self.LibResilientPlugins[2].uses[1].uses[0].uses[0].name, 'plugin-4') assertEquals(self.LibResilientPlugins[2].uses[1].uses[0].uses[0].uses.length, 0) // fourth plugin dependencies assertEquals(self.LibResilientPlugins[3].uses.length, 0) }) it("should instantiate configured plugins; explicitly disabled plugins should not be instantiated", async () => { self.LibResilientConfig = { plugins: [{ name: 'plugin-enabled' },{ name: 'plugin-disabled', enabled: false },{ name: 'plugin-enabled', enabled: true }] } window.LibResilientPluginConstructors.set('plugin-enabled', ()=>{ return { name: 'plugin-enabled', description: 'Enabled plugin', version: '0.0.1', fetch: (url)=>{return true} } }) window.LibResilientPluginConstructors.set('plugin-disabled', ()=>{ return { name: 'plugin-disabled', description: 'Disabled plugin', version: '0.0.1', fetch: (url)=>{return true} } }) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() assertEquals(typeof self.LibResilientConfig, 'object') assertEquals(self.LibResilientPlugins.length, 2) assertEquals(self.LibResilientPlugins[0].name, 'plugin-enabled') assertEquals(self.LibResilientPlugins[1].name, 'plugin-enabled') }) it("should instantiate configured plugins; explicitly disabled dependencies should not be instantiated", async () => { self.LibResilientConfig = { plugins: [{ name: 'plugin-disabled', enabled: false, uses: [{ name: 'dependency-enabled' }] },{ name: 'plugin-enabled', uses: [{ name: 'dependency-disabled', enabled: false }] },{ name: 'plugin-enabled', uses: [{ name: 'dependency-enabled', enabled: true }] }], loggedComponents: ['service-worker'] } window.LibResilientPluginConstructors.set('plugin-enabled', (LRPC, config)=>{ return { name: 'plugin-enabled', description: 'Enabled plugin', version: '0.0.1', fetch: (url)=>{return true}, uses: config.uses || [] } }) window.LibResilientPluginConstructors.set('plugin-disabled', (LRPC, config)=>{ return { name: 'plugin-disabled', description: 'Disabled plugin', version: '0.0.1', fetch: (url)=>{return true}, uses: config.uses || [] } }) window.LibResilientPluginConstructors.set('dependency-disabled', (LRPC, config)=>{ return { name: 'dependency-disabled', description: 'Disabled dependency plugin', version: '0.0.1', fetch: (url)=>{return true}, uses: config.uses || [] } }) window.LibResilientPluginConstructors.set('dependency-enabled', (LRPC, config)=>{ return { name: 'dependency-enabled', description: 'Enabled dependency plugin', version: '0.0.1', fetch: (url)=>{return true}, uses: config.uses || [] } }) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() assertEquals(typeof self.LibResilientConfig, 'object') assertEquals(self.LibResilientPlugins.length, 2) assertEquals(self.LibResilientPlugins[0].name, 'plugin-enabled') assertEquals(self.LibResilientPlugins[0].uses.length, 0) assertEquals(self.LibResilientPlugins[1].name, 'plugin-enabled') assertEquals(self.LibResilientPlugins[1].uses.length, 1) assertEquals(self.LibResilientPlugins[1].uses[0].name, 'dependency-enabled') assertEquals(self.LibResilientPlugins[1].uses[0].uses.length, 0) }) it("should use a cached valid config.json file, with no fetch happening", async () => { // prepare the config request/response let mock_response_data = { data: JSON.stringify({loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 5000}) } var config_url = window.location.origin + 'config.json' // get the response containing config to cache var config_response = await window.getMockedResponse(config_url, {}, mock_response_data) // cache it once you get it await caches .open('v1') .then(async (cache)=>{ await cache .put( config_url, await window.getMockedResponse(config_url, {}, mock_response_data) ) }) // service worker is a go! await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() assertEquals(typeof self.LibResilientConfig, 'object') assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache']) assertSpyCalls(self.fetch, 0) }) it("should use a stale cached valid config.json file without a fetch, then retrieve and cache a fresh config.json using the configured plugins", async () => { // this does not change var config_url = window.location.origin + 'config.json' // prepare the stale config request/response let mock_response_data = { data: JSON.stringify({loggedComponents: ['service-worker', 'cache', 'fetch'], plugins: [{name: "fetch"},{name: "cache"}], defaultPluginTimeout: 5000}), headers: { // very stale date 'Date': new Date(0).toUTCString() } } // cache it once you get it await caches .open('v1') .then(async (cache)=>{ await cache .put( config_url, await window.getMockedResponse(config_url, {}, mock_response_data) ) let resp = await cache .match(config_url) console.log(resp) console.log(await resp.text()) }) // prepare the fresh config request/response let mock_response_data2 = { data: JSON.stringify({loggedComponents: ['service-worker', 'fetch'], plugins: [{name: "fetch"}], defaultPluginTimeout: 2000}), headers: { // very stale date 'Date': new Date(0).toUTCString() } } // we need to be able to spy on the function that "fetches" the config let resolveConfigFetch = spy(window.getMockedFetch(mock_response_data2)) window.LibResilientPluginConstructors.set('fetch', ()=>{ return { name: 'fetch', description: 'Resolve with config data (pretending to be fetch).', version: '0.0.1', fetch: resolveConfigFetch } }) // service worker is a go! await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() // verify current config (the one from the pre-cached stale `config.json`) assertEquals(typeof self.LibResilientConfig, 'object') assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache', 'fetch']) assertSpyCalls(self.fetch, 0) assertSpyCalls(resolveConfigFetch, 1) // wait until verify the *new* config got cached // running waitForCacheAdd only once might not be enough, as the cache // already contained config.json! // // we have try to get it a few times, but limit how many times we try // so as not to end up in a forever loop for (let i=0; i<100; i++) { // did we get the new config? if (await window.waitForCacheAction(config_url) === mock_response_data2.data) { // we did! we're done return true; } } fail('New config failed to cache, apparently!') }) it("should use a stale cached valid config.json file without a fetch; invalid config.json retrieved using the configured plugins should not be cached", async () => { // this does not change var config_url = window.location.origin + 'config.json' // prepare the stale config request/response let mock_response_data = { data: JSON.stringify({loggedComponents: ['service-worker', 'cache', 'resolve-config'], plugins: [{name: "cache"}, {name: "resolve-config"}], defaultPluginTimeout: 5000}), headers: { // very stale date 'Date': new Date(0).toUTCString() } } // cache it once you get it await caches .open('v1') .then(async (cache)=>{ await cache .put( config_url, await window.getMockedResponse(config_url, {}, mock_response_data) ) let resp = await cache .match(config_url) console.log(resp) console.log(await resp.text()) }) // prepare the fresh invalid config request/response let mock_response_data2 = { data: JSON.stringify({loggedComponentsInvalid: ['service-worker', 'resolve-config'], pluginsInvalid: [{name: "resolve-config"}], defaultPluginTimeoutInvalid: 2000}), headers: { // very stale date 'Date': new Date(0).toUTCString() } } // we need to be able to spy on the function that "fetches" the config let resolveConfigFetch = spy(window.getMockedFetch(mock_response_data2)) window.LibResilientPluginConstructors.set('resolve-config', ()=>{ return { name: 'resolve-config', description: 'Resolve with config data.', version: '0.0.1', fetch: resolveConfigFetch } }) // service worker is a go! await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() // verify current config (the one from the pre-cached stale `config.json`) assertEquals(typeof self.LibResilientConfig, 'object') assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "cache"}, {name: "resolve-config"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache', 'resolve-config']) assertSpyCalls(self.fetch, 0) assertSpyCalls(resolveConfigFetch, 1) // waiting for potential caching of the "new" config for (let i=0; i<100; i++) { // did we get the new config? if (await window.waitForCacheAction(config_url) === mock_response_data2.data) { // we did! that's a paddling! fail('New config failed to cache, apparently!') } } }) it("should use a stale cached valid config.json file without a fetch; valid config.json retrieved using the configured plugins other than fetch should not be cached", async () => { // this does not change var config_url = window.location.origin + 'config.json' // prepare the stale config request/response let mock_response_data = { data: JSON.stringify({loggedComponents: ['service-worker', 'resolve-config'], plugins: [{name: "resolve-config"}], defaultPluginTimeout: 5000}), headers: { // very stale date 'Date': new Date(0).toUTCString() } } // cache it once you get it await caches .open('v1') .then(async (cache)=>{ await cache .put( config_url, await window.getMockedResponse(config_url, {}, mock_response_data) ) let resp = await cache .match(config_url) console.log(resp) console.log(await resp.text()) }) // prepare the fresh invalid config request/response let mock_response_data2 = { data: JSON.stringify({loggedComponents: ['service-worker', 'resolve-config', 'cache'], plugins: [{name: "resolve-config"}, {name: "cache"}], defaultPluginTimeout: 2000}), headers: { // very stale date 'Date': new Date(0).toUTCString() } } // we need to be able to spy on the function that "fetches" the config let resolveConfigFetch = spy(window.getMockedFetch(mock_response_data2)) window.LibResilientPluginConstructors.set('resolve-config', ()=>{ return { name: 'resolve-config', description: 'Resolve with config data.', version: '0.0.1', fetch: resolveConfigFetch } }) // service worker is a go! await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() // verify current config (the one from the pre-cached stale `config.json`) assertEquals(typeof self.LibResilientConfig, 'object') assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "resolve-config"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'resolve-config']) assertSpyCalls(self.fetch, 0) assertSpyCalls(resolveConfigFetch, 1) // waiting for potential caching of the "new" config for (let i=0; i<100; i++) { // did we get the new config? if (await window.waitForCacheAction(config_url) === mock_response_data2.data) { // we did! that's a paddling fail('New config failed to cache, apparently!') } } }) it("should ignore failed fetch by first configured plugin if followed by a successful fetch by a second one", async () => { window.LibResilientConfig = { plugins: [{ name: 'reject-all' },{ name: 'resolve-all' }], loggedComponents: [ 'service-worker' ] } let rejectingFetch = spy( (request, init)=>{ return Promise.reject('reject-all rejecting a request for: ' + request); } ) window.LibResilientPluginConstructors.set('reject-all', ()=>{ return { name: 'reject-all', description: 'Reject all requests.', version: '0.0.1', fetch: rejectingFetch } }) window.LibResilientPluginConstructors.set('resolve-all', ()=>{ return { name: 'resolve-all', description: 'Resolve all requests.', version: '0.0.1', fetch: fetch } }) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() let fetch_event = new FetchEvent('test.json') window.dispatchEvent(fetch_event) let response = await fetch_event.waitForResponse() assertSpyCalls(window.fetch, 2); // two, because the first one is for config.json assertSpyCalls(rejectingFetch, 1); assertSpyCall(window.fetch, 1, { args: [ "https://test.resilient.is/test.json", { cache: undefined, integrity: undefined, method: "GET", redirect: "follow", referrer: undefined, }] }) assertEquals(await response.json(), { test: "success" }) }); it("should pass the Request() init data to plugins", async () => { self.LibResilientConfig = { plugins: [{ name: 'reject-all' },{ name: 'resolve-all' }], loggedComponents: [ 'service-worker' ] } let rejectingFetch = spy( (request, init)=>{ return Promise.reject('reject-all rejecting a request for: ' + request); } ) window.LibResilientPluginConstructors.set('reject-all', ()=>{ return { name: 'reject-all', description: 'Reject all requests.', version: '0.0.1', fetch: rejectingFetch } }) window.LibResilientPluginConstructors.set('resolve-all', ()=>{ return { name: 'resolve-all', description: 'Resolve all requests.', version: '0.0.1', fetch: fetch } }) let initTest = { method: "GET", // TODO: ref. https://gitlab.com/rysiekpl/libresilient/-/issues/23 //headers: new Headers({"x-stub": "STUB"}), //mode: "mode-stub", //credentials: "credentials-stub", //cache: "cache-stub", //referrer: "referrer-stub", redirect: "error", //integrity: "" } await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() let fetch_event = new FetchEvent('test.json', initTest) window.dispatchEvent(fetch_event) let response = await fetch_event.waitForResponse() assertSpyCalls(rejectingFetch, 1); assertSpyCalls(window.fetch, 2); // two, because the first one is for config.json assertEquals(await response.json(), { test: "success" }) assertSpyCall(rejectingFetch, 0, { args: [ "https://test.resilient.is/test.json", { cache: undefined, integrity: undefined, method: "GET", redirect: "error", referrer: undefined, }] }) assertSpyCall(window.fetch, 1, { args: [ "https://test.resilient.is/test.json", { cache: undefined, integrity: undefined, method: "GET", redirect: "error", referrer: undefined, }] }) }); it("should respect defaultPluginTimeout", async () => { window.LibResilientConfig = { defaultPluginTimeout: 100, plugins: [{ name: 'resolve-with-timeout' }], loggedComponents: [ 'service-worker', ] } let rwtCallback = spy() let rwt_timeout_id = null window.LibResilientPluginConstructors.set('resolve-with-timeout', ()=>{ return { name: 'resolve-with-timeout', description: 'Resolve all requests after a timeout.', version: '0.0.1', fetch: (request, init)=>{ return new Promise((resolve, reject)=>{ rwt_timeout_id = setTimeout(rwtCallback, 300) }) } } }) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() let fetch_event = new FetchEvent('test.json') window.dispatchEvent(fetch_event) let err = null try { let response = await fetch_event.waitForResponse() } catch(e) { err = e } clearTimeout(rwt_timeout_id) assertEquals(err.toString(), "Error: LibResilient request using resolve-with-timeout timed out after 100ms.") }); it("external request should work and not go through the plugins", async () => { self.LibResilientConfig = { plugins: [{ name: 'reject-all' }], loggedComponents: [ 'service-worker' ] } window.LibResilientPluginConstructors.set('reject-all', ()=>{ return { name: 'reject-all', description: 'Reject all requests.', version: '0.0.1', fetch: (request, init)=>{ return Promise.reject(request); } } }) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() let fetch_event = new FetchEvent('https://example.com/test.json') window.dispatchEvent(fetch_event) let response = await fetch_event.waitForResponse() assertEquals(await response.json(), { test: "success" }) }) it("should make POST requests not go through the plugins", async () => { self.LibResilientConfig = { plugins: [{ name: 'reject-all' }], loggedComponents: [ 'service-worker' ] } window.LibResilientPluginConstructors.set('reject-all', ()=>{ return { name: 'reject-all', description: 'Reject all requests.', version: '0.0.1', fetch: (request, init)=>{ return Promise.reject(request); } } }) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() let fetch_event = new FetchEvent(window.location.origin + 'test.json', {method: "POST"}) window.dispatchEvent(fetch_event) let response = await fetch_event.waitForResponse() assertEquals(await response.json(), { test: "success" }) }) it("should stash content after a successful fetch", async () => { self.LibResilientConfig = { plugins: [{ name: 'fetch' },{ name: 'cache' }], loggedComponents: [ 'service-worker', 'fetch', 'cache' ] } await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() let fetch_event = new FetchEvent(window.location.origin + 'test.json') window.dispatchEvent(fetch_event) let response = await fetch_event.waitForResponse() assertEquals(await response.json(), { test: "success" }) // stashing plugin's stash() is called asynchronously in the Service Worker, // if we don't make sure that the caching has completed, we will get an error. // so we wait until config.json is cached, and use that to verify it is in fact // cached assertEquals( JSON.parse( await window.waitForCacheAction(window.location.origin + 'test.json')), { test: "success" } ); }); it("should skip stashing should if content was retrieved from a stashing plugin", async () => { self.LibResilientConfig = { plugins: [{ name: 'stashing-test' },{ name: 'reject-all' }], loggedComponents: [ 'service-worker' ] } // three little mocks let resolvingFetch = spy(window.getMockedFetch()) let rejectingFetch = spy((request, init)=>{ return Promise.reject(request); }) let stashingStash = spy() // two little plugins window.LibResilientPluginConstructors.set('stashing-test', ()=>{ return { name: 'stashing-test', description: 'Mock stashing plugin.', version: '0.0.1', fetch: resolvingFetch, stash: stashingStash } }) window.LibResilientPluginConstructors.set('reject-all', ()=>{ return { name: 'reject-all', description: 'Reject all requests.', version: '0.0.1', fetch: rejectingFetch } }) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() let fetch_event = new FetchEvent(window.location.origin + 'test.json') window.dispatchEvent(fetch_event) let response = await fetch_event.waitForResponse() assertEquals(await response.json(), { test: "success" }) assertSpyCalls(resolvingFetch, 1) assertSpyCalls(stashingStash, 0) assertSpyCalls(rejectingFetch, 1) }); it("should stash content if it was retrieved from a job after retrieval from a stashing plugin, and it differs from the stashed version", async () => { self.LibResilientConfig = { plugins: [{ name: 'stashing-test' },{ name: 'resolve-all' }], loggedComponents: [ 'service-worker' ] } // three little mocks let resolvingFetch = spy(window.getMockedFetch()) let resolvingFetch2 = spy(window.getMockedFetch({ data: JSON.stringify({ test: "success2" }), headers: { 'X-LibResilient-ETag': 'NewTestingETagHeader' } })) let stashingStash = spy(async (response, url)=>{ assertEquals(await response.json(), { test: "success2" }) assertEquals(response.headers.get('X-LibResilient-ETag'), 'NewTestingETagHeader') }) window.LibResilientPluginConstructors.set('stashing-test', ()=>{ return { name: 'stashing-test', description: 'Mock stashing plugin.', version: '0.0.1', fetch: resolvingFetch, stash: stashingStash } }) window.LibResilientPluginConstructors.set('resolve-all', ()=>{ return { name: 'resolve-all', description: 'Resolve all requests.', version: '0.0.1', fetch: resolvingFetch2 } }) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() let fetch_event = new FetchEvent(window.location.origin + 'test.json') window.dispatchEvent(fetch_event) let response = await fetch_event.waitForResponse() assertEquals(await response.json(), { test: "success" }) assertSpyCalls(resolvingFetch, 1) assertSpyCalls(stashingStash, 1) assertSpyCalls(resolvingFetch2, 1) assertSpyCall( window.clients.prototypePostMessage, 6, { args: [{ url: "https://test.resilient.is/test.json", fetchedDiffers: true }]} ) }); it("should stash content when explicitly asked to", async () => { self.LibResilientConfig = { plugins: [{ name: 'cache' }], loggedComponents: [ 'service-worker', 'cache' ] } await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() let stashEvent = new Event('message') stashEvent.data = { stash: [await window.getMockedResponse(window.location.origin + 'test.json')] } // stash it! await self.dispatchEvent(stashEvent) // let's see if it got added to cache assertEquals( JSON.parse(await window.waitForCacheAction(window.location.origin + 'test.json')), { test: "success" } ); }); it("should pass the Request() init data to a background plugin after a retrieval from a stashing plugin", async () => { self.LibResilientConfig = { plugins: [{ name: 'stashing-test' },{ name: 'resolve-all' }], loggedComponents: [ 'service-worker' ] } let resolvingFetch = spy(window.getMockedFetch({ headers: { 'X-LibResilient-Method': 'resolve-all', 'X-LibResilient-ETag': 'TestingETagHeader' } })) let resolvingFetch2 = spy(window.getMockedFetch({ data: JSON.stringify({ test: "success2" }), headers: { 'ETag': 'NewTestingETagHeader' } })) let stashingStash = spy(async (response, url)=>{ assertEquals(await response.json(), { test: "success2" }) assertEquals(response.headers.get('ETag'), 'NewTestingETagHeader') }) window.LibResilientPluginConstructors.set('stashing-test', ()=>{ return { name: 'stashing-test', description: 'Mock stashing plugin.', version: '0.0.1', fetch: resolvingFetch, stash: stashingStash } }) window.LibResilientPluginConstructors.set('resolve-all', ()=>{ return { name: 'resolve-all', description: 'Resolve all requests.', version: '0.0.1', fetch: resolvingFetch2 } }) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() let initTest = { method: "GET", // TODO: ref. https://gitlab.com/rysiekpl/libresilient/-/issues/23 //headers: new Headers({"x-stub": "STUB"}), //mode: "mode-stub", //credentials: "same-origin", cache: undefined, referrer: undefined, redirect: "error", // this is the only signal we get here, really! integrity: undefined } let fetch_event = new FetchEvent(window.location.origin + 'test.json', initTest) window.dispatchEvent(fetch_event) let response = await fetch_event.waitForResponse() assertSpyCalls(resolvingFetch, 1); assertSpyCalls(resolvingFetch2, 1); assertEquals(await response.json(), { test: "success" }) assertSpyCall( resolvingFetch, 0, { args: [ window.location.origin + 'test.json', initTest ]} ) assertSpyCall( resolvingFetch2, 0, { args: [ window.location.origin + 'test.json', initTest ]} ) }); it("should unstash content when explicitly asked to", async () => { self.LibResilientConfig = { plugins: [{ name: 'cache' }], loggedComponents: [ 'service-worker', 'cache' ] } await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() let stashEvent = new Event('message') stashEvent.data = { stash: [await window.getMockedResponse(window.location.origin + 'test.json')] } // stash it! await self.dispatchEvent(stashEvent) // let's see if it got added to cache assertEquals( JSON.parse(await window.waitForCacheAction(window.location.origin + 'test.json')), { test: "success" } ); let unstashEvent = new Event("message") unstashEvent.data = { unstash: [window.location.origin + 'test.json'] } // unstash it! await self.dispatchEvent(unstashEvent) // let's see if it got removed from cache assertEquals( await window.waitForCacheAction(window.location.origin + 'test.json', "remove"), undefined ); }); it("should handle publishing content explicitly", async () => { self.LibResilientConfig = { plugins: [{ name: 'publish-test' }], loggedComponents: [ 'service-worker' ] } let publishMock = spy() window.LibResilientPluginConstructors.set('publish-test', ()=>{ return { name: 'publish-test', description: 'Publish plugin fixture.', version: '0.0.1', publish: publishMock } }) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() let publishEvent = new Event('message') publishEvent.data = { publish: [await window.getMockedResponse(window.location.origin + 'test.json')] } // publish it! await self.dispatchEvent(publishEvent) assertSpyCall(publishMock, 0, { args: [publishEvent.data.publish[0]] }) }) it("should be able to use plugins with dependencies correctly", async () => { self.LibResilientConfig = { plugins: [{ name: 'dependent-test', uses: [{ name: 'dependency1-test' },{ name: 'dependency2-test' }] }], loggedComponents: [ 'service-worker' ] } window.LibResilientPluginConstructors.set('dependent-test', ()=>{ return { name: 'dependent-test', description: 'Dependent plugin fixture.', version: '0.0.1', uses: [{ name: 'dependency1-test' },{ name: 'dependency2-test' }] } }) window.LibResilientPluginConstructors.set('dependency1-test', ()=>{ return { name: 'dependency1-test', description: 'First dependency plugin fixture.', version: '0.0.1' } }) window.LibResilientPluginConstructors.set('dependency2-test', ()=>{ return { name: 'dependency2-test', description: 'Second dependency plugin fixture.', version: '0.0.1' } }) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() assertEquals(self.LibResilientPlugins.map(p=>p.name), ['dependent-test']) assertEquals(self.LibResilientPlugins[0].uses.map(p=>p.name), ['dependency1-test', 'dependency2-test']) }) it("should be able to use multiple instances of the same plugin", async () => { self.LibResilientConfig = { plugins: [{ name: 'plugin-test', },{ name: 'plugin-test', },{ name: 'plugin-test', }], loggedComponents: [ 'service-worker' ] } var pver = 0 window.LibResilientPluginConstructors.set('plugin-test', ()=>{ pver += 1 return { name: 'plugin-test', description: 'Simple plugin stub.', version: '0.0.' + pver } }) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() assertEquals(self.LibResilientPlugins.map(p=>p.name), ['plugin-test', 'plugin-test', 'plugin-test']) assertEquals(self.LibResilientPlugins.map(p=>p.version), ['0.0.1', '0.0.2', '0.0.3']) }) it("should error out if all plugins fail", async () => { self.LibResilientConfig = { plugins: [{ name: 'reject-all' }], loggedComponents: [ 'service-worker' ] } window.LibResilientPluginConstructors.set('reject-all', ()=>{ return { name: 'reject-all', description: 'Reject all requests.', version: '0.0.1', fetch: (request, init)=>{ return Promise.reject(request); } } }) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() let fetch_event = new FetchEvent('test.json') window.dispatchEvent(fetch_event) assertRejects( ()=>{ return fetch_event.waitForResponse() }, fetch_event.request ) }) it("should send clientId back if event.resultingClientId is set", async () => { self.LibResilientConfig = { plugins: [{ name: 'resolve-all' }], loggedComponents: [ 'service-worker' ] } window.LibResilientPluginConstructors.set('resolve-all', ()=>{ return { name: 'resolve-all', description: 'Resolve all requests.', version: '0.0.1', fetch: window.fetch } }) await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() // we need a FetchEvent with a resultingClientId field set let fetch_event = new FetchEvent('test.json') fetch_event.resultingClientId = 'resulting-client-id-test' // do the fetch and wait for the result that we don't really care about window.dispatchEvent(fetch_event) await fetch_event.waitForResponse() // assert that resulting-client-id-test shows up in messages // posted from the service worker assertSpyCall(window.clients.prototypePostMessage, 0, { args: [{ clientId: "resulting-client-id-test", plugins: [ "resolve-all" ], serviceWorker: "COMMIT_UNKNOWN" }] }) }) })