@khanacademy/wonder-blocks-testing 3.0.1 → 4.0.2

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,32 @@
1
1
  # @khanacademy/wonder-blocks-testing
2
2
 
3
+ ## 4.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [1385f468]
8
+ - Updated dependencies [0720470e]
9
+ - Updated dependencies [0720470e]
10
+ - Updated dependencies [cf9ed87f]
11
+ - Updated dependencies [b882b082]
12
+ - Updated dependencies [0720470e]
13
+ - Updated dependencies [75c10036]
14
+ - Updated dependencies [a85f2f3a]
15
+ - Updated dependencies [0720470e]
16
+ - @khanacademy/wonder-blocks-data@8.0.0
17
+
18
+ ## 4.0.1
19
+
20
+ ### Patch Changes
21
+
22
+ - @khanacademy/wonder-blocks-data@7.0.1
23
+
24
+ ## 4.0.0
25
+
26
+ ### Major Changes
27
+
28
+ - 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`.
29
+
3
30
  ## 3.0.1
4
31
 
5
32
  ### Patch 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,79 +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.
271
- //
272
- // Alternatives I considered were:
273
- // - Use an additional parameter for the options and then do an arg number
274
- // check, but that always makes typing a function harder and often breaks
275
- // types. I didn't want that battle today.
276
- // - Use a tuple when providing component and options with the first element
277
- // being the component and the second being the options. However that
278
- // feels like an obscure API even though it's really easy to do the
279
- // typing.
280
- if ( // Most React components, whether functional or class-based, are
281
- // inherently functions in JavaScript, so a check for functions is
282
- // usually sufficient.
283
- typeof componentOrOptions === "function" || // However, the return of React.forwardRef is not a function,
284
- // so we also have to cope with that.
285
- // A forwardRef has $$typeof = Symbol(react.forward_ref) and a
286
- // render function.
287
- // $FlowIgnore[prop-missing]
288
- typeof componentOrOptions.render === "function") {
162
+ if (typeof componentOrOptions === "function" || typeof componentOrOptions.render === "function") {
289
163
  return {
290
- // $FlowIgnore[incompatible-return]
291
164
  component: componentOrOptions
292
165
  };
293
- } // We can't test for React.ComponentType at runtime.
294
- // Let's assume our simple heuristic above is sufficient.
295
- // $FlowIgnore[incompatible-return]
296
-
166
+ }
297
167
 
298
168
  return componentOrOptions;
299
169
  };
300
- /**
301
- * Describe a group of fixtures for a given component.
302
- *
303
- * Only one `fixtures` call should be used per fixture file as it returns
304
- * the exports for that file.
305
- *
306
- * @param {FixtureOptions<TProps>} options Options describing the
307
- * fixture group.
308
- * @param {FixtureFn<TProps> => void} fn A function that provides a `fixture`
309
- * function for defining fixtures.
310
- * @returns {Exports} The object to be exported as `module.exports`.
311
- *
312
- * TODO(somewhatabstract): Determine a way around this requirement so we
313
- * can support named exports and default exports via the adapters in a
314
- * deterministic way. Currently this is imposed on us because of how
315
- * storybook, the popular framework, uses both default and named exports for
316
- * its interface.
317
- */
318
-
319
170
 
320
171
  const fixtures = (componentOrOptions, fn) => {
321
172
  var _additionalAdapterOpt;
@@ -330,13 +181,12 @@ const fixtures = (componentOrOptions, fn) => {
330
181
  description: groupDescription,
331
182
  defaultWrapper,
332
183
  additionalAdapterOptions
333
- } = normalizeOptions(componentOrOptions); // 1. Create a new adapter group.
334
-
184
+ } = normalizeOptions(componentOrOptions);
335
185
  const group = adapter.declareGroup({
336
186
  title,
337
187
  description: groupDescription,
338
188
  getDefaultTitle: () => component.displayName || component.name || "Component"
339
- }); // 2. Invoke fn with a function that can add a new fixture.
189
+ });
340
190
 
341
191
  const addFixture = (description, props, wrapper = null) => {
342
192
  var _ref;
@@ -348,22 +198,138 @@ const fixtures = (componentOrOptions, fn) => {
348
198
  });
349
199
  };
350
200
 
351
- fn(addFixture); // 3. Combine the adapter options from the fixture group with the
352
- // defaults from our setup.
353
-
201
+ fn(addFixture);
354
202
  const groupAdapterOverrides = (_additionalAdapterOpt = additionalAdapterOptions == null ? void 0 : additionalAdapterOptions[adapter.name]) != null ? _additionalAdapterOpt : {};
355
- const combinedAdapterOptions = combineOptions(defaultAdapterOptions, groupAdapterOverrides); // 4. Call close on the group and return the result.
356
-
203
+ const combinedAdapterOptions = combineOptions(defaultAdapterOptions, groupAdapterOverrides);
357
204
  return group.closeGroup(combinedAdapterOptions);
358
205
  };
359
206
 
360
- const safeHasOwnProperty = (obj, prop) => // Flow really shouldn't be raising this error here.
361
- // $FlowFixMe[method-unbinding]
362
- Object.prototype.hasOwnProperty.call(obj, prop); // TODO(somewhatabstract, FEI-4268): use a third-party library to do this and
363
- // possibly make it also support the jest `jest.objectContaining` type matching
364
- // to simplify mock declaration (note that it would need to work in regular
365
- // 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);
366
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);
367
333
 
368
334
  const areObjectsEqual = (a, b) => {
369
335
  if (a === b) {
@@ -397,172 +363,27 @@ const areObjectsEqual = (a, b) => {
397
363
  };
398
364
 
399
365
  const gqlRequestMatchesMock = (mock, operation, variables, context) => {
400
- // If they don't represent the same operation, then they can't match.
401
- // NOTE: Operations can include more fields than id and type, but we only
402
- // care about id and type. The rest is ignored.
403
366
  if (mock.operation.id !== operation.id || mock.operation.type !== operation.type) {
404
367
  return false;
405
- } // We do a loose match, so if the lhs doesn't define variables,
406
- // we just assume it matches everything.
407
-
368
+ }
408
369
 
409
370
  if (mock.variables != null) {
410
- // Variables have to match.
411
371
  if (!areObjectsEqual(mock.variables, variables)) {
412
372
  return false;
413
373
  }
414
- } // We do a loose match, so if the lhs doesn't define context,
415
- // we just assume it matches everything.
416
-
374
+ }
417
375
 
418
376
  if (mock.context != null) {
419
- // Context has to match.
420
377
  if (!areObjectsEqual(mock.context, context)) {
421
378
  return false;
422
379
  }
423
- } // If we get here, we have a match.
424
-
380
+ }
425
381
 
426
382
  return true;
427
383
  };
428
384
 
429
- /**
430
- * Helpers to define rejection states for mocking GQL requests.
431
- */
432
- const RespondWith = Object.freeze({
433
- data: data => ({
434
- type: "data",
435
- data
436
- }),
437
- unparseableBody: () => ({
438
- type: "parse"
439
- }),
440
- abortedRequest: () => ({
441
- type: "abort"
442
- }),
443
- errorStatusCode: statusCode => {
444
- if (statusCode < 300) {
445
- throw new Error(`${statusCode} is not a valid error status code`);
446
- }
447
-
448
- return {
449
- type: "status",
450
- statusCode
451
- };
452
- },
453
- nonGraphQLBody: () => ({
454
- type: "invalid"
455
- }),
456
- graphQLErrors: errorMessages => ({
457
- type: "graphql",
458
- errors: errorMessages
459
- })
460
- });
461
- /**
462
- * Turns an ErrorResponse value in an actual Response that will invoke
463
- * that error.
464
- */
465
-
466
- const makeGqlMockResponse = response => {
467
- switch (response.type) {
468
- case "data":
469
- return Promise.resolve({
470
- status: 200,
471
- text: () => Promise.resolve(JSON.stringify({
472
- data: response.data
473
- }))
474
- });
475
-
476
- case "parse":
477
- return Promise.resolve({
478
- status: 200,
479
- text: () => Promise.resolve("INVALID JSON")
480
- });
481
-
482
- case "abort":
483
- const abortError = new Error("Mock request aborted");
484
- abortError.name = "AbortError";
485
- return Promise.reject(abortError);
486
-
487
- case "status":
488
- return Promise.resolve({
489
- status: response.statusCode,
490
- text: () => Promise.resolve(JSON.stringify({}))
491
- });
492
-
493
- case "invalid":
494
- return Promise.resolve({
495
- status: 200,
496
- text: () => Promise.resolve(JSON.stringify({
497
- valid: "json",
498
- that: "is not a valid graphql response"
499
- }))
500
- });
501
-
502
- case "graphql":
503
- return Promise.resolve({
504
- status: 200,
505
- text: () => Promise.resolve(JSON.stringify({
506
- errors: response.errors.map(e => ({
507
- message: e
508
- }))
509
- }))
510
- });
511
-
512
- default:
513
- throw new Error(`Unknown response type: ${response.type}`);
514
- }
515
- };
516
-
517
- /**
518
- * A mock for the fetch function passed to GqlRouter.
519
- */
520
- const mockGqlFetch = () => {
521
- // We want this to work in jest and in fixtures to make life easy for folks.
522
- // This is the array of mocked operations that we will traverse and
523
- // manipulate.
524
- const mocks = []; // What we return has to be a drop in for the fetch function that is
525
- // provided to `GqlRouter` which is how folks will then use this mock.
526
-
527
- const gqlFetchMock = (operation, variables, context) => {
528
- // Iterate our mocked operations and find the first one that matches.
529
- for (const mock of mocks) {
530
- if (mock.onceOnly && mock.used) {
531
- // This is a once-only mock and it has been used, so skip it.
532
- continue;
533
- }
534
-
535
- if (gqlRequestMatchesMock(mock.operation, operation, variables, context)) {
536
- mock.used = true;
537
- return mock.response();
538
- }
539
- } // Default is to reject with some helpful info on what request
540
- // we rejected.
541
-
542
-
543
- return Promise.reject(new Error(`No matching GraphQL mock response found for request:
544
- Operation: ${operation.type} ${operation.id}
385
+ const mockGqlFetch = () => mockRequester(gqlRequestMatchesMock, (operation, variables, context) => `Operation: ${operation.type} ${operation.id}
545
386
  Variables: ${variables == null ? "None" : JSON.stringify(variables, null, 2)}
546
- Context: ${JSON.stringify(context, null, 2)}`));
547
- };
548
-
549
- const addMockedOperation = (operation, response, onceOnly) => {
550
- const mockResponse = () => makeGqlMockResponse(response);
551
-
552
- mocks.push({
553
- operation,
554
- response: mockResponse,
555
- onceOnly,
556
- used: false
557
- });
558
- return gqlFetchMock;
559
- };
560
-
561
- gqlFetchMock.mockOperation = (operation, response) => addMockedOperation(operation, response, false);
562
-
563
- gqlFetchMock.mockOperationOnce = (operation, response) => addMockedOperation(operation, response, true);
564
-
565
- return gqlFetchMock;
566
- };
387
+ Context: ${JSON.stringify(context, null, 2)}`);
567
388
 
568
- export { RespondWith, adapters, fixtures, mockGqlFetch, setup as setupFixtures };
389
+ export { RespondWith, adapters, fixtures, mockFetch, mockGqlFetch, setup as setupFixtures };