@khanacademy/wonder-blocks-testing 3.0.0 → 4.0.1
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 +18 -0
- package/dist/es/index.js +144 -316
- package/dist/index.js +271 -143
- package/package.json +4 -3
- package/src/{gql/__tests__/make-gql-mock-response.test.js → __tests__/make-mock-response.test.js} +196 -34
- package/src/__tests__/mock-requester.test.js +213 -0
- package/src/__tests__/response-impl.test.js +47 -0
- package/src/fetch/__tests__/__snapshots__/mock-fetch.test.js.snap +29 -0
- package/src/fetch/__tests__/fetch-request-matches-mock.test.js +99 -0
- package/src/fetch/__tests__/mock-fetch.test.js +84 -0
- package/src/fetch/fetch-request-matches-mock.js +43 -0
- package/src/fetch/mock-fetch.js +19 -0
- package/src/fetch/types.js +18 -0
- package/src/fixtures/__tests__/fixtures.test.js +32 -1
- package/src/fixtures/fixtures.js +15 -5
- package/src/gql/__tests__/mock-gql-fetch.test.js +24 -15
- package/src/gql/__tests__/wb-data-integration.test.js +7 -4
- package/src/gql/mock-gql-fetch.js +9 -80
- package/src/gql/types.js +11 -10
- package/src/index.js +9 -3
- package/src/make-mock-response.js +150 -0
- package/src/mock-requester.js +75 -0
- package/src/response-impl.js +9 -0
- package/src/types.js +39 -0
- package/src/gql/make-gql-mock-response.js +0 -124
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @khanacademy/wonder-blocks-testing
|
|
2
2
|
|
|
3
|
+
## 4.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- @khanacademy/wonder-blocks-data@7.0.1
|
|
8
|
+
|
|
9
|
+
## 4.0.0
|
|
10
|
+
|
|
11
|
+
### Major Changes
|
|
12
|
+
|
|
13
|
+
- fce91b39: Introduced `mockFetch` and expanded `RespondWith` options. `RespondWith` responses will now be real `Response` instances (needs node-fetch peer dependency if no other implementation exists). Breaking changes: `RespondWith.data` is now `RespondWith.graphQLData`.
|
|
14
|
+
|
|
15
|
+
## 3.0.1
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- 6e4fbeed: Make sure simplified fixtures call copes with return values from React.forwardRef
|
|
20
|
+
|
|
3
21
|
## 3.0.0
|
|
4
22
|
|
|
5
23
|
### Major Changes
|
package/dist/es/index.js
CHANGED
|
@@ -3,17 +3,7 @@ import * as React from 'react';
|
|
|
3
3
|
import { action } from '@storybook/addon-actions';
|
|
4
4
|
import { clone } from '@khanacademy/wonder-stuff-core';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Simple adapter group implementation.
|
|
8
|
-
*/
|
|
9
6
|
class AdapterGroup {
|
|
10
|
-
/**
|
|
11
|
-
* Create an adapter group.
|
|
12
|
-
*
|
|
13
|
-
* @param {CloseGroupFn<TProps, Options, Exports>} closeGroupFn A function
|
|
14
|
-
* to invoke when the group is closed.
|
|
15
|
-
* @param {AdapterGroupOptions} options The options for the group.
|
|
16
|
-
*/
|
|
17
7
|
constructor(closeGroupFn, _options) {
|
|
18
8
|
this.closeGroup = (adapterOptions = null) => {
|
|
19
9
|
if (this._closeGroupFn == null) {
|
|
@@ -51,29 +41,10 @@ class AdapterGroup {
|
|
|
51
41
|
this._options = _options;
|
|
52
42
|
this._fixtures = [];
|
|
53
43
|
}
|
|
54
|
-
/**
|
|
55
|
-
* Close the group.
|
|
56
|
-
*
|
|
57
|
-
* This declares that no more fixtures are to be added to the group,
|
|
58
|
-
* and will call the parent adapter with the declared fixtures so that they
|
|
59
|
-
* can be adapted for the target fixture framework, such as Storybook.
|
|
60
|
-
*/
|
|
61
|
-
|
|
62
44
|
|
|
63
45
|
}
|
|
64
46
|
|
|
65
|
-
/**
|
|
66
|
-
* Class for implementing a custom adapter.
|
|
67
|
-
*/
|
|
68
47
|
class Adapter {
|
|
69
|
-
/**
|
|
70
|
-
* @param {string} name The name of the adapter.
|
|
71
|
-
* @param {CloseGroupFn<any, Options, Exports>} closeGroupFn The function
|
|
72
|
-
* an adapter group should call when the group is closed. This is invoked
|
|
73
|
-
* by an adapter group when it is closed. This function is where an
|
|
74
|
-
* adapter implements the logic to generate the actual fixtures for the
|
|
75
|
-
* adapter's target framework.
|
|
76
|
-
*/
|
|
77
48
|
constructor(name, closeGroupFn) {
|
|
78
49
|
if (typeof name !== "string") {
|
|
79
50
|
throw new TypeError("name must be a string");
|
|
@@ -90,22 +61,10 @@ class Adapter {
|
|
|
90
61
|
this._name = name;
|
|
91
62
|
this._closeGroupFn = closeGroupFn;
|
|
92
63
|
}
|
|
93
|
-
/**
|
|
94
|
-
* The name of the adapter.
|
|
95
|
-
*/
|
|
96
|
-
|
|
97
64
|
|
|
98
65
|
get name() {
|
|
99
66
|
return this._name;
|
|
100
67
|
}
|
|
101
|
-
/**
|
|
102
|
-
* Declare a new fixture group.
|
|
103
|
-
*
|
|
104
|
-
* @param {AdapterGroupOptions} options The options describing the fixture
|
|
105
|
-
* group.
|
|
106
|
-
* @returns {AdapterGroupInterface} The new fixture group.
|
|
107
|
-
*/
|
|
108
|
-
|
|
109
68
|
|
|
110
69
|
declareGroup(options) {
|
|
111
70
|
return new AdapterGroup(this._closeGroupFn, options);
|
|
@@ -113,14 +72,9 @@ class Adapter {
|
|
|
113
72
|
|
|
114
73
|
}
|
|
115
74
|
|
|
116
|
-
/**
|
|
117
|
-
* Get a fixture framework adapter for Storybook support.
|
|
118
|
-
*/
|
|
119
75
|
const getAdapter = (MountingComponent = null) => new Adapter("storybook", ({
|
|
120
76
|
title,
|
|
121
77
|
description: groupDescription,
|
|
122
|
-
// We don't use the default title in Storybook as storybook
|
|
123
|
-
// will generate titles for us if we pass a nullish title.
|
|
124
78
|
getDefaultTitle: _
|
|
125
79
|
}, adapterOptions, declaredFixtures) => {
|
|
126
80
|
const templateMap = new WeakMap();
|
|
@@ -133,43 +87,22 @@ const getAdapter = (MountingComponent = null) => new Adapter("storybook", ({
|
|
|
133
87
|
component: Component
|
|
134
88
|
}, i) => {
|
|
135
89
|
const storyName = `${i + 1} ${description}`;
|
|
136
|
-
const exportName = storyName
|
|
137
|
-
.replace(/\b\w/g, c => c.toUpperCase()) // Remove all non-alphanumeric characters.
|
|
138
|
-
.replace(/[^\w]+/g, "") // Remove all underscores.
|
|
139
|
-
.replace(/[_]+/g, ""); // We create a “template” of how args map to rendering
|
|
140
|
-
// for each type of component as the component here could
|
|
141
|
-
// be the component under test, or wrapped in a wrapper
|
|
142
|
-
// component. We don't use decorators for the wrapper
|
|
143
|
-
// because we may not be in a storybook context and it
|
|
144
|
-
// keeps the framework API simpler this way.
|
|
145
|
-
|
|
90
|
+
const exportName = storyName.replace(/\b\w/g, c => c.toUpperCase()).replace(/[^\w]+/g, "").replace(/[_]+/g, "");
|
|
146
91
|
let Template = templateMap.get(Component);
|
|
147
92
|
|
|
148
93
|
if (Template == null) {
|
|
149
|
-
|
|
150
|
-
// Storybook decorator. It's a React component that
|
|
151
|
-
// takes over rendering the component in the fixture
|
|
152
|
-
// with the given args, allowing for greater
|
|
153
|
-
// customization in a platform-agnostic manner (i.e.
|
|
154
|
-
// not just story format).
|
|
155
|
-
Template = MountingComponent ? args => /*#__PURE__*/React.createElement(MountingComponent, {
|
|
94
|
+
Template = MountingComponent ? args => React.createElement(MountingComponent, {
|
|
156
95
|
component: Component,
|
|
157
96
|
props: args,
|
|
158
97
|
log: log
|
|
159
|
-
}) : args =>
|
|
98
|
+
}) : args => React.createElement(Component, args);
|
|
160
99
|
templateMap.set(Component, Template);
|
|
161
|
-
}
|
|
162
|
-
// template.
|
|
163
|
-
|
|
100
|
+
}
|
|
164
101
|
|
|
165
102
|
acc[exportName] = Template.bind({});
|
|
166
103
|
acc[exportName].args = getProps({
|
|
167
104
|
log
|
|
168
|
-
});
|
|
169
|
-
// care about naming the exports correctly, if we don't
|
|
170
|
-
// want (useful if we need to autogenerate or manually
|
|
171
|
-
// expose ESM exports).
|
|
172
|
-
|
|
105
|
+
});
|
|
173
106
|
acc[exportName].storyName = storyName;
|
|
174
107
|
return acc;
|
|
175
108
|
}, {
|
|
@@ -186,20 +119,9 @@ var adapters = /*#__PURE__*/Object.freeze({
|
|
|
186
119
|
});
|
|
187
120
|
|
|
188
121
|
let _configuration = null;
|
|
189
|
-
/**
|
|
190
|
-
* Setup the fixture framework.
|
|
191
|
-
*/
|
|
192
|
-
|
|
193
122
|
const setup = configuration => {
|
|
194
123
|
_configuration = configuration;
|
|
195
124
|
};
|
|
196
|
-
/**
|
|
197
|
-
* Get the framework configuration.
|
|
198
|
-
*
|
|
199
|
-
* @returns {Configuration} The configuration as provided via setup().
|
|
200
|
-
* @throws {Error} If the configuration has not been set.
|
|
201
|
-
*/
|
|
202
|
-
|
|
203
125
|
const getConfiguration = () => {
|
|
204
126
|
if (_configuration == null) {
|
|
205
127
|
throw new Error("Not configured");
|
|
@@ -208,26 +130,8 @@ const getConfiguration = () => {
|
|
|
208
130
|
return _configuration;
|
|
209
131
|
};
|
|
210
132
|
|
|
211
|
-
/**
|
|
212
|
-
* Combine two values.
|
|
213
|
-
*
|
|
214
|
-
* This method clones val2 before using any of its properties to try to ensure
|
|
215
|
-
* the combined object is not linked back to the original.
|
|
216
|
-
*
|
|
217
|
-
* If the values are objects, it will merge them at the top level. Properties
|
|
218
|
-
* themselves are not merged; val2 properties will overwrite val1 where there
|
|
219
|
-
* are conflicts
|
|
220
|
-
*
|
|
221
|
-
* If the values are arrays, it will concatenate and dedupe them.
|
|
222
|
-
* NOTE: duplicates in either val1 or val2 will also be deduped.
|
|
223
|
-
*
|
|
224
|
-
* If the values are any other type, or val2 has a different type to val1, val2
|
|
225
|
-
* will be returned.
|
|
226
|
-
*/
|
|
227
|
-
|
|
228
133
|
const combineTopLevel = (val1, val2) => {
|
|
229
|
-
const obj2Clone = clone(val2);
|
|
230
|
-
// If not, we will just return val2.
|
|
134
|
+
const obj2Clone = clone(val2);
|
|
231
135
|
|
|
232
136
|
if (val1 !== null && val2 !== null && typeof val1 === "object" && typeof val2 === "object") {
|
|
233
137
|
const val1IsArray = Array.isArray(val1);
|
|
@@ -243,72 +147,26 @@ const combineTopLevel = (val1, val2) => {
|
|
|
243
147
|
return obj2Clone;
|
|
244
148
|
};
|
|
245
149
|
|
|
246
|
-
/**
|
|
247
|
-
* Combine one or more objects into a single object.
|
|
248
|
-
*
|
|
249
|
-
* Objects later in the argument list take precedence over those that are
|
|
250
|
-
* earlier. Object and array values at the root level are merged.
|
|
251
|
-
*/
|
|
252
|
-
|
|
253
150
|
const combineOptions = (...toBeCombined) => {
|
|
254
151
|
const combined = toBeCombined.filter(Boolean).reduce((acc, cur) => {
|
|
255
152
|
for (const key of Object.keys(cur)) {
|
|
256
|
-
// We always call combine, even if acc[key] is undefined
|
|
257
|
-
// because we need to make sure we clone values.
|
|
258
153
|
acc[key] = combineTopLevel(acc[key], cur[key]);
|
|
259
154
|
}
|
|
260
155
|
|
|
261
156
|
return acc;
|
|
262
|
-
}, {});
|
|
263
|
-
// $FlowIgnore[incompatible-return]
|
|
264
|
-
|
|
157
|
+
}, {});
|
|
265
158
|
return combined;
|
|
266
159
|
};
|
|
267
160
|
|
|
268
161
|
const normalizeOptions = componentOrOptions => {
|
|
269
|
-
|
|
270
|
-
// we have to do some type checking. Since all React components, whether
|
|
271
|
-
// functional or class-based, are inherently functions in JavaScript
|
|
272
|
-
// this should do the trick without relying on internal React details like
|
|
273
|
-
// protoype.isReactComponent. This should be sufficient for our purposes.
|
|
274
|
-
// Alternatives I considered were:
|
|
275
|
-
// - Use an additional parameter for the options and then do an arg number
|
|
276
|
-
// check, but that always makes typing a function harder and often breaks
|
|
277
|
-
// types. I didn't want that battle today.
|
|
278
|
-
// - Use a tuple when providing component and options with the first element
|
|
279
|
-
// being the component and the second being the options. However that
|
|
280
|
-
// feels like an obscure API even though it's really easy to do the
|
|
281
|
-
// typing.
|
|
282
|
-
if (typeof componentOrOptions === "function") {
|
|
162
|
+
if (typeof componentOrOptions === "function" || typeof componentOrOptions.render === "function") {
|
|
283
163
|
return {
|
|
284
164
|
component: componentOrOptions
|
|
285
165
|
};
|
|
286
|
-
}
|
|
287
|
-
// Let's assume our simple heuristic above is sufficient.
|
|
288
|
-
// $FlowIgnore[incompatible-return]
|
|
289
|
-
|
|
166
|
+
}
|
|
290
167
|
|
|
291
168
|
return componentOrOptions;
|
|
292
169
|
};
|
|
293
|
-
/**
|
|
294
|
-
* Describe a group of fixtures for a given component.
|
|
295
|
-
*
|
|
296
|
-
* Only one `fixtures` call should be used per fixture file as it returns
|
|
297
|
-
* the exports for that file.
|
|
298
|
-
*
|
|
299
|
-
* @param {FixtureOptions<TProps>} options Options describing the
|
|
300
|
-
* fixture group.
|
|
301
|
-
* @param {FixtureFn<TProps> => void} fn A function that provides a `fixture`
|
|
302
|
-
* function for defining fixtures.
|
|
303
|
-
* @returns {Exports} The object to be exported as `module.exports`.
|
|
304
|
-
*
|
|
305
|
-
* TODO(somewhatabstract): Determine a way around this requirement so we
|
|
306
|
-
* can support named exports and default exports via the adapters in a
|
|
307
|
-
* deterministic way. Currently this is imposed on us because of how
|
|
308
|
-
* storybook, the popular framework, uses both default and named exports for
|
|
309
|
-
* its interface.
|
|
310
|
-
*/
|
|
311
|
-
|
|
312
170
|
|
|
313
171
|
const fixtures = (componentOrOptions, fn) => {
|
|
314
172
|
var _additionalAdapterOpt;
|
|
@@ -323,13 +181,12 @@ const fixtures = (componentOrOptions, fn) => {
|
|
|
323
181
|
description: groupDescription,
|
|
324
182
|
defaultWrapper,
|
|
325
183
|
additionalAdapterOptions
|
|
326
|
-
} = normalizeOptions(componentOrOptions);
|
|
327
|
-
|
|
184
|
+
} = normalizeOptions(componentOrOptions);
|
|
328
185
|
const group = adapter.declareGroup({
|
|
329
186
|
title,
|
|
330
187
|
description: groupDescription,
|
|
331
188
|
getDefaultTitle: () => component.displayName || component.name || "Component"
|
|
332
|
-
});
|
|
189
|
+
});
|
|
333
190
|
|
|
334
191
|
const addFixture = (description, props, wrapper = null) => {
|
|
335
192
|
var _ref;
|
|
@@ -341,22 +198,138 @@ const fixtures = (componentOrOptions, fn) => {
|
|
|
341
198
|
});
|
|
342
199
|
};
|
|
343
200
|
|
|
344
|
-
fn(addFixture);
|
|
345
|
-
// defaults from our setup.
|
|
346
|
-
|
|
201
|
+
fn(addFixture);
|
|
347
202
|
const groupAdapterOverrides = (_additionalAdapterOpt = additionalAdapterOptions == null ? void 0 : additionalAdapterOptions[adapter.name]) != null ? _additionalAdapterOpt : {};
|
|
348
|
-
const combinedAdapterOptions = combineOptions(defaultAdapterOptions, groupAdapterOverrides);
|
|
349
|
-
|
|
203
|
+
const combinedAdapterOptions = combineOptions(defaultAdapterOptions, groupAdapterOverrides);
|
|
350
204
|
return group.closeGroup(combinedAdapterOptions);
|
|
351
205
|
};
|
|
352
206
|
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
207
|
+
const ResponseImpl = typeof Response === "undefined" ? require("node-fetch").Response : Response;
|
|
208
|
+
|
|
209
|
+
const textResponse = (text, statusCode = 200) => ({
|
|
210
|
+
type: "text",
|
|
211
|
+
text,
|
|
212
|
+
statusCode
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const rejectResponse = error => ({
|
|
216
|
+
type: "reject",
|
|
217
|
+
error
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const RespondWith = Object.freeze({
|
|
221
|
+
text: (text, statusCode = 200) => textResponse(text, statusCode),
|
|
222
|
+
json: json => textResponse(() => JSON.stringify(json)),
|
|
223
|
+
graphQLData: data => textResponse(() => JSON.stringify({
|
|
224
|
+
data
|
|
225
|
+
})),
|
|
226
|
+
unparseableBody: () => textResponse("INVALID JSON"),
|
|
227
|
+
abortedRequest: () => rejectResponse(() => {
|
|
228
|
+
const abortError = new Error("Mock request aborted");
|
|
229
|
+
abortError.name = "AbortError";
|
|
230
|
+
return abortError;
|
|
231
|
+
}),
|
|
232
|
+
reject: error => rejectResponse(error),
|
|
233
|
+
errorStatusCode: statusCode => {
|
|
234
|
+
if (statusCode < 300) {
|
|
235
|
+
throw new Error(`${statusCode} is not a valid error status code`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return textResponse("{}", statusCode);
|
|
239
|
+
},
|
|
240
|
+
nonGraphQLBody: () => textResponse(() => JSON.stringify({
|
|
241
|
+
valid: "json",
|
|
242
|
+
that: "is not a valid graphql response"
|
|
243
|
+
})),
|
|
244
|
+
graphQLErrors: errorMessages => textResponse(() => JSON.stringify({
|
|
245
|
+
errors: errorMessages.map(e => ({
|
|
246
|
+
message: e
|
|
247
|
+
}))
|
|
248
|
+
}))
|
|
249
|
+
});
|
|
250
|
+
const makeMockResponse = response => {
|
|
251
|
+
switch (response.type) {
|
|
252
|
+
case "text":
|
|
253
|
+
const text = typeof response.text === "function" ? response.text() : response.text;
|
|
254
|
+
return Promise.resolve(new ResponseImpl(text, {
|
|
255
|
+
status: response.statusCode
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
case "reject":
|
|
259
|
+
const error = response.error instanceof Error ? response.error : response.error();
|
|
260
|
+
return Promise.reject(error);
|
|
261
|
+
|
|
262
|
+
default:
|
|
263
|
+
throw new Error(`Unknown response type: ${response.type}`);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const getHref = input => {
|
|
268
|
+
if (typeof input === "string") {
|
|
269
|
+
return input;
|
|
270
|
+
} else if (typeof input.url === "string") {
|
|
271
|
+
return input.url;
|
|
272
|
+
} else if (typeof input.href === "string") {
|
|
273
|
+
return input.href;
|
|
274
|
+
} else {
|
|
275
|
+
throw new Error(`Unsupported input type`);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const fetchRequestMatchesMock = (mock, input, init) => {
|
|
280
|
+
const href = getHref(input);
|
|
359
281
|
|
|
282
|
+
if (typeof mock === "string") {
|
|
283
|
+
return href === mock;
|
|
284
|
+
} else if (mock instanceof RegExp) {
|
|
285
|
+
return mock.test(href);
|
|
286
|
+
} else {
|
|
287
|
+
throw new Error(`Unsupported mock operation: ${JSON.stringify(mock)}`);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const mockRequester = (operationMatcher, operationToString) => {
|
|
292
|
+
const mocks = [];
|
|
293
|
+
|
|
294
|
+
const mockFn = (...args) => {
|
|
295
|
+
for (const mock of mocks) {
|
|
296
|
+
if (mock.onceOnly && mock.used) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (operationMatcher.apply(void 0, [mock.operation].concat(args))) {
|
|
301
|
+
mock.used = true;
|
|
302
|
+
return mock.response();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return Promise.reject(new Error(`No matching mock response found for request:
|
|
307
|
+
${operationToString.apply(void 0, args)}`));
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const addMockedOperation = (operation, response, onceOnly) => {
|
|
311
|
+
const mockResponse = () => makeMockResponse(response);
|
|
312
|
+
|
|
313
|
+
mocks.push({
|
|
314
|
+
operation,
|
|
315
|
+
response: mockResponse,
|
|
316
|
+
onceOnly,
|
|
317
|
+
used: false
|
|
318
|
+
});
|
|
319
|
+
return mockFn;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
mockFn.mockOperation = (operation, response) => addMockedOperation(operation, response, false);
|
|
323
|
+
|
|
324
|
+
mockFn.mockOperationOnce = (operation, response) => addMockedOperation(operation, response, true);
|
|
325
|
+
|
|
326
|
+
return mockFn;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const mockFetch = () => mockRequester(fetchRequestMatchesMock, (input, init) => `Input: ${typeof input === "string" ? input : JSON.stringify(input, null, 2)}
|
|
330
|
+
Options: ${init == null ? "None" : JSON.stringify(init, null, 2)}`);
|
|
331
|
+
|
|
332
|
+
const safeHasOwnProperty = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop);
|
|
360
333
|
|
|
361
334
|
const areObjectsEqual = (a, b) => {
|
|
362
335
|
if (a === b) {
|
|
@@ -390,172 +363,27 @@ const areObjectsEqual = (a, b) => {
|
|
|
390
363
|
};
|
|
391
364
|
|
|
392
365
|
const gqlRequestMatchesMock = (mock, operation, variables, context) => {
|
|
393
|
-
// If they don't represent the same operation, then they can't match.
|
|
394
|
-
// NOTE: Operations can include more fields than id and type, but we only
|
|
395
|
-
// care about id and type. The rest is ignored.
|
|
396
366
|
if (mock.operation.id !== operation.id || mock.operation.type !== operation.type) {
|
|
397
367
|
return false;
|
|
398
|
-
}
|
|
399
|
-
// we just assume it matches everything.
|
|
400
|
-
|
|
368
|
+
}
|
|
401
369
|
|
|
402
370
|
if (mock.variables != null) {
|
|
403
|
-
// Variables have to match.
|
|
404
371
|
if (!areObjectsEqual(mock.variables, variables)) {
|
|
405
372
|
return false;
|
|
406
373
|
}
|
|
407
|
-
}
|
|
408
|
-
// we just assume it matches everything.
|
|
409
|
-
|
|
374
|
+
}
|
|
410
375
|
|
|
411
376
|
if (mock.context != null) {
|
|
412
|
-
// Context has to match.
|
|
413
377
|
if (!areObjectsEqual(mock.context, context)) {
|
|
414
378
|
return false;
|
|
415
379
|
}
|
|
416
|
-
}
|
|
417
|
-
|
|
380
|
+
}
|
|
418
381
|
|
|
419
382
|
return true;
|
|
420
383
|
};
|
|
421
384
|
|
|
422
|
-
|
|
423
|
-
* Helpers to define rejection states for mocking GQL requests.
|
|
424
|
-
*/
|
|
425
|
-
const RespondWith = Object.freeze({
|
|
426
|
-
data: data => ({
|
|
427
|
-
type: "data",
|
|
428
|
-
data
|
|
429
|
-
}),
|
|
430
|
-
unparseableBody: () => ({
|
|
431
|
-
type: "parse"
|
|
432
|
-
}),
|
|
433
|
-
abortedRequest: () => ({
|
|
434
|
-
type: "abort"
|
|
435
|
-
}),
|
|
436
|
-
errorStatusCode: statusCode => {
|
|
437
|
-
if (statusCode < 300) {
|
|
438
|
-
throw new Error(`${statusCode} is not a valid error status code`);
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
return {
|
|
442
|
-
type: "status",
|
|
443
|
-
statusCode
|
|
444
|
-
};
|
|
445
|
-
},
|
|
446
|
-
nonGraphQLBody: () => ({
|
|
447
|
-
type: "invalid"
|
|
448
|
-
}),
|
|
449
|
-
graphQLErrors: errorMessages => ({
|
|
450
|
-
type: "graphql",
|
|
451
|
-
errors: errorMessages
|
|
452
|
-
})
|
|
453
|
-
});
|
|
454
|
-
/**
|
|
455
|
-
* Turns an ErrorResponse value in an actual Response that will invoke
|
|
456
|
-
* that error.
|
|
457
|
-
*/
|
|
458
|
-
|
|
459
|
-
const makeGqlMockResponse = response => {
|
|
460
|
-
switch (response.type) {
|
|
461
|
-
case "data":
|
|
462
|
-
return Promise.resolve({
|
|
463
|
-
status: 200,
|
|
464
|
-
text: () => Promise.resolve(JSON.stringify({
|
|
465
|
-
data: response.data
|
|
466
|
-
}))
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
case "parse":
|
|
470
|
-
return Promise.resolve({
|
|
471
|
-
status: 200,
|
|
472
|
-
text: () => Promise.resolve("INVALID JSON")
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
case "abort":
|
|
476
|
-
const abortError = new Error("Mock request aborted");
|
|
477
|
-
abortError.name = "AbortError";
|
|
478
|
-
return Promise.reject(abortError);
|
|
479
|
-
|
|
480
|
-
case "status":
|
|
481
|
-
return Promise.resolve({
|
|
482
|
-
status: response.statusCode,
|
|
483
|
-
text: () => Promise.resolve(JSON.stringify({}))
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
case "invalid":
|
|
487
|
-
return Promise.resolve({
|
|
488
|
-
status: 200,
|
|
489
|
-
text: () => Promise.resolve(JSON.stringify({
|
|
490
|
-
valid: "json",
|
|
491
|
-
that: "is not a valid graphql response"
|
|
492
|
-
}))
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
case "graphql":
|
|
496
|
-
return Promise.resolve({
|
|
497
|
-
status: 200,
|
|
498
|
-
text: () => Promise.resolve(JSON.stringify({
|
|
499
|
-
errors: response.errors.map(e => ({
|
|
500
|
-
message: e
|
|
501
|
-
}))
|
|
502
|
-
}))
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
default:
|
|
506
|
-
throw new Error(`Unknown response type: ${response.type}`);
|
|
507
|
-
}
|
|
508
|
-
};
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* A mock for the fetch function passed to GqlRouter.
|
|
512
|
-
*/
|
|
513
|
-
const mockGqlFetch = () => {
|
|
514
|
-
// We want this to work in jest and in fixtures to make life easy for folks.
|
|
515
|
-
// This is the array of mocked operations that we will traverse and
|
|
516
|
-
// manipulate.
|
|
517
|
-
const mocks = []; // What we return has to be a drop in for the fetch function that is
|
|
518
|
-
// provided to `GqlRouter` which is how folks will then use this mock.
|
|
519
|
-
|
|
520
|
-
const gqlFetchMock = (operation, variables, context) => {
|
|
521
|
-
// Iterate our mocked operations and find the first one that matches.
|
|
522
|
-
for (const mock of mocks) {
|
|
523
|
-
if (mock.onceOnly && mock.used) {
|
|
524
|
-
// This is a once-only mock and it has been used, so skip it.
|
|
525
|
-
continue;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
if (gqlRequestMatchesMock(mock.operation, operation, variables, context)) {
|
|
529
|
-
mock.used = true;
|
|
530
|
-
return mock.response();
|
|
531
|
-
}
|
|
532
|
-
} // Default is to reject with some helpful info on what request
|
|
533
|
-
// we rejected.
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
return Promise.reject(new Error(`No matching GraphQL mock response found for request:
|
|
537
|
-
Operation: ${operation.type} ${operation.id}
|
|
385
|
+
const mockGqlFetch = () => mockRequester(gqlRequestMatchesMock, (operation, variables, context) => `Operation: ${operation.type} ${operation.id}
|
|
538
386
|
Variables: ${variables == null ? "None" : JSON.stringify(variables, null, 2)}
|
|
539
|
-
Context: ${JSON.stringify(context, null, 2)}`)
|
|
540
|
-
};
|
|
541
|
-
|
|
542
|
-
const addMockedOperation = (operation, response, onceOnly) => {
|
|
543
|
-
const mockResponse = () => makeGqlMockResponse(response);
|
|
544
|
-
|
|
545
|
-
mocks.push({
|
|
546
|
-
operation,
|
|
547
|
-
response: mockResponse,
|
|
548
|
-
onceOnly,
|
|
549
|
-
used: false
|
|
550
|
-
});
|
|
551
|
-
return gqlFetchMock;
|
|
552
|
-
};
|
|
553
|
-
|
|
554
|
-
gqlFetchMock.mockOperation = (operation, response) => addMockedOperation(operation, response, false);
|
|
555
|
-
|
|
556
|
-
gqlFetchMock.mockOperationOnce = (operation, response) => addMockedOperation(operation, response, true);
|
|
557
|
-
|
|
558
|
-
return gqlFetchMock;
|
|
559
|
-
};
|
|
387
|
+
Context: ${JSON.stringify(context, null, 2)}`);
|
|
560
388
|
|
|
561
|
-
export { RespondWith, adapters, fixtures, mockGqlFetch, setup as setupFixtures };
|
|
389
|
+
export { RespondWith, adapters, fixtures, mockFetch, mockGqlFetch, setup as setupFixtures };
|