@salesforce/pwa-kit-react-sdk 3.0.0-preview.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/LICENSE +14 -0
- package/README.md +37 -0
- package/package.json +82 -0
- package/scripts/file-utils.js +80 -0
- package/scripts/file-utils.test.js +189 -0
- package/scripts/setup-jsdom.js +20 -0
- package/scripts/version.js +22 -0
- package/ssr/browser/main.js +122 -0
- package/ssr/browser/main.test.js +54 -0
- package/ssr/server/react-rendering.js +405 -0
- package/ssr/server/react-rendering.test.js +708 -0
- package/ssr/universal/compatibility.js +31 -0
- package/ssr/universal/components/_app/index.js +35 -0
- package/ssr/universal/components/_app/index.test.js +20 -0
- package/ssr/universal/components/_app-config/index.js +88 -0
- package/ssr/universal/components/_app-config/index.test.js +21 -0
- package/ssr/universal/components/_document/index.js +93 -0
- package/ssr/universal/components/_document/index.test.js +58 -0
- package/ssr/universal/components/_error/index.js +56 -0
- package/ssr/universal/components/_error/index.test.js +28 -0
- package/ssr/universal/components/app-error-boundary/index.js +115 -0
- package/ssr/universal/components/app-error-boundary/index.test.js +109 -0
- package/ssr/universal/components/fetch-strategy/index.js +42 -0
- package/ssr/universal/components/route-component/index.js +409 -0
- package/ssr/universal/components/route-component/index.test.js +375 -0
- package/ssr/universal/components/switch/index.js +63 -0
- package/ssr/universal/components/throw-404/index.js +37 -0
- package/ssr/universal/components/throw-404/index.test.js +26 -0
- package/ssr/universal/components/with-correlation-id/index.js +36 -0
- package/ssr/universal/components/with-legacy-get-props/index.js +86 -0
- package/ssr/universal/components/with-legacy-get-props/index.test.js +35 -0
- package/ssr/universal/components/with-react-query/index.js +103 -0
- package/ssr/universal/components/with-react-query/index.test.js +44 -0
- package/ssr/universal/contexts/index.js +71 -0
- package/ssr/universal/contexts/index.test.js +101 -0
- package/ssr/universal/errors.js +34 -0
- package/ssr/universal/errors.test.js +20 -0
- package/ssr/universal/events.js +40 -0
- package/ssr/universal/events.test.js +39 -0
- package/ssr/universal/hooks/index.js +52 -0
- package/ssr/universal/routes.js +16 -0
- package/ssr/universal/utils.client.test.js +46 -0
- package/ssr/universal/utils.js +60 -0
- package/ssr/universal/utils.server.test.js +24 -0
- package/utils/assets.js +120 -0
- package/utils/assets.test.js +106 -0
- package/utils/url.js +39 -0
- package/utils/url.test.js +47 -0
- package/utils/uuidv4.client.js +21 -0
- package/utils/uuidv4.client.test.js +27 -0
- package/utils/warnings.js +81 -0
- package/utils/warnings.test.js +48 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _reactRendering = require("./react-rendering");
|
|
4
|
+
var _crypto = require("crypto");
|
|
5
|
+
var _buildRemoteServer = require("@salesforce/pwa-kit-runtime/ssr/server/build-remote-server");
|
|
6
|
+
var _supertest = _interopRequireDefault(require("supertest"));
|
|
7
|
+
var _nodeHtmlParser = require("node-html-parser");
|
|
8
|
+
var _path = _interopRequireDefault(require("path"));
|
|
9
|
+
var _ssrServer = require("@salesforce/pwa-kit-runtime/utils/ssr-server");
|
|
10
|
+
var _compatibility = require("../universal/compatibility");
|
|
11
|
+
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
|
|
12
|
+
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
|
|
13
|
+
function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }
|
|
14
|
+
function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
|
|
15
|
+
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
|
|
16
|
+
function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
|
|
17
|
+
function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
|
|
18
|
+
function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /**
|
|
19
|
+
* @jest-environment node
|
|
20
|
+
*/ /*
|
|
21
|
+
* Copyright (c) 2023, Salesforce, Inc.
|
|
22
|
+
* All rights reserved.
|
|
23
|
+
* SPDX-License-Identifier: BSD-3-Clause
|
|
24
|
+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
25
|
+
*/ // The @jest-environment comment block *MUST* be the first line of the file for the tests to pass.
|
|
26
|
+
// That conflicts with the monorepo header rule, so we must disable the rule!
|
|
27
|
+
/* eslint-disable header/header */
|
|
28
|
+
const opts = (overrides = {}) => {
|
|
29
|
+
const fixtures = _path.default.join(__dirname, '..', '..', 'ssr', 'server', 'test_fixtures');
|
|
30
|
+
const defaults = {
|
|
31
|
+
buildDir: fixtures,
|
|
32
|
+
mobify: {
|
|
33
|
+
ssrEnabled: true,
|
|
34
|
+
ssrParameters: {
|
|
35
|
+
proxyConfigs: []
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
protocol: 'http'
|
|
39
|
+
};
|
|
40
|
+
return _objectSpread(_objectSpread({}, defaults), overrides);
|
|
41
|
+
};
|
|
42
|
+
jest.mock('../universal/compatibility', () => {
|
|
43
|
+
const AppConfig = jest.requireActual('../universal/components/_app-config').default;
|
|
44
|
+
const {
|
|
45
|
+
withReactQuery
|
|
46
|
+
} = jest.requireActual('../universal/components/with-react-query');
|
|
47
|
+
const {
|
|
48
|
+
withLegacyGetProps
|
|
49
|
+
} = jest.requireActual('../universal/components/with-legacy-get-props');
|
|
50
|
+
const appConfig = withReactQuery(withLegacyGetProps(AppConfig));
|
|
51
|
+
return {
|
|
52
|
+
getAppConfig: () => appConfig
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
jest.mock('../universal/routes', () => {
|
|
56
|
+
// TODO: Can these requires be converted to top-level imports?
|
|
57
|
+
/* eslint-disable @typescript-eslint/no-var-requires */
|
|
58
|
+
const React = require('react');
|
|
59
|
+
const PropTypes = require('prop-types');
|
|
60
|
+
const errors = require('../universal/errors');
|
|
61
|
+
const {
|
|
62
|
+
Redirect
|
|
63
|
+
} = require('react-router-dom');
|
|
64
|
+
const {
|
|
65
|
+
Helmet
|
|
66
|
+
} = require('react-helmet');
|
|
67
|
+
const {
|
|
68
|
+
useQuery
|
|
69
|
+
} = require('@tanstack/react-query');
|
|
70
|
+
const {
|
|
71
|
+
useServerContext
|
|
72
|
+
} = require('../universal/hooks');
|
|
73
|
+
/* eslint-enable @typescript-eslint/no-var-requires */
|
|
74
|
+
|
|
75
|
+
// Test utility to exercise paths that work with @loadable/component.
|
|
76
|
+
const fakeLoadable = Wrapped => {
|
|
77
|
+
class FakeLoadable extends React.Component {
|
|
78
|
+
static preload() {
|
|
79
|
+
return Promise.resolve(Wrapped);
|
|
80
|
+
}
|
|
81
|
+
render() {
|
|
82
|
+
return /*#__PURE__*/React.createElement(Wrapped, null);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return FakeLoadable;
|
|
86
|
+
};
|
|
87
|
+
class PWAPage extends React.Component {
|
|
88
|
+
static getProps() {
|
|
89
|
+
return Promise.resolve();
|
|
90
|
+
}
|
|
91
|
+
static getTemplateName() {
|
|
92
|
+
return 'templateName';
|
|
93
|
+
}
|
|
94
|
+
render() {
|
|
95
|
+
return /*#__PURE__*/React.createElement("div", null, "This is a PWA");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
class UnknownErrorPage extends React.Component {
|
|
99
|
+
static getProps() {
|
|
100
|
+
throw new Error('This is an error');
|
|
101
|
+
}
|
|
102
|
+
render() {
|
|
103
|
+
return /*#__PURE__*/React.createElement("div", null, "This should not be rendered");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
class ThrowStringErrorPage extends React.Component {
|
|
107
|
+
static getProps() {
|
|
108
|
+
throw 'This is an error';
|
|
109
|
+
}
|
|
110
|
+
render() {
|
|
111
|
+
return /*#__PURE__*/React.createElement("div", null, "This should not be rendered");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
class KnownErrorPage extends React.Component {
|
|
115
|
+
static getProps() {
|
|
116
|
+
throw new errors.HTTPError(503, 'Service not available');
|
|
117
|
+
}
|
|
118
|
+
render() {
|
|
119
|
+
return /*#__PURE__*/React.createElement("div", null, "This should not be rendered");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
class GetProps404ErrorPage extends React.Component {
|
|
123
|
+
static getProps() {
|
|
124
|
+
throw new errors.HTTPNotFound('Not found');
|
|
125
|
+
}
|
|
126
|
+
render() {
|
|
127
|
+
return /*#__PURE__*/React.createElement("div", null, "This should not be rendered");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
class InitSetsStatusPage extends React.Component {
|
|
131
|
+
static getProps({
|
|
132
|
+
res
|
|
133
|
+
}) {
|
|
134
|
+
res.status(418);
|
|
135
|
+
return Promise.resolve();
|
|
136
|
+
}
|
|
137
|
+
render() {
|
|
138
|
+
return /*#__PURE__*/React.createElement("div", null, "418 - I am a Teapot");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
class GetPropsRejectsWithEmptyString extends React.Component {
|
|
142
|
+
static getProps() {
|
|
143
|
+
return Promise.reject('');
|
|
144
|
+
}
|
|
145
|
+
render() {
|
|
146
|
+
return /*#__PURE__*/React.createElement("div", null, "This should not be rendered");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
class RenderThrowsError extends React.Component {
|
|
150
|
+
static getProps() {
|
|
151
|
+
return Promise.resolve();
|
|
152
|
+
}
|
|
153
|
+
// eslint-disable-next-line react/require-render-return
|
|
154
|
+
render() {
|
|
155
|
+
throw new Error('This is an error rendering');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
class GetPropsReturnsObject extends React.Component {
|
|
159
|
+
static getProps() {
|
|
160
|
+
return {
|
|
161
|
+
prop: 'prop-value'
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
render() {
|
|
165
|
+
return /*#__PURE__*/React.createElement("div", null, this.props.prop);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
class RedirectPage extends React.Component {
|
|
169
|
+
static getProps() {
|
|
170
|
+
return Promise.resolve();
|
|
171
|
+
}
|
|
172
|
+
render() {
|
|
173
|
+
return /*#__PURE__*/React.createElement(Redirect, {
|
|
174
|
+
to: "/elsewhere/"
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
class HelmetPage extends React.Component {
|
|
179
|
+
static getProps() {
|
|
180
|
+
return Promise.resolve();
|
|
181
|
+
}
|
|
182
|
+
static getTemplateName() {
|
|
183
|
+
return 'templateName';
|
|
184
|
+
}
|
|
185
|
+
render() {
|
|
186
|
+
return /*#__PURE__*/React.createElement(Helmet, null, /*#__PURE__*/React.createElement("html", {
|
|
187
|
+
lang: "helmet-html-attribute"
|
|
188
|
+
}), /*#__PURE__*/React.createElement("body", {
|
|
189
|
+
className: "helmet-body-attribute"
|
|
190
|
+
}), /*#__PURE__*/React.createElement("title", null, "Helmet title"), /*#__PURE__*/React.createElement("base", {
|
|
191
|
+
target: "_blank",
|
|
192
|
+
href: "http://mysite.com/"
|
|
193
|
+
}), /*#__PURE__*/React.createElement("meta", {
|
|
194
|
+
name: "helmet-meta-1",
|
|
195
|
+
content: "helmet-meta-1"
|
|
196
|
+
}), /*#__PURE__*/React.createElement("meta", {
|
|
197
|
+
property: "helmet-meta-2",
|
|
198
|
+
content: "helmet-meta-2"
|
|
199
|
+
}), /*#__PURE__*/React.createElement("link", {
|
|
200
|
+
rel: "helmet-link-1",
|
|
201
|
+
href: "http://mysite.com/example"
|
|
202
|
+
}), /*#__PURE__*/React.createElement("link", {
|
|
203
|
+
rel: "helmet-link-2",
|
|
204
|
+
href: "http://mysite.com/img/apple-touch-icon-57x57.png"
|
|
205
|
+
}), /*#__PURE__*/React.createElement("script", {
|
|
206
|
+
src: "http://include.com/pathtojs.js",
|
|
207
|
+
type: "text/javascript"
|
|
208
|
+
}), /*#__PURE__*/React.createElement("script", {
|
|
209
|
+
type: "application/ld+json"
|
|
210
|
+
}, `
|
|
211
|
+
{
|
|
212
|
+
"@context": "http://schema.org"
|
|
213
|
+
}
|
|
214
|
+
`), /*#__PURE__*/React.createElement("noscript", null, `
|
|
215
|
+
<link rel="stylesheet" type="text/css" href="foo.css" />
|
|
216
|
+
`), /*#__PURE__*/React.createElement("style", {
|
|
217
|
+
type: "text/css"
|
|
218
|
+
}, `
|
|
219
|
+
body {
|
|
220
|
+
background-color: blue;
|
|
221
|
+
}
|
|
222
|
+
`));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
class XSSPage extends React.Component {
|
|
226
|
+
static getProps() {
|
|
227
|
+
return {
|
|
228
|
+
prop: '<script>alert("hey! give me your money")</script>'
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
render() {
|
|
232
|
+
return /*#__PURE__*/React.createElement("div", null, "XSS attack");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const UseQueryResolvesObject = () => {
|
|
236
|
+
const {
|
|
237
|
+
data,
|
|
238
|
+
isLoading
|
|
239
|
+
} = useQuery(['use-query-resolves-object'], /*#__PURE__*/_asyncToGenerator(function* () {
|
|
240
|
+
return {
|
|
241
|
+
prop: 'prop-value'
|
|
242
|
+
};
|
|
243
|
+
}));
|
|
244
|
+
return /*#__PURE__*/React.createElement("div", null, isLoading ? 'loading' : data.prop);
|
|
245
|
+
};
|
|
246
|
+
const DisabledUseQueryIsntResolved = () => {
|
|
247
|
+
const {
|
|
248
|
+
data,
|
|
249
|
+
isLoading
|
|
250
|
+
} = useQuery(['use-query-resolves-object'], /*#__PURE__*/_asyncToGenerator(function* () {
|
|
251
|
+
return {
|
|
252
|
+
prop: 'prop-value'
|
|
253
|
+
};
|
|
254
|
+
}), {
|
|
255
|
+
enabled: false
|
|
256
|
+
});
|
|
257
|
+
return /*#__PURE__*/React.createElement("div", null, isLoading ? 'loading' : data.prop);
|
|
258
|
+
};
|
|
259
|
+
const GetServerContext = () => {
|
|
260
|
+
const {
|
|
261
|
+
res
|
|
262
|
+
} = useServerContext();
|
|
263
|
+
if (res) {
|
|
264
|
+
console.log('--- isServerSide');
|
|
265
|
+
res.status(404);
|
|
266
|
+
}
|
|
267
|
+
return /*#__PURE__*/React.createElement("div", null);
|
|
268
|
+
};
|
|
269
|
+
GetPropsReturnsObject.propTypes = {
|
|
270
|
+
prop: PropTypes.node
|
|
271
|
+
};
|
|
272
|
+
return {
|
|
273
|
+
__esModule: true,
|
|
274
|
+
default: [{
|
|
275
|
+
path: '/pwa/',
|
|
276
|
+
component: fakeLoadable(PWAPage)
|
|
277
|
+
}, {
|
|
278
|
+
path: '/unknown-error/',
|
|
279
|
+
component: UnknownErrorPage
|
|
280
|
+
}, {
|
|
281
|
+
path: '/throw-string/',
|
|
282
|
+
component: ThrowStringErrorPage
|
|
283
|
+
}, {
|
|
284
|
+
path: '/known-error/',
|
|
285
|
+
component: KnownErrorPage
|
|
286
|
+
}, {
|
|
287
|
+
path: '/404-in-get-props-error/',
|
|
288
|
+
component: GetProps404ErrorPage
|
|
289
|
+
}, {
|
|
290
|
+
path: '/redirect/',
|
|
291
|
+
component: RedirectPage
|
|
292
|
+
}, {
|
|
293
|
+
path: '/init-sets-status/',
|
|
294
|
+
component: InitSetsStatusPage
|
|
295
|
+
}, {
|
|
296
|
+
path: '/get-props-returns-object/',
|
|
297
|
+
component: GetPropsReturnsObject
|
|
298
|
+
}, {
|
|
299
|
+
path: '/get-props-rejects-with-empty-string/',
|
|
300
|
+
component: GetPropsRejectsWithEmptyString
|
|
301
|
+
}, {
|
|
302
|
+
path: '/render-throws-error/',
|
|
303
|
+
component: RenderThrowsError
|
|
304
|
+
}, {
|
|
305
|
+
path: '/render-helmet/',
|
|
306
|
+
component: fakeLoadable(HelmetPage)
|
|
307
|
+
}, {
|
|
308
|
+
path: '/xss/',
|
|
309
|
+
component: XSSPage
|
|
310
|
+
}, {
|
|
311
|
+
path: '/use-query-resolves-object/',
|
|
312
|
+
component: UseQueryResolvesObject
|
|
313
|
+
}, {
|
|
314
|
+
path: '/disabled-use-query-isnt-resolved/',
|
|
315
|
+
component: DisabledUseQueryIsntResolved
|
|
316
|
+
}, {
|
|
317
|
+
path: '/server-context',
|
|
318
|
+
component: GetServerContext
|
|
319
|
+
}]
|
|
320
|
+
};
|
|
321
|
+
});
|
|
322
|
+
jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-server', () => {
|
|
323
|
+
const actual = jest.requireActual('@salesforce/pwa-kit-runtime/utils/ssr-server');
|
|
324
|
+
return _objectSpread(_objectSpread({}, actual), {}, {
|
|
325
|
+
isRemote: jest.fn()
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
jest.mock('@loadable/server', () => {
|
|
329
|
+
const lodableServer = jest.requireActual('@loadable/server');
|
|
330
|
+
return _objectSpread(_objectSpread({}, lodableServer), {}, {
|
|
331
|
+
// Tests aren't being run through webpack, therefore no chunks or `loadable-stats.json`
|
|
332
|
+
// file is being created. ChunkExtractor causes a file read exception. For this
|
|
333
|
+
// reason, we mock the implementation to do nothing.
|
|
334
|
+
ChunkExtractor: function () {
|
|
335
|
+
return {
|
|
336
|
+
collectChunks: jest.fn().mockImplementation(x => x),
|
|
337
|
+
getScriptElements: jest.fn().mockReturnValue([])
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
jest.mock('@salesforce/pwa-kit-runtime/ssr/server/build-remote-server', () => {
|
|
343
|
+
const actual = jest.requireActual('@salesforce/pwa-kit-runtime/ssr/server/build-remote-server');
|
|
344
|
+
return _objectSpread(_objectSpread({}, actual), {}, {
|
|
345
|
+
RemoteServerFactory: _objectSpread(_objectSpread({}, actual.RemoteServerFactory), {}, {
|
|
346
|
+
_setRequestId: jest.fn()
|
|
347
|
+
})
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
describe('The Node SSR Environment', () => {
|
|
351
|
+
const OLD_ENV = process.env;
|
|
352
|
+
beforeAll(() => {
|
|
353
|
+
// These values are not allowed to be `undefined` when `isRemote` returns true. So we mock them.
|
|
354
|
+
process.env.BUNDLE_ID = '1';
|
|
355
|
+
process.env.DEPLOY_TARGET = 'production';
|
|
356
|
+
process.env.EXTERNAL_DOMAIN_NAME = 'test.com';
|
|
357
|
+
process.env.MOBIFY_PROPERTY_ID = 'test';
|
|
358
|
+
});
|
|
359
|
+
afterAll(() => {
|
|
360
|
+
process.env = OLD_ENV; // Restore old environment
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
afterEach(() => {
|
|
364
|
+
jest.restoreAllMocks();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Scripts are "safe" if they are external, not executable or on our allow list of
|
|
369
|
+
* static, inline scripts.
|
|
370
|
+
*/
|
|
371
|
+
const scriptsAreSafe = doc => {
|
|
372
|
+
const scripts = Array.from(doc.querySelectorAll('script'));
|
|
373
|
+
expect(scripts.length > 0).toBe(true);
|
|
374
|
+
return scripts.every(script => {
|
|
375
|
+
const external = script.hasAttribute('src');
|
|
376
|
+
const executable = !script.hasAttribute('type') || script.getAttribute('type') === 'application/javascript';
|
|
377
|
+
const allowlisted = _reactRendering.ALLOWLISTED_INLINE_SCRIPTS.indexOf(script.innerHTML) >= 0;
|
|
378
|
+
return external || !executable || allowlisted;
|
|
379
|
+
});
|
|
380
|
+
};
|
|
381
|
+
const dataFromHTML = doc => JSON.parse(doc.querySelector('#mobify-data').innerHTML);
|
|
382
|
+
const cases = [{
|
|
383
|
+
description: `rendering PWA's for desktop`,
|
|
384
|
+
req: {
|
|
385
|
+
url: '/pwa/'
|
|
386
|
+
},
|
|
387
|
+
assertions: res => {
|
|
388
|
+
expect(res.statusCode).toBe(200);
|
|
389
|
+
const html = res.text;
|
|
390
|
+
console.error(html);
|
|
391
|
+
const doc = (0, _nodeHtmlParser.parse)(html);
|
|
392
|
+
const include = ['<div>This is a PWA</div>'];
|
|
393
|
+
const dataScript = doc.querySelectorAll('script[id=mobify-data]')[0];
|
|
394
|
+
expect(dataScript.innerHTML.split(/\r\n|\r|\n/)).toHaveLength(1);
|
|
395
|
+
include.forEach(s => expect(html).toEqual(expect.stringContaining(s)));
|
|
396
|
+
expect(scriptsAreSafe(doc)).toBe(true);
|
|
397
|
+
}
|
|
398
|
+
}, {
|
|
399
|
+
description: `rendering PWA's for tablet`,
|
|
400
|
+
req: {
|
|
401
|
+
url: '/pwa/'
|
|
402
|
+
},
|
|
403
|
+
assertions: res => {
|
|
404
|
+
expect(res.statusCode).toBe(200);
|
|
405
|
+
const html = res.text;
|
|
406
|
+
const doc = (0, _nodeHtmlParser.parse)(html);
|
|
407
|
+
const include = ['<div>This is a PWA</div>'];
|
|
408
|
+
include.forEach(s => expect(html).toEqual(expect.stringContaining(s)));
|
|
409
|
+
expect(scriptsAreSafe(doc)).toBe(true);
|
|
410
|
+
}
|
|
411
|
+
}, {
|
|
412
|
+
description: `rendering PWA's for mobile`,
|
|
413
|
+
req: {
|
|
414
|
+
url: '/pwa/'
|
|
415
|
+
},
|
|
416
|
+
assertions: res => {
|
|
417
|
+
expect(res.statusCode).toBe(200);
|
|
418
|
+
const html = res.text;
|
|
419
|
+
const doc = (0, _nodeHtmlParser.parse)(html);
|
|
420
|
+
const include = ['<div>This is a PWA</div>'];
|
|
421
|
+
include.forEach(s => expect(html).toEqual(expect.stringContaining(s)));
|
|
422
|
+
expect(scriptsAreSafe(doc)).toBe(true);
|
|
423
|
+
}
|
|
424
|
+
}, {
|
|
425
|
+
description: `rendering PWA's in "mobify-server-only" mode should not execute scripts on the client`,
|
|
426
|
+
req: {
|
|
427
|
+
url: '/pwa/',
|
|
428
|
+
query: {
|
|
429
|
+
mobify_server_only: '1'
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
assertions: res => {
|
|
433
|
+
const html = res.text;
|
|
434
|
+
const doc = (0, _nodeHtmlParser.parse)(html);
|
|
435
|
+
const include = ['<div>This is a PWA</div>'];
|
|
436
|
+
include.forEach(s => expect(html).toEqual(expect.stringContaining(s)));
|
|
437
|
+
doc.querySelectorAll('script').forEach(script => {
|
|
438
|
+
// application/json prevents execution!
|
|
439
|
+
expect(script.getAttribute('type')).toBe('application/json');
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}, {
|
|
443
|
+
description: `rendering PWA's in "__server-only" mode should not execute scripts on the client`,
|
|
444
|
+
req: {
|
|
445
|
+
url: '/pwa/',
|
|
446
|
+
query: {
|
|
447
|
+
__server_only: '1'
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
assertions: res => {
|
|
451
|
+
const html = res.text;
|
|
452
|
+
const doc = (0, _nodeHtmlParser.parse)(html);
|
|
453
|
+
const include = ['<div>This is a PWA</div>'];
|
|
454
|
+
include.forEach(s => expect(html).toEqual(expect.stringContaining(s)));
|
|
455
|
+
doc.querySelectorAll('script').forEach(script => {
|
|
456
|
+
// application/json prevents execution!
|
|
457
|
+
expect(script.getAttribute('type')).toBe('application/json');
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}, {
|
|
461
|
+
description: `rendering PWA's with legacy "mobify_pretty" mode should print stylized global state`,
|
|
462
|
+
req: {
|
|
463
|
+
url: '/pwa/',
|
|
464
|
+
query: {
|
|
465
|
+
mobify_pretty: '1'
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
assertions: res => {
|
|
469
|
+
const html = res.text;
|
|
470
|
+
const doc = (0, _nodeHtmlParser.parse)(html);
|
|
471
|
+
const include = ['<div>This is a PWA</div>'];
|
|
472
|
+
include.forEach(s => expect(html).toEqual(expect.stringContaining(s)));
|
|
473
|
+
const script = doc.querySelectorAll('script[id=mobify-data]')[0];
|
|
474
|
+
expect(script.innerHTML.split(/\r\n|\r|\n/).length).toBeGreaterThan(1);
|
|
475
|
+
}
|
|
476
|
+
}, {
|
|
477
|
+
description: `rendering PWA's with "__pretty_print" mode should print stylized global state`,
|
|
478
|
+
req: {
|
|
479
|
+
url: '/pwa/',
|
|
480
|
+
query: {
|
|
481
|
+
__pretty_print: '1'
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
assertions: res => {
|
|
485
|
+
const html = res.text;
|
|
486
|
+
const doc = (0, _nodeHtmlParser.parse)(html);
|
|
487
|
+
const include = ['<div>This is a PWA</div>'];
|
|
488
|
+
include.forEach(s => expect(html).toEqual(expect.stringContaining(s)));
|
|
489
|
+
const script = doc.querySelectorAll('script[id=mobify-data]')[0];
|
|
490
|
+
expect(script.innerHTML.split(/\r\n|\r|\n/).length).toBeGreaterThan(1);
|
|
491
|
+
}
|
|
492
|
+
}, {
|
|
493
|
+
description: `404 when no route matches`,
|
|
494
|
+
req: {
|
|
495
|
+
url: '/this-should-404/'
|
|
496
|
+
},
|
|
497
|
+
assertions: res => {
|
|
498
|
+
expect(res.statusCode).toBe(404);
|
|
499
|
+
}
|
|
500
|
+
}, {
|
|
501
|
+
description: `404 when getProps method throws a 404`,
|
|
502
|
+
req: {
|
|
503
|
+
url: '/404-in-get-props-error/'
|
|
504
|
+
},
|
|
505
|
+
assertions: res => {
|
|
506
|
+
expect(res.statusCode).toBe(404);
|
|
507
|
+
}
|
|
508
|
+
}, {
|
|
509
|
+
description: `supports react-routers redirect mechanism`,
|
|
510
|
+
req: {
|
|
511
|
+
url: '/redirect/'
|
|
512
|
+
},
|
|
513
|
+
assertions: res => {
|
|
514
|
+
expect(res.statusCode).toBe(302);
|
|
515
|
+
}
|
|
516
|
+
}, {
|
|
517
|
+
description: `500 on unknown errors in getProps`,
|
|
518
|
+
req: {
|
|
519
|
+
url: '/unknown-error/'
|
|
520
|
+
},
|
|
521
|
+
assertions: res => {
|
|
522
|
+
expect(res.statusCode).toBe(500);
|
|
523
|
+
}
|
|
524
|
+
}, {
|
|
525
|
+
description: `500 when string (not Error) thrown in getProps`,
|
|
526
|
+
req: {
|
|
527
|
+
url: '/throw-string/'
|
|
528
|
+
},
|
|
529
|
+
assertions: res => {
|
|
530
|
+
expect(res.statusCode).toBe(500);
|
|
531
|
+
}
|
|
532
|
+
}, {
|
|
533
|
+
description: `5XX on known HTTP errors in getProps`,
|
|
534
|
+
req: {
|
|
535
|
+
url: '/known-error/'
|
|
536
|
+
},
|
|
537
|
+
assertions: res => {
|
|
538
|
+
expect(res.statusCode).toBe(503);
|
|
539
|
+
}
|
|
540
|
+
}, {
|
|
541
|
+
description: `Respects HTTP status codes set in init() methods`,
|
|
542
|
+
req: {
|
|
543
|
+
url: '/init-sets-status/'
|
|
544
|
+
},
|
|
545
|
+
assertions: res => {
|
|
546
|
+
expect(res.statusCode).toBe(418);
|
|
547
|
+
}
|
|
548
|
+
}, {
|
|
549
|
+
description: `Works if the user returns an Object of props, instead of a Promise`,
|
|
550
|
+
req: {
|
|
551
|
+
url: '/get-props-returns-object/'
|
|
552
|
+
},
|
|
553
|
+
assertions: res => {
|
|
554
|
+
expect(res.statusCode).toBe(200);
|
|
555
|
+
const html = res.text;
|
|
556
|
+
const include = ['<div>prop-value</div>'];
|
|
557
|
+
include.forEach(s => expect(html).toEqual(expect.stringContaining(s)));
|
|
558
|
+
}
|
|
559
|
+
}, {
|
|
560
|
+
description: `Renders the error page if getProps rejects with an empty string`,
|
|
561
|
+
req: {
|
|
562
|
+
url: '/get-props-rejects-with-empty-string/'
|
|
563
|
+
},
|
|
564
|
+
assertions: res => {
|
|
565
|
+
const html = res.text;
|
|
566
|
+
const doc = (0, _nodeHtmlParser.parse)(html);
|
|
567
|
+
const data = dataFromHTML(doc);
|
|
568
|
+
expect(data.__ERROR__.message).toBe('Internal Server Error');
|
|
569
|
+
expect(typeof data.__ERROR__.stack).toEqual((0, _ssrServer.isRemote)() ? 'undefined' : 'string');
|
|
570
|
+
expect(data.__ERROR__.status).toBe(500);
|
|
571
|
+
}
|
|
572
|
+
}, {
|
|
573
|
+
description: `Renders the error page instead if there is an error during component rendering`,
|
|
574
|
+
req: {
|
|
575
|
+
url: '/render-throws-error/'
|
|
576
|
+
},
|
|
577
|
+
assertions: res => {
|
|
578
|
+
const html = res.text;
|
|
579
|
+
const doc = (0, _nodeHtmlParser.parse)(html);
|
|
580
|
+
const data = dataFromHTML(doc);
|
|
581
|
+
expect(data.__ERROR__.message).toBe('Internal Server Error');
|
|
582
|
+
expect(typeof data.__ERROR__.stack).toEqual((0, _ssrServer.isRemote)() ? 'undefined' : 'string');
|
|
583
|
+
expect(data.__ERROR__.status).toBe(500);
|
|
584
|
+
expect(res.statusCode).toBe(500);
|
|
585
|
+
}
|
|
586
|
+
}, {
|
|
587
|
+
description: `Renders react-helmet tags`,
|
|
588
|
+
req: {
|
|
589
|
+
url: '/render-helmet/'
|
|
590
|
+
},
|
|
591
|
+
assertions: res => {
|
|
592
|
+
expect(res.statusCode).toBe(200);
|
|
593
|
+
const html = res.text;
|
|
594
|
+
const doc = (0, _nodeHtmlParser.parse)(html);
|
|
595
|
+
const head = doc.querySelector('head');
|
|
596
|
+
expect(html).toContain('lang="helmet-html-attribute"');
|
|
597
|
+
expect(doc.querySelector('body').getAttribute('class')).toBe('helmet-body-attribute');
|
|
598
|
+
expect(head.querySelector(`title`).innerHTML).toBe('Helmet title');
|
|
599
|
+
expect(head.querySelector('base').getAttribute('target')).toBe('_blank');
|
|
600
|
+
expect(doc.querySelector('style').innerHTML).toContain('background-color: blue;');
|
|
601
|
+
expect(doc.querySelector('noscript').innerHTML).toContain('<link rel="stylesheet" type="text/css" href="foo.css" />');
|
|
602
|
+
expect(doc.querySelector('noscript').innerHTML).toEqual(expect.stringContaining('<link rel="stylesheet" type="text/css" href="foo.css" />'));
|
|
603
|
+
expect(head.querySelector('meta[name="helmet-meta-1"]')).not.toBeNull();
|
|
604
|
+
expect(head.querySelector('meta[property="helmet-meta-2"]')).not.toBeNull();
|
|
605
|
+
expect(head.querySelector('link[rel="helmet-link-1"]')).not.toBeNull();
|
|
606
|
+
expect(head.querySelector('link[rel="helmet-link-2"]')).not.toBeNull();
|
|
607
|
+
expect(head.querySelector('script[src="http://include.com/pathtojs.js"]')).not.toBeNull();
|
|
608
|
+
expect(head.querySelector('script[type="application/ld+json"]').innerHTML).toContain(`"@context": "http://schema.org"`);
|
|
609
|
+
}
|
|
610
|
+
}, {
|
|
611
|
+
description: `Frozen state is escaped preventing injection attacks`,
|
|
612
|
+
req: {
|
|
613
|
+
url: '/xss/'
|
|
614
|
+
},
|
|
615
|
+
assertions: res => {
|
|
616
|
+
const html = res.text;
|
|
617
|
+
const doc = (0, _nodeHtmlParser.parse)(html);
|
|
618
|
+
const scriptContent = doc.querySelector('#mobify-data').innerHTML;
|
|
619
|
+
expect(scriptContent).not.toContain('<script>');
|
|
620
|
+
}
|
|
621
|
+
}, {
|
|
622
|
+
description: `AppConfig errors are caught`,
|
|
623
|
+
req: {
|
|
624
|
+
url: '/pwa/'
|
|
625
|
+
},
|
|
626
|
+
mocks: () => {
|
|
627
|
+
const AppConfig = (0, _compatibility.getAppConfig)();
|
|
628
|
+
jest.spyOn(AppConfig.prototype, 'render').mockImplementation(() => {
|
|
629
|
+
throw new Error();
|
|
630
|
+
});
|
|
631
|
+
},
|
|
632
|
+
assertions: res => {
|
|
633
|
+
const html = res.text;
|
|
634
|
+
expect(res.statusCode).toBe(500);
|
|
635
|
+
const shouldIncludeErrorStack = !(0, _ssrServer.isRemote)();
|
|
636
|
+
expect(html).toContain(shouldIncludeErrorStack ? 'Error: ' : 'Internal Server Error');
|
|
637
|
+
}
|
|
638
|
+
}, {
|
|
639
|
+
description: `Works if the user resolves an Object with useQuery`,
|
|
640
|
+
req: {
|
|
641
|
+
url: '/use-query-resolves-object/'
|
|
642
|
+
},
|
|
643
|
+
assertions: res => {
|
|
644
|
+
expect(res.statusCode).toBe(200);
|
|
645
|
+
const html = res.text;
|
|
646
|
+
expect(html).toEqual(expect.stringContaining('<div>prop-value</div>'));
|
|
647
|
+
}
|
|
648
|
+
}, {
|
|
649
|
+
description: `Disabled useQuery queries aren't run on the server`,
|
|
650
|
+
req: {
|
|
651
|
+
url: '/disabled-use-query-isnt-resolved/'
|
|
652
|
+
},
|
|
653
|
+
assertions: res => {
|
|
654
|
+
expect(res.statusCode).toBe(200);
|
|
655
|
+
const html = res.text;
|
|
656
|
+
expect(html).toEqual(expect.stringContaining('<div>loading</div>'));
|
|
657
|
+
}
|
|
658
|
+
}, {
|
|
659
|
+
description: 'Get the server context and set the response status to 404',
|
|
660
|
+
req: {
|
|
661
|
+
url: '/server-context'
|
|
662
|
+
},
|
|
663
|
+
mocks: () => {
|
|
664
|
+
jest.spyOn(console, 'log');
|
|
665
|
+
},
|
|
666
|
+
assertions: res => {
|
|
667
|
+
expect(res.statusCode).toBe(404);
|
|
668
|
+
|
|
669
|
+
// we'll expect that this method is called three times,
|
|
670
|
+
// twice for rendering pipeline because of the prepass step and one for http request logging
|
|
671
|
+
expect(console.log).toHaveBeenCalledTimes(3);
|
|
672
|
+
}
|
|
673
|
+
}];
|
|
674
|
+
const isRemoteValues = [true, false];
|
|
675
|
+
_buildRemoteServer.RemoteServerFactory._setRequestId.mockImplementation(_app => {
|
|
676
|
+
_app.use((req, res, next) => {
|
|
677
|
+
res.locals.requestId = (0, _crypto.randomUUID)();
|
|
678
|
+
next();
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
isRemoteValues.forEach(isRemoteValue => {
|
|
682
|
+
// Rename `assertions` to `expectations` to pass linter rule
|
|
683
|
+
cases.forEach(({
|
|
684
|
+
description,
|
|
685
|
+
req,
|
|
686
|
+
assertions: expectations,
|
|
687
|
+
mocks
|
|
688
|
+
}) => {
|
|
689
|
+
test(`renders PWA pages properly when ${isRemoteValue ? 'remote' : 'local'} (${description})`, /*#__PURE__*/_asyncToGenerator(function* () {
|
|
690
|
+
// Mock `isRemote` per test execution.
|
|
691
|
+
_ssrServer.isRemote.mockReturnValue(isRemoteValue);
|
|
692
|
+
process.env.NODE_ENV = isRemoteValue ? 'production' : 'development';
|
|
693
|
+
const {
|
|
694
|
+
url,
|
|
695
|
+
headers,
|
|
696
|
+
query
|
|
697
|
+
} = req;
|
|
698
|
+
const app = _buildRemoteServer.RemoteServerFactory._createApp(opts());
|
|
699
|
+
app.get('/*', _reactRendering.render);
|
|
700
|
+
if (mocks) {
|
|
701
|
+
mocks();
|
|
702
|
+
}
|
|
703
|
+
const res = yield (0, _supertest.default)(app).get(url).set(headers || {}).query(query || {});
|
|
704
|
+
expectations(res);
|
|
705
|
+
}));
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
});
|