Skip to content

Commit 3296b14

Browse files
authored
Add test coverage for service worker (#57)
1 parent 3187693 commit 3296b14

File tree

2 files changed

+226
-26
lines changed

2 files changed

+226
-26
lines changed

service-worker/bootstrap.js

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ console.log('[react-storefront service worker]', 'Using React Storefront Service
22

33
workbox.loadModule('workbox-strategies')
44

5+
const IS_AMP_REGEX = /([?&]amp=1)(&.*)?$/
6+
57
const PREFETCH_CACHE_MISS = 412
68

79
let runtimeCacheOptions = {}
@@ -206,7 +208,7 @@ self.addEventListener('message', function(event) {
206208
}
207209
})
208210

209-
const isApiRequest = path => path.match(/^\/api\//)
211+
const isApiRequest = path => !!path.match(/^\/api\//)
210212

211213
/**
212214
* Gets the name of the versioned runtime cache
@@ -235,12 +237,13 @@ self.addEventListener('install', event => {
235237
})
236238
.then(allClients => {
237239
allClients
238-
.map(client => {
239-
const url = new URL(client.url)
240-
return url.pathname + url.search
240+
.filter(path => path.url.match(IS_AMP_REGEX))
241+
.map(path => {
242+
const url = new URL(path.url)
243+
// remove "amp=1" from anywhere in url.search:
244+
const fixedSearch = (url.search || '').replace(IS_AMP_REGEX, '$2').replace(/^&/, '?')
245+
return url.pathname + fixedSearch
241246
})
242-
.filter(path => path.match(/\.amp$/))
243-
.map(path => path.replace('.amp', ''))
244247
.forEach(path => cachePath({ path }, true))
245248
})
246249
})
@@ -290,7 +293,7 @@ function isStaticAsset(context) {
290293
* @return {Boolean}
291294
*/
292295
function isAmp(url) {
293-
return !!url.pathname.match(/\.amp$/)
296+
return !!(url.search || '').match(IS_AMP_REGEX)
294297
}
295298

296299
/**
@@ -299,7 +302,7 @@ function isAmp(url) {
299302
* @return {Boolean}
300303
*/
301304
function isVideo(context) {
302-
return context.url.pathname.match(/\.mp4$/)
305+
return !!context.url.pathname.match(/\.mp4(\?.*)?$/)
303306
}
304307

305308
const matchRuntimePath = context => {

test/bootstrap.test.js

Lines changed: 215 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import makeServiceWorkerEnv from 'service-worker-mock'
2+
import url from 'url';
23

34
let sw
45

@@ -11,28 +12,111 @@ describe('bootstrap', () => {
1112
})
1213
serviceWorkerEnv.workbox = {
1314
loadModule: () => null,
14-
expiration: { ExpirationPlugin: jest.fn() },
15+
expiration: {
16+
ExpirationPlugin: class ExpirationPlugin {
17+
constructor(params) {
18+
Object.assign(this, params)
19+
}
20+
},
21+
},
1522
routing: { registerRoute: jest.fn() },
1623
}
1724
Object.assign(global, serviceWorkerEnv)
1825
jest.resetModules()
1926
sw = require('../service-worker/bootstrap')
2027
})
2128

22-
it('should add data to the cache', async () => {
23-
const cacheData = { testData: 'testData1' }
24-
self.trigger('message', {
25-
data: {
26-
action: 'cache-state',
27-
path: 'testPath',
28-
cacheData,
29-
apiVersion: 'v1',
30-
},
29+
describe('message listener', () => {
30+
it('should listen for messages to cache a url path', async () => {
31+
const apiVersion = 'v1'
32+
const apiCacheName = sw.__get__('getAPICacheName')(apiVersion)
33+
const path = '/api/p/1'
34+
const abortControllers = sw.__get__('abortControllers')
35+
expect(abortControllers.size).toEqual(0)
36+
await self.trigger('message', {
37+
data: {
38+
action: 'cache-path',
39+
path,
40+
apiVersion,
41+
},
42+
})
43+
expect(self.snapshot().caches[apiCacheName]).toBeDefined()
44+
})
45+
46+
it('should listen for messages to cache data', async () => {
47+
const apiVersion = 'v1'
48+
const apiCacheName = sw.__get__('getAPICacheName')(apiVersion)
49+
const cacheData = { testData: 'testData1' }
50+
self.trigger('message', {
51+
data: {
52+
action: 'cache-state',
53+
path: 'testPath',
54+
cacheData,
55+
apiVersion,
56+
},
57+
})
58+
const cache = await caches.open(apiCacheName)
59+
const cachedValue = await cache.store.get('testPath').response.json()
60+
expect(cachedValue).toEqual(cacheData)
61+
})
62+
63+
it('should listen for messages to configure caching options', async () => {
64+
await self.trigger('message', {
65+
data: {
66+
action: 'configure-runtime-caching',
67+
options: { maxEntries: 100, maxAgeSeconds: 1 },
68+
},
69+
})
70+
const options = sw.__get__('runtimeCacheOptions')
71+
expect(options.plugins[0].maxEntries).toEqual(100)
72+
expect(options.plugins[0].maxAgeSeconds).toEqual(1)
3173
})
32-
const cache = await caches.open('runtime-v1')
33-
const cachedValue = await cache.store.get('testPath').response.json()
3474

35-
expect(cachedValue).toEqual(cacheData)
75+
it('should listen for messages to abort prefetches', async () => {
76+
const abortControllers = sw.__get__('abortControllers')
77+
abortControllers.add(new AbortController())
78+
expect(abortControllers.size).toEqual(1)
79+
await self.trigger('message', {
80+
data: {
81+
action: 'abort-prefetches',
82+
},
83+
})
84+
expect(abortControllers.size).toEqual(0)
85+
})
86+
87+
it('should listen for messages to resume prefetches', async () => {
88+
const cachePath = jest.fn()
89+
sw.__set__('cachePath', cachePath)
90+
const toResume = sw.__get__('toResume')
91+
toResume.add([{ path: '', apiVersion: 'v1' }])
92+
await self.trigger('message', {
93+
data: {
94+
action: 'resume-prefetches',
95+
},
96+
})
97+
expect(cachePath).toHaveBeenCalled()
98+
})
99+
})
100+
101+
describe('precacheLinks', () => {
102+
it('should detect all `data-rsf-prefetch` links in a response', async () => {
103+
const precacheLinks = sw.__get__('precacheLinks')
104+
const text = () =>
105+
Promise.resolve('<a href="/api/p/1">No</a><a href="/api/p/2" data-rsf-prefetch>Yes</a>')
106+
const abortControllers = sw.__get__('abortControllers')
107+
expect(abortControllers.size).toEqual(0)
108+
await precacheLinks({ text })
109+
expect(abortControllers.size).toEqual(1)
110+
expect(abortControllers.values().next().value.args[0].path).toEqual('/api/p/2')
111+
})
112+
113+
it('should ignore links without `data-rsf-prefetch`', async () => {
114+
const precacheLinks = sw.__get__('precacheLinks')
115+
const text = () => Promise.resolve('<a href="/api/p/1">No</a>')
116+
const abortControllers = sw.__get__('abortControllers')
117+
await precacheLinks({ text })
118+
expect(abortControllers.size).toEqual(0)
119+
})
36120
})
37121

38122
describe('abortPrefetches', () => {
@@ -76,13 +160,15 @@ describe('bootstrap', () => {
76160
})
77161
})
78162

79-
describe('fetch', () => {
80-
it('should abort prefetches when fetching more important resources', () => {
163+
describe('fetch listener', () => {
164+
it('should abort prefetches when fetching more important resources', async () => {
81165
const abortControllers = sw.__get__('abortControllers')
82-
const toResume = sw.__get__('toResume')
83-
abortControllers.add(new AbortController())
84-
self.trigger('fetch')
166+
const abortController = new AbortController()
167+
abortController.args = [{ path: '', apiVersion: 'v1' }]
168+
abortControllers.add(abortController)
169+
self.trigger('fetch', '')
85170
expect(abortControllers.size).toEqual(0)
171+
const toResume = sw.__get__('toResume')
86172
expect(toResume.size).toEqual(1)
87173
})
88174

@@ -102,5 +188,116 @@ describe('bootstrap', () => {
102188
} catch (e) {}
103189
expect(toResume.size).toEqual(0)
104190
})
191+
192+
it('should fetch from the cache if the request is cached', async () => {
193+
const getAPICacheName = sw.__get__('getAPICacheName')
194+
const addToCache = sw.__get__('addToCache')
195+
const cache = await caches.open(getAPICacheName('v1'))
196+
await addToCache(cache, '/api/p/1', 'data')
197+
global.fetch = jest.fn()
198+
await self.trigger('fetch', { respondWith: () => null, request: '/api/p/1' })
199+
expect(global.fetch).not.toHaveBeenCalled()
200+
await self.trigger('fetch', { respondWith: () => null, request: '/api/p/2' })
201+
expect(global.fetch).toHaveBeenCalled()
202+
})
203+
})
204+
205+
describe('util functions', () => {
206+
it('should detect if a path is for an API request', () => {
207+
const isApiRequest = sw.__get__('isApiRequest');
208+
expect(isApiRequest('/api/p/1')).toEqual(true)
209+
expect(isApiRequest('/p/1')).toEqual(false)
210+
})
211+
212+
it('should detect if a request is using a secure connection', () => {
213+
const isSecure = sw.__get__('isSecure');
214+
const secureUrl = url.parse('https://wwww.example.com')
215+
const localhostUrl = url.parse('http://localhost:3000')
216+
const insecureUrl = url.parse('http://wwww.example.com')
217+
expect(isSecure({ url: secureUrl })).toEqual(true)
218+
expect(isSecure({ url: localhostUrl })).toEqual(true)
219+
expect(isSecure({ url: insecureUrl })).toEqual(false)
220+
})
221+
222+
it('should detect if a request is for a static asset', () => {
223+
const isStaticAsset = sw.__get__('isStaticAsset');
224+
const staticUrl = url.parse('https://wwww.example.com/_next/static/asset.png')
225+
const nonStaticUrl = url.parse('https://www.example.com/p/1')
226+
expect(isStaticAsset({ url: staticUrl })).toEqual(true)
227+
expect(isStaticAsset({ url: nonStaticUrl })).toEqual(false)
228+
})
229+
230+
it('should detect if a request is using amp', () => {
231+
const isAmp = sw.__get__('isAmp');
232+
const firstParamUrl = url.parse('https://wwww.example.com/p/1?amp=1')
233+
const laterParamUrl = url.parse('https://wwww.example.com/p/1?param1=test&amp=1')
234+
const innerParamUrl = url.parse('https://wwww.example.com/p/1?param1=test&amp=1&param2=test')
235+
const nonAmpUrl = url.parse('https://www.example.com/p/1')
236+
expect(isAmp(firstParamUrl)).toEqual(true)
237+
expect(isAmp(laterParamUrl)).toEqual(true)
238+
expect(isAmp(innerParamUrl)).toEqual(true)
239+
expect(isAmp(nonAmpUrl)).toEqual(false)
240+
})
241+
242+
it('should detect if a request is for a video', () => {
243+
const isVideo = sw.__get__('isVideo');
244+
const videoUrl = url.parse('https://wwww.example.com/p/vid.mp4')
245+
const videoWithParamsUrl = url.parse('https://wwww.example.com/p/vid.mp4?autoplay=true')
246+
const nonVideoUrl = url.parse('https://www.example.com/p/1')
247+
expect(isVideo({ url: videoUrl })).toEqual(true)
248+
expect(isVideo({ url: videoWithParamsUrl })).toEqual(true)
249+
expect(isVideo({ url: nonVideoUrl })).toEqual(false)
250+
})
251+
})
252+
253+
describe('install listener', () => {
254+
it('should delete existing runtime caches when installing', async () => {
255+
const cacheName = 'delete-me'
256+
const cache = await caches.open(cacheName)
257+
await cache.put('test', 'cached info')
258+
await self.trigger('install')
259+
console.log(self.clients.matchAll)
260+
expect(self.snapshot().caches[cacheName]).toBeUndefined()
261+
})
262+
263+
it('should cache non-amp version of pages when users land on AMP page', async () => {
264+
const cachePath = jest.fn()
265+
sw.__set__('cachePath', cachePath)
266+
self.clients.clients.push(new Client('https://example.com/api/p/1?amp=1'))
267+
await self.trigger('install')
268+
console.log(self.snapshot().caches)
269+
expect(cachePath).toHaveBeenCalled()
270+
})
271+
})
272+
273+
describe('matchRuntimePath', () => {
274+
it ('should return true for routes that are cacheable', () => {
275+
const matchRuntimePath = sw.__get__('matchRuntimePath');
276+
expect(matchRuntimePath({ url: url.parse('http://example.com/p/1') })).toEqual(false)
277+
expect(matchRuntimePath({ url: url.parse('https://example.com/_next/static/asset') })).toEqual(false)
278+
expect(matchRuntimePath({ url: url.parse('https://example.com/p/1.mp4') })).toEqual(false)
279+
expect(matchRuntimePath({ url: url.parse('https://example.com/p/1') })).toEqual(true)
280+
})
281+
})
282+
283+
describe('offlineResponse', () => {
284+
it ('should send back a standard response for API calls', async () => {
285+
const offlineResponse = sw.__get__('offlineResponse');
286+
const resp = await offlineResponse('v1', { url: url.parse('/api/p/1') })
287+
expect(JSON.parse(resp.body.parts[0])).toEqual({ page: 'Offline' })
288+
})
289+
290+
it ('should send back the app shell for non-API calls', async () => {
291+
const appShellPath = sw.__get__('appShellPath');
292+
const testCachedData = 'test-cache-data'
293+
const offlineResponse = sw.__get__('offlineResponse');
294+
const apiVersion = 'v1'
295+
const apiCacheName = sw.__get__('getAPICacheName')(apiVersion)
296+
const addToCache = sw.__get__('addToCache')
297+
const cache = await caches.open(apiCacheName)
298+
await addToCache(cache, appShellPath, testCachedData)
299+
const resp = await offlineResponse(apiVersion, { url: url.parse('/p/1') })
300+
expect(resp.body.parts[0]).toEqual(testCachedData)
301+
})
105302
})
106303
})

0 commit comments

Comments
 (0)