@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 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 // Make word boundaries start with an upper case letter.
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
- // The MountingComponent is a bit different than just a
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 => /*#__PURE__*/React.createElement(Component, args);
98
+ }) : args => React.createElement(Component, args);
160
99
  templateMap.set(Component, Template);
161
- } // Each story that shares that component then reuses that
162
- // template.
163
-
100
+ }
164
101
 
165
102
  acc[exportName] = Template.bind({});
166
103
  acc[exportName].args = getProps({
167
104
  log
168
- }); // Adding a story name here means that we don't have to
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); // Only merge if they're both arrays or both objects.
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
- }, {}); // We know that we are creating a compatible return type.
263
- // $FlowIgnore[incompatible-return]
264
-
157
+ }, {});
265
158
  return combined;
266
159
  };
267
160
 
268
161
  const normalizeOptions = componentOrOptions => {
269
- // To differentiate between a React component and a FixturesOptions object,
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
- } // We can't test for React.ComponentType at runtime.
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); // 1. Create a new adapter group.
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
- }); // 2. Invoke fn with a function that can add a new fixture.
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); // 3. Combine the adapter options from the fixture group with the
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); // 4. Call close on the group and return the result.
349
-
203
+ const combinedAdapterOptions = combineOptions(defaultAdapterOptions, groupAdapterOverrides);
350
204
  return group.closeGroup(combinedAdapterOptions);
351
205
  };
352
206
 
353
- const safeHasOwnProperty = (obj, prop) => // Flow really shouldn't be raising this error here.
354
- // $FlowFixMe[method-unbinding]
355
- Object.prototype.hasOwnProperty.call(obj, prop); // TODO(somewhatabstract, FEI-4268): use a third-party library to do this and
356
- // possibly make it also support the jest `jest.objectContaining` type matching
357
- // to simplify mock declaration (note that it would need to work in regular
358
- // tests and stories/fixtures).
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
- } // We do a loose match, so if the lhs doesn't define variables,
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
- } // We do a loose match, so if the lhs doesn't define context,
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
- } // If we get here, we have a match.
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 };