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

Commit

Permalink
feat(sendHtml): allow custom error page (#281)
Browse files Browse the repository at this point in the history
  • Loading branch information
James Singleton authored Sep 15, 2020
1 parent d9e2d19 commit 73eb8a7
Show file tree
Hide file tree
Showing 8 changed files with 325 additions and 29 deletions.
26 changes: 26 additions & 0 deletions __tests__/integration/one-app.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,32 @@ describe('Tests that require Docker setup', () => {
});
});

describe('custom error page', () => {
const loadCustomErrorPageRoot = async () => {
await addModuleToModuleMap({
moduleName: 'frank-lloyd-root',
version: '0.0.2',
});
// wait for change to be picked up
await waitFor(5000);
};

afterAll(() => {
writeModuleMap(originalModuleMap);
});
describe('successful fetch of error page', () => {
beforeAll(loadCustomErrorPageRoot);
test('responds with a custom error page', async () => {
const response = await fetch(
`${appAtTestUrls.fetchUrl}/%c0.%c0./%c0.%c0./%c0.%c0./%c0.%c0./winnt/win.ini`,
defaultFetchOptions
);
const body = await response.text();
expect(body).toContain('Here is a custom error page though.');
});
});
});

describe('progressive web app', () => {
const scriptUrl = `${appAtTestUrls.fetchUrl}/_/pwa/service-worker.js`;
const webManifestUrl = `${appAtTestUrls.fetchUrl}/_/pwa/manifest.webmanifest`;
Expand Down
169 changes: 169 additions & 0 deletions __tests__/server/middleware/sendHtml.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import sendHtml, {
renderStaticErrorPage,
renderModuleScripts,
safeSend,
setErrorPage,
} from '../../../src/server/middleware/sendHtml';
// _client is a method to control the mock
// eslint-disable-next-line import/named
Expand All @@ -38,6 +39,15 @@ jest.mock('holocron', () => ({
return module;
},
}));
jest.mock('@americanexpress/fetch-enhancers', () => ({
createTimeoutFetch: jest.fn(
(timeout) => (next) => (url) => next(url)
.then((res) => {
res.timeout = timeout;
return res;
})
),
}));
jest.mock('../../../src/server/utils/stateConfig');
jest.mock('../../../src/server/utils/readJsonFile', () => (filePath) => {
switch (filePath) {
Expand Down Expand Up @@ -99,6 +109,7 @@ jest.mock('../../../src/universal/utils/transit', () => ({
jest.spyOn(console, 'info').mockImplementation(() => {});
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementationOnce(() => {});

describe('sendHtml', () => {
const appHtml = '<p>Why, hello!</p>';
Expand Down Expand Up @@ -777,6 +788,9 @@ describe('sendHtml', () => {
});

describe('renderStaticErrorPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('sends default static error page', () => {
renderStaticErrorPage(res);
expect(res.send).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -825,7 +839,162 @@ describe('sendHtml', () => {
expect(res.send.mock.calls[0][0]).not.toMatch('[object ');
expect(res.send.mock.calls[0][0]).not.toContain('undefined');
});
it('returns default error page if fetching custom error page url fails', async () => {
const errorPageUrl = 'https://example.com';
const fetchError = new Error('getaddrinfo ENOTFOUND');
global.fetch = jest.fn(() => Promise.reject(fetchError));

await setErrorPage(errorPageUrl);
renderStaticErrorPage(res);

expect(res.send).toHaveBeenCalledTimes(1);
expect(res.send.mock.calls[0][0]).toContain('<!DOCTYPE html>');
expect(res.send.mock.calls[0][0]).toContain('<meta name="application-name" content="one-app">');
});
it('uses the default error page if custom error page does not 200', async () => {
const errorPageUrl = 'https://example.com';
const statusCode = 500;

global.fetch = jest.fn(() => Promise.resolve({
headers: new global.Headers({
'Content-Type': 'text/html',
}),
status: statusCode,
}));

await setErrorPage(errorPageUrl);
renderStaticErrorPage(res);

const data = await global.fetch.mock.results[0].value;

expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith(errorPageUrl);
expect(await data.timeout).toBe(6000);
expect(res.send).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith('Failed to fetch custom error page with status:', statusCode);
expect(res.send.mock.calls[0][0]).toContain('<!DOCTYPE html>');
expect(res.send.mock.calls[0][0]).toContain('<meta name="application-name" content="one-app">');
expect(res.send.mock.calls[0][0]).toContain('Sorry, we are unable to load this page at this time. Please try again later.');
});
it('returns a custom error page if provided', async () => {
const errorPageUrl = 'https://example.com';
const mockResponse = `<!doctype html>
<html>
<head>
<title>Custom Error Page</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div>
<h1>Custom Error Page</h1>
<p>This is a custom error page.</p>
</div>
</body>
</html>`;

global.fetch = jest.fn(() => Promise.resolve({
text: () => Promise.resolve(mockResponse),
headers: new global.Headers({
'Content-Type': 'text/html',
}),
status: 200,
}));

await setErrorPage(errorPageUrl);
renderStaticErrorPage(res);

const data = await global.fetch.mock.results[0].value;

expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith(errorPageUrl);
expect(await data.text()).toBe(mockResponse);
expect(await data.timeout).toBe(6000);
expect(res.send).toHaveBeenCalledTimes(1);
expect(res.send.mock.calls[0][0]).toContain('<!doctype html>');
expect(res.send.mock.calls[0][0]).toContain('<h1>Custom Error Page</h1>');
});
});

describe('setErrorPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const errorPageUrl = 'https://example.com';
const mockResponse = `<!doctype html>
<html>
<head>
<title>Custom Error Page</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div>
<h1>Custom Error Page</h1>
<p>This is a custom error page.</p>
</div>
</body>
</html>`;
it('fetches errorPageUrl', async () => {
global.fetch = jest.fn(() => Promise.resolve({
text: () => Promise.resolve(mockResponse),
headers: new global.Headers({
'Content-Type': 'text/html',
}),
status: 200,
}));

setErrorPage(errorPageUrl);

const data = await global.fetch.mock.results[0].value;

expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith(errorPageUrl);
expect(await data.text()).toBe(mockResponse);
expect(await data.timeout).toBe(6000);
});

it('warns if content-type is not text/html', async () => {
global.fetch = jest.fn(() => Promise.resolve({
text: () => Promise.resolve(mockResponse),
headers: new global.Headers({
'Content-Type': 'text/plain',
}),
status: 200,
}));

await setErrorPage(errorPageUrl);
expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith('[appConfig/errorPageUrl] Content-Type was not of type text/html and may not render correctly');
});

it('warns if url cannot be fetched', async () => {
const fetchError = new Error('getaddrinfo ENOTFOUND');
global.fetch = jest.fn(() => Promise.reject(fetchError));

await setErrorPage(errorPageUrl);
expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith('Could not fetch the URL', fetchError);
});

it('warns if content-length is greater than 244Kb', async () => {
global.fetch = jest.fn(() => Promise.resolve({
text: () => Promise.resolve(mockResponse),
headers: new global.Headers({
'Content-Type': 'text/html',
'Content-Length': 750000,
}),
status: 200,
}));

await setErrorPage(errorPageUrl);
expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith('[appConfig/errorPageUrl] Content-Length is over 244Kb and may have an impact on performance');
});
});

describe('safeSend', () => {
it('should res.send if no headers were sent', () => {
const fakeRes = {
Expand Down
19 changes: 19 additions & 0 deletions __tests__/server/utils/onModuleLoad.spec.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { setConfigureRequestLog } from '../../../src/server/utils/logging/server
import { setCreateSsrFetch } from '../../../src/server/utils/createSsrFetch';
import { getEventLoopDelayThreshold } from '../../../src/server/utils/createCircuitBreaker';
import { configurePWA } from '../../../src/server/middleware/pwa';
import { setErrorPage } from '../../../src/server/middleware/sendHtml';

jest.mock('../../../src/server/utils/stateConfig', () => ({
setStateConfig: jest.fn(),
Expand All @@ -52,6 +53,9 @@ jest.mock('../../../src/server/utils/safeRequest', () => ({
jest.mock('../../../src/server/middleware/pwa', () => ({
configurePWA: jest.fn(),
}));
jest.mock('../../../src/server/middleware/sendHtml.js', () => ({
setErrorPage: jest.fn(),
}));

const RootModule = () => <h1>Hello, world</h1>;
const csp = "default: 'none'";
Expand Down Expand Up @@ -326,6 +330,21 @@ describe('onModuleLoad', () => {
expect(configurePWA).toHaveBeenCalledWith(pwa);
});

it('calls setErrorPage with error page URL', () => {
const errorPageUrl = 'https://example.com';
onModuleLoad({
module: {
[CONFIGURATION_KEY]: {
csp,
errorPageUrl,
},
[META_DATA_KEY]: { version: '1.0.14' },
},
moduleName: 'some-root',
});
expect(setErrorPage).toHaveBeenCalledWith(errorPageUrl);
});

it('sets the event loop lag threshold from the root module', () => {
const eventLoopDelayThreshold = 50;
expect(getEventLoopDelayThreshold()).not.toBe(eventLoopDelayThreshold);
Expand Down
21 changes: 21 additions & 0 deletions docs/api/modules/App-Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ if (!global.BROWSER) {
pwa,
createSsrFetch,
eventLoopDelayThreshold,
errorPageUrl,
/* Child Module Specific */
validateStateConfig,
requiredSafeRequestRestrictedAttributes,
Expand Down Expand Up @@ -65,6 +66,7 @@ export default MyModule;
- [`extendSafeRequestRestrictedAttributes`](#extendsaferequestrestrictedattributes)
- [`createSsrFetch`](#createssrfetch)
- [`eventLoopDelayThreshold`](#eventloopdelaythreshold)
- [`errorPageUrl`](#errorpageurl)
- [`validateStateConfig`](#validatestateconfig)
- [`requiredSafeRequestRestrictedAttributes`](#requiredsaferequestrestrictedattributes)

Expand Down Expand Up @@ -454,6 +456,25 @@ The `eventLoopDelayThreshold` directive accepts a number representing the thresh
* [Frank Lloyd Root's `appConfig`](../../../prod-sample/sample-modules/frank-lloyd-root/0.0.0/src/config.js)
* Library: [Opossum](https://nodeshift.dev/opossum/)

## `errorPageUrl`

**Module Type**

- ✅ Root Module
- 🚫 Child Module

**Shape**

```js
if (!global.BROWSER) {
Module.appConfig = {
errorPageUrl: String,
};
}
```

The `errorPageUrl` directive is useful for supplying a URL to a custom error page. This URL should return a `Content-Type` of `text/html` and a `Content-Length` of less than `244000`. The URL will get called when your root module is loaded and is rendered if and when an error occurs. It is recommended that you keep the custom error page as small as possible.

## `validateStateConfig`
**Module Type**
* 🚫 Root Module
Expand Down
22 changes: 22 additions & 0 deletions prod-sample/assets/error.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<title>One App - Custom Error Page</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="application-name" content="one-app">
</head>
<body style="background-color: #F0F0F0">
<div id="root">
<div>
<div style="width: 70%; background-color: white; margin: 4% auto;">
<h2 style="display: flex; justify-content: center; padding: 40px 15px 0px;">Custom Loading Error</h2>
<p style="display: flex; justify-content: center; padding: 10px 15px 40px;">
Sorry, we are unable to load this page at this time. Here is a custom error page though.
</p>
</div>
</div>
</div>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,5 @@ export default {
},
},
},
errorPageUrl: 'https://sample-cdn.frank/error.html',
};
Loading

0 comments on commit 73eb8a7

Please sign in to comment.