Skip to content
This repository has been archived by the owner on May 3, 2024. It is now read-only.

Commit

Permalink
feat(pwa/offline): html shell
Browse files Browse the repository at this point in the history
  • Loading branch information
Francois-Esquire authored Jun 19, 2020
1 parent e7676a8 commit e8a1dd2
Show file tree
Hide file tree
Showing 23 changed files with 628 additions and 35 deletions.
21 changes: 21 additions & 0 deletions __tests__/client/__snapshots__/initClient.spec.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,27 @@ Immutable.Map {
}
`;

exports[`initClient should use ReactDOM.render if renderMode is "render" 1`] = `
<Provider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"modules": Immutable.Map {},
"rebuildReducer": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(observable): [Function],
}
}
>
<Router
render={[Function]}
testProp="test"
/>
</Provider>
`;

exports[`initClient should use strict mode 1`] = `
<Provider
store={
Expand Down
29 changes: 29 additions & 0 deletions __tests__/client/initClient.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ jest.mock('../../src/client/prerender', () => {
jest.mock('react-dom', () => {
const reactDom = jest.requireActual('react-dom');
reactDom.hydrate = jest.fn();
reactDom.render = jest.fn();
return reactDom;
});

Expand Down Expand Up @@ -190,6 +191,34 @@ describe('initClient', () => {
expect(tree).toMatchSnapshot();
});

it('should use ReactDOM.render if renderMode is "render"', async () => {
expect.assertions(2);
const promiseResolveSpy = jest.spyOn(Promise, 'resolve');
const { render } = require('react-dom');

document.getElementById = jest.fn(() => ({ remove: jest.fn() }));

const { matchPromise } = require('@americanexpress/one-app-router');
matchPromise.mockImplementationOnce(() => Promise.resolve({
redirectLocation: null,
renderProps: { testProp: 'test' },
}));

const { loadPrerenderScripts } = require('../../src/client/prerender');
loadPrerenderScripts.mockReturnValueOnce(Promise.resolve());
promiseResolveSpy.mockRestore();

// eslint-disable-next-line no-underscore-dangle
global.__render_mode__ = 'render';

const initClient = require('../../src/client/initClient').default;

await initClient();

const tree = shallow(render.mock.calls[0][0]);
expect(tree).toMatchSnapshot();
});

it('should load pwa script', async () => {
expect.assertions(2);
document.getElementById = jest.fn(() => ({ remove: jest.fn() }));
Expand Down
41 changes: 37 additions & 4 deletions __tests__/client/service-worker/client.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* permissions and limitations under the License.
*/

import { on, register } from '@americanexpress/one-service-worker';
import { on, register, match } from '@americanexpress/one-service-worker';

import serviceWorkerClient from '../../../src/client/service-worker/client';

Expand All @@ -23,20 +23,53 @@ jest.mock('@americanexpress/one-service-worker', () => ({
messenger: jest.fn(),
on: jest.fn(),
register: jest.fn(() => Promise.resolve()),
// made synchronous to aid testing
match: jest.fn(() => ({ then: jest.fn((cb) => cb()) })),
createCacheName: jest.fn((str) => str),
}));

beforeEach(() => {
global.fetch = jest.fn();
jest.clearAllMocks();
});

describe('serviceWorkerClient', () => {
test('it calls register and listens for messages', async () => {
test('it calls register and listens for messages and registration', async () => {
expect.assertions(4);
const scriptUrl = '/_/pwa/sw.js';
const scope = '/';
await expect(serviceWorkerClient({ scriptUrl, scope })).resolves.toBeUndefined();
expect(on).toHaveBeenCalledTimes(1);

await expect(serviceWorkerClient({ scope, scriptUrl })).resolves.toBeUndefined();
expect(on).toHaveBeenCalledTimes(2);
expect(register).toHaveBeenCalledTimes(1);
expect(register).toHaveBeenCalledWith(scriptUrl, { scope });
});

test('preps the offline cache and fetches if missing', async () => {
expect.assertions(7);
const scriptUrl = '/_/pwa/sw.js';
const webManifestUrl = '/_/pwa/manifest.webmanifest';
const offlineUrl = '/_/pwa/shell';
const scope = '/';

await expect(serviceWorkerClient({
scope, scriptUrl, webManifestUrl, offlineUrl,
})).resolves.toBeUndefined();

// we should expect the cache items to be missing and
// should call fetch for both resources
const registerHandle = on.mock.calls[1][1];
expect(() => registerHandle()).not.toThrow();
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenCalledWith(webManifestUrl);
expect(global.fetch).toHaveBeenCalledWith(offlineUrl);

match.mockImplementationOnce(() => ({ then: jest.fn((cb) => cb({})) }));
match.mockImplementationOnce(() => ({ then: jest.fn((cb) => cb({})) }));

// we should expect the caching mechanism to trigger (via fetch) if
// the cache items are missing
expect(() => registerHandle()).not.toThrow();
expect(global.fetch).toHaveBeenCalledTimes(2);
});
});
176 changes: 176 additions & 0 deletions __tests__/client/service-worker/events/fetch.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/*
* Copyright 2020 American Express Travel Related Services Company, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

import {
expiration, cacheRouter, appShell, match,
} from '@americanexpress/one-service-worker';

import createFetchMiddleware from '../../../../src/client/service-worker/events/fetch';

jest.mock('@americanexpress/one-service-worker', () => {
const osw = jest.requireActual('@americanexpress/one-service-worker');
Object.keys(osw).forEach((key) => {
if (typeof osw[key] === 'function') jest.spyOn(osw, key);
});
return osw;
});

beforeEach(() => {
jest.clearAllMocks();
});

function waitFor(asyncTarget, getTarget = () => asyncTarget.mock.calls) {
return Promise.all(getTarget().reduce((array, next) => array.concat(next), []));
}

function createFetchEvent(url = '/index.html') {
const request = new Request(url);
const response = new Response('body', { url: request.url });
['json', 'text', 'clone'].forEach((method) => {
response[method] = response[method].bind(response);
jest.spyOn(response, method);
});
const event = new global.FetchEvent('fetch', {
request,
});
event.response = response;
['waitUntil', 'respondWith'].forEach((method) => {
event[method] = event[method].bind(event);
jest.spyOn(event, method);
});
event.waitForCompletion = async () => {
await waitFor(event.respondWith);
await waitFor(event.waitUntil);
};
return event;
}

function createFetchEventsChainForURLS(middleware, urls = [], initEvent) {
return urls.reduce(async (lastPromises, url, index) => {
const lastEvents = await lastPromises;
const event = createFetchEvent(url);
if (typeof initEvent === 'function') {
initEvent(event, index);
} else if (initEvent !== null) {
fetch.mockImplementationOnce(() => Promise.resolve(event.response));
}
middleware(event);
await event.waitForCompletion();
return Promise.resolve(lastEvents.concat(event));
}, Promise.resolve([]));
}

function createServiceWorkerEnvironment(target = global) {
const EventTarget = require('service-worker-mock/models/EventTarget');
const createServiceWorkerMocks = require('service-worker-mock');

Object.assign(
target,
createServiceWorkerMocks(),
Object.assign(new EventTarget(), {
addEventListener(type, listener) {
if (this.listeners.has(type)) {
this.listeners.get(type).add(listener);
} else {
this.listeners.set(type, new Set([listener]));
}
},
}),
{
oninstall: null,
onactivate: null,
onfetch: null,
}
);
const { match: nativeMatch, matchAll } = target.Cache.prototype;
// eslint-disable-next-line no-param-reassign
target.Cache.prototype.match = function matchCorrected(...args) {
return nativeMatch.call(this, ...args).then((result) => (result && result.clone()) || null);
};
// eslint-disable-next-line no-param-reassign
target.Cache.prototype.matchAll = function matchAllCorrected(...args) {
return matchAll
.call(this, ...args)
.then((results) => (results && results.map((result) => result.clone())) || []);
};
// eslint-disable-next-line no-param-reassign
target.fetch = jest.fn(() => Promise.resolve({
json: jest.fn(() => Promise.resolve()),
text: jest.fn(() => Promise.resolve()),
// eslint-disable-next-line no-restricted-globals
clone: jest.fn(new Response(null, { url: self.location })),
}));
}

describe('createFetchMiddleware', () => {
test('createFetchMiddleware exports a function and calls middleware', () => {
expect(createFetchMiddleware()).toBeInstanceOf(Function);
expect(appShell).toHaveBeenCalledTimes(1);
expect(cacheRouter).toHaveBeenCalledTimes(1);
expect(expiration).toHaveBeenCalledTimes(1);
});

describe('offline support', () => {
beforeAll(() => {
createServiceWorkerEnvironment();
});

test('caches the core offline responses when fetched', async () => {
expect.assertions(4);

const middleware = createFetchMiddleware();
await createFetchEventsChainForURLS(middleware, [
'/_/pwa/shell',
'/_/pwa/manifest.webmanifest',
]);

expect(fetch).toHaveBeenCalledTimes(2);
await expect(match(new Request('http://localhost/_/pwa/shell'))).resolves.toBeInstanceOf(Response);
await expect(match(new Request('http://localhost/_/pwa/manifest.webmanifest'))).resolves.toBeInstanceOf(Response);
expect(caches.snapshot()).toMatchObject({
'__sw/offline': {
'http://localhost/_/pwa/shell': expect.any(Response),
'http://localhost/_/pwa/manifest.webmanifest': expect.any(Response),
},
});
});

test('responds from cache when resources are offline', async () => {
expect.assertions(3);

Object.defineProperty(navigator, 'onLine', {
configurable: true,
writable: true,
value: false,
});

const middleware = createFetchMiddleware();
const events = await createFetchEventsChainForURLS(middleware, [
'/_/pwa/shell',
'/_/pwa/manifest.webmanifest',
], (event, index) => {
// eslint-disable-next-line no-param-reassign
if (index === 0) event.request.mode = 'navigate';
fetch.mockImplementationOnce(() => Promise.resolve(event.response));
});

expect(fetch).toHaveBeenCalledTimes(0);
events.forEach((event) => {
expect(event.respondWith).toHaveBeenCalledTimes(1);
});
});
});
});
8 changes: 3 additions & 5 deletions __tests__/client/service-worker/worker.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import createServiceWorkerMocks from 'service-worker-mock';
import {
createInstallMiddleware,
createActivateMiddleware,
createFetchMiddleware,
} from '../../../src/client/service-worker/events';
import { ERROR_MESSAGE_ID_KEY } from '../../../src/client/service-worker/constants';

Expand All @@ -45,18 +46,15 @@ describe('service worker script', () => {
});

test('calls "on" with lifecycle middleware', () => {
expect.assertions(3);

loadServiceWorker();

expect(on).toHaveBeenCalledTimes(2);
expect(on).toHaveBeenCalledTimes(3);
expect(on).toHaveBeenCalledWith('install', createInstallMiddleware());
expect(on).toHaveBeenCalledWith('activate', createActivateMiddleware());
expect(on).toHaveBeenCalledWith('fetch', createFetchMiddleware());
});

test('catches error during initialization, logs the error and unregisters the service worker', () => {
expect.assertions(5);

self.unregister = jest.fn();
const failureError = new Error('failure');
on.mockImplementationOnce(() => {
Expand Down
Loading

0 comments on commit e8a1dd2

Please sign in to comment.