Skip to content

Commit c16e49e

Browse files
zzmpleggechr
andauthored
feat: service worker with etag cache (Uniswap#3897)
* fix: always-fresh service worker cache * chore: clarify service-worker * fix: cache in CacheStorage * feat: set __isDocumentCached * add back in manifest precaching * add unit tests (incomplete) * test: simplify test env * test: add service-worker cypress test * test: service-worker document handler * fix: CachedDocument ctor * fix: Readable for ReadableStream in jest * build: clean up module loading * fix: rename commands->ethereum * build: simplify package.json deps * build: clean up cypress usage * build: clean up yarn.lock * build: record cypress runs * build: disable chromeWebSecurity in cypress tests * build: rm babel * build: disable sw in ci cypress * build: nits * build: update workbox version * chore: fix merge * test: cache * test: cypress-ify the before hook * test: clear sw before each test * fix: cy then * test: cypress shenanigans * style: lint * chore: rm todo * test: fail fast for service worker with dev builds * docs: update contributing to tests * fix: clean up tests after merge - Add fast fail in case of dev server, which lacks ServiceWorker * fix: inject ethereum * test: service worker * test: increase sw timeout * test: sw state * test: run cypress in chrome * feat: add on-demand caching to improve sw startup time * test: test dynamically * fix: simplify cached doc * fix: optional sw * fix: expose response on cached doc * fix: stub out sw req * fix: intercept Co-authored-by: Christine Legge <[email protected]>
1 parent 7e709e1 commit c16e49e

15 files changed

Lines changed: 493 additions & 81 deletions

File tree

.github/workflows/integration-tests.yaml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,8 @@ jobs:
4242
CI: false # disables lint checks when building
4343

4444
- run: yarn serve &
45-
env:
46-
CI: false # disables lint checks when building
4745

48-
- run: yarn cypress run --record
46+
- run: yarn cypress:run --record
4947
env:
5048
CYPRESS_INTEGRATION_TEST_PRIVATE_KEY: ${{ secrets.CYPRESS_INTEGRATION_TEST_PRIVATE_KEY }}
5149
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

CONTRIBUTING.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,24 @@ By default, this runs only unit tests that have been affected since the last com
4444
yarn test --watchAll
4545
```
4646

47-
## Running cypress integration tests
47+
## Running integration tests (cypress)
4848

4949
Integration tests require a server to be running. In order to see your changes quickly, run `start` in its own tab/window:
5050

5151
```
5252
yarn start
5353
```
5454

55-
Integration tests are run using `cypress`. When developing locally, use `cypress open` for an interactive UI, and to inspect the rendered page:
55+
Integration tests are run using `cypress`. When developing locally, use `cypress:open` for an interactive UI, and to inspect the rendered page:
5656

5757
```
58-
yarn cypress open
58+
yarn cypress:open
59+
```
60+
61+
To run _all_ cypress integration tests _from the command line_:
62+
63+
```
64+
yarn cypress:run
5965
```
6066

6167
## Engineering standards

cypress.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ export default defineConfig({
66
defaultCommandTimeout: 10000,
77
chromeWebSecurity: false,
88
e2e: {
9+
setupNodeEvents(on, config) {
10+
return {
11+
...config,
12+
// Only enable Chrome.
13+
// Electron (the default) has issues injecting window.ethereum before pageload, so it is not viable.
14+
browsers: config.browsers.filter(({ name }) => name === 'chrome'),
15+
}
16+
},
917
baseUrl: 'http://localhost:3000',
1018
specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
1119
},

cypress/e2e/service-worker.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import assert = require('assert')
2+
3+
describe('Service Worker', () => {
4+
before(() => {
5+
// Fail fast if there is no Service Worker on this build.
6+
cy.request({ url: '/service-worker.js', headers: { 'Service-Worker': 'script' } }).then((response) => {
7+
const isValid = isValidServiceWorker(response)
8+
if (!isValid) {
9+
throw new Error(
10+
'\n' +
11+
'Service Worker tests must be run on a production-like build\n' +
12+
'To test, build with `yarn build:e2e` and serve with `yarn serve`'
13+
)
14+
}
15+
})
16+
17+
function isValidServiceWorker(response: Cypress.Response<any>) {
18+
const contentType = response.headers['content-type']
19+
return !(response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1))
20+
}
21+
})
22+
23+
function unregister() {
24+
return cy.log('unregister service worker').then(async () => {
25+
const cacheKeys = await window.caches.keys()
26+
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
27+
if (cacheKey) {
28+
await window.caches.delete(cacheKey)
29+
}
30+
31+
const sw = await window.navigator.serviceWorker.getRegistration(Cypress.config().baseUrl ?? undefined)
32+
await sw?.unregister()
33+
})
34+
}
35+
before(unregister)
36+
after(unregister)
37+
38+
beforeEach(() => {
39+
cy.intercept({ hostname: 'www.google-analytics.com' }, (req) => {
40+
const body = req.body.toString()
41+
if (req.query['ep.event_category'] === 'Service Worker' || body.includes('Service%20Worker')) {
42+
if (req.query['en'] === 'Not Installed' || body.includes('Not%20Installed')) {
43+
req.alias = 'NotInstalled'
44+
} else if (req.query['en'] === 'Cache Hit' || body.includes('Cache%20Hit')) {
45+
req.alias = 'CacheHit'
46+
} else if (req.query['en'] === 'Cache Miss' || body.includes('Cache%20Miss')) {
47+
req.alias = 'CacheMiss'
48+
}
49+
}
50+
})
51+
})
52+
53+
it('installs a ServiceWorker', () => {
54+
cy.visit('/', { serviceWorker: true })
55+
.get('#swap-page')
56+
.wait('@NotInstalled', { timeout: 20000 })
57+
.window({ timeout: 20000 })
58+
.and((win) => {
59+
expect(win.navigator.serviceWorker.controller?.state).to.equal('activated')
60+
})
61+
})
62+
63+
it('records a cache hit', () => {
64+
cy.visit('/', { serviceWorker: true }).get('#swap-page').wait('@CacheHit', { timeout: 20000 })
65+
})
66+
67+
it('records a cache miss', () => {
68+
cy.then(async () => {
69+
const cacheKeys = await window.caches.keys()
70+
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
71+
assert(cacheKey)
72+
73+
const cache = await window.caches.open(cacheKey)
74+
const keys = await cache.keys()
75+
const key = keys.find((key) => key.url.match(/index/))
76+
assert(key)
77+
78+
await cache.put(key, new Response())
79+
})
80+
.visit('/', { serviceWorker: true })
81+
.get('#swap-page')
82+
.wait('@CacheMiss', { timeout: 20000 })
83+
})
84+
})

cypress/support/e2e.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,41 @@
66
// ***********************************************************
77

88
// Import commands.ts using ES2015 syntax:
9-
import './ethereum'
9+
import { injected } from './ethereum'
10+
import assert = require('assert')
11+
12+
declare global {
13+
// eslint-disable-next-line @typescript-eslint/no-namespace
14+
namespace Cypress {
15+
interface ApplicationWindow {
16+
ethereum: typeof injected
17+
}
18+
interface VisitOptions {
19+
serviceWorker?: true
20+
}
21+
}
22+
}
23+
24+
// sets up the injected provider to be a mock ethereum provider with the given mnemonic/index
25+
// eslint-disable-next-line no-undef
26+
Cypress.Commands.overwrite(
27+
'visit',
28+
(original, url: string | Partial<Cypress.VisitOptions>, options?: Partial<Cypress.VisitOptions>) => {
29+
assert(typeof url === 'string')
30+
31+
cy.intercept('/service-worker.js', options?.serviceWorker ? undefined : { statusCode: 404 }).then(() => {
32+
original({
33+
...options,
34+
url: (url.startsWith('/') && url.length > 2 && !url.startsWith('/#') ? `/#${url}` : url) + '?chain=rinkeby',
35+
onBeforeLoad(win) {
36+
options?.onBeforeLoad?.(win)
37+
win.localStorage.clear()
38+
win.ethereum = injected
39+
},
40+
})
41+
})
42+
}
43+
)
1044

1145
beforeEach(() => {
1246
// Infura security policies are based on Origin headers.

cypress/support/ethereum.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import { Eip1193Bridge } from '@ethersproject/experimental/lib/eip1193-bridge'
66
import { JsonRpcProvider } from '@ethersproject/providers'
77
import { Wallet } from '@ethersproject/wallet'
8-
import assert = require('assert')
98

109
// todo: figure out how env vars actually work in CI
1110
// const TEST_PRIVATE_KEY = Cypress.env('INTEGRATION_TEST_PRIVATE_KEY')
@@ -21,7 +20,7 @@ export const TEST_ADDRESS_NEVER_USE_SHORTENED = `${TEST_ADDRESS_NEVER_USE.substr
2120

2221
const provider = new JsonRpcProvider('https://rinkeby.infura.io/v3/4bf032f2d38a4ed6bb975b80d6340847', 4)
2322
const signer = new Wallet(TEST_PRIVATE_KEY, provider)
24-
const injected = new (class extends Eip1193Bridge {
23+
export const injected = new (class extends Eip1193Bridge {
2524
chainId = 4
2625

2726
async sendAsync(...args: any[]) {
@@ -73,21 +72,3 @@ const injected = new (class extends Eip1193Bridge {
7372
}
7473
}
7574
})(signer, provider)
76-
77-
// sets up the injected provider to be a mock ethereum provider with the given mnemonic/index
78-
// eslint-disable-next-line no-undef
79-
Cypress.Commands.overwrite(
80-
'visit',
81-
(original, url: string | Partial<Cypress.VisitOptions>, options?: Partial<Cypress.VisitOptions>) => {
82-
assert(typeof url === 'string')
83-
return original({
84-
...options,
85-
url: (url.startsWith('/') && url.length > 2 && !url.startsWith('/#') ? `/#${url}` : url) + '?chain=rinkeby',
86-
onBeforeLoad(win: Cypress.AUTWindow & { ethereum?: Eip1193Bridge }) {
87-
options?.onBeforeLoad?.(win)
88-
win.localStorage.clear()
89-
win.ethereum = injected
90-
},
91-
})
92-
}
93-
)

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"build": "react-scripts build",
1919
"serve": "serve build -l 3000",
2020
"test": "react-scripts test --coverage",
21-
"cypress": "cypress"
21+
"cypress:open": "cypress open --browser chrome --e2e",
22+
"cypress:run": "cypress run --browser chrome --e2e"
2223
},
2324
"jest": {
2425
"collectCoverageFrom": [
@@ -196,6 +197,7 @@
196197
"web3-react-walletlink-connector": "npm:@web3-react/walletlink-connector@^6.2.13",
197198
"wicg-inert": "^3.1.1",
198199
"workbox-core": "^6.1.0",
200+
"workbox-navigation-preload": "^6.1.0",
199201
"workbox-precaching": "^6.1.0",
200202
"workbox-routing": "^6.1.0"
201203
},

src/components/analytics/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ if (typeof GOOGLE_ANALYTICS_ID === 'string') {
5353
googleAnalytics.initialize('test', { gtagOptions: { debug_mode: true } })
5454
}
5555

56+
const installed = Boolean(window.navigator.serviceWorker?.controller)
57+
const hit = Boolean((window as any).__isDocumentCached)
58+
const action = installed ? (hit ? 'Cache hit' : 'Cache miss') : 'Not installed'
59+
sendEvent({ category: 'Service Worker', action, nonInteraction: true })
60+
5661
function reportWebVitals({ name, delta, id }: Metric) {
5762
sendTiming('Web Vitals', name, Math.round(name === 'CLS' ? delta * 1000 : delta), id)
5863
}

src/service-worker.ts

Lines changed: 1 addition & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1 @@
1-
/// <reference lib="webworker" />
2-
/* eslint-disable no-restricted-globals */
3-
4-
import { clientsClaim } from 'workbox-core'
5-
import { createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
6-
import { registerRoute } from 'workbox-routing'
7-
8-
declare const self: ServiceWorkerGlobalScope
9-
10-
clientsClaim()
11-
12-
// Precache the relevant assets generated by the build process.
13-
const manifest = self.__WB_MANIFEST.filter((entry) => {
14-
const url = typeof entry === 'string' ? entry : entry.url
15-
// If this is a language file, skip. They are compiled elsewhere.
16-
if (url.endsWith('.po')) {
17-
return false
18-
}
19-
20-
// If this isn't a var woff2 font, skip. Modern browsers only need var fonts.
21-
if (url.endsWith('.woff') || (url.endsWith('.woff2') && !url.includes('.var'))) {
22-
return false
23-
}
24-
25-
return true
26-
})
27-
precacheAndRoute(manifest)
28-
29-
// Set up App Shell-style routing, so that navigation requests are fulfilled
30-
// immediately with a local index.html shell. See
31-
// https://developers.google.com/web/fundamentals/architecture/app-shell
32-
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$')
33-
registerRoute(({ request, url }: { request: Request; url: URL }) => {
34-
// If this isn't app.uniswap.org, skip. IPFS gateways may not have domain
35-
// separation, so they cannot use App Shell-style routing.
36-
if (url.hostname !== 'app.uniswap.org') {
37-
return false
38-
}
39-
40-
// If this isn't a navigation, skip.
41-
if (request.mode !== 'navigate') {
42-
return false
43-
}
44-
45-
// If this looks like a URL for a resource, skip.
46-
if (url.pathname.match(fileExtensionRegexp)) {
47-
return false
48-
}
49-
50-
return true
51-
}, createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html'))
1+
import './serviceWorker'

0 commit comments

Comments
 (0)