@khanacademy/wonder-blocks-testing 2.0.8 → 4.0.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/es/index.js +255 -140
  3. package/dist/index.js +304 -145
  4. package/package.json +2 -1
  5. package/src/{gql/__tests__/make-gql-mock-response.test.js → __tests__/make-mock-response.test.js} +196 -34
  6. package/src/__tests__/mock-requester.test.js +213 -0
  7. package/src/__tests__/response-impl.test.js +47 -0
  8. package/src/fetch/__tests__/__snapshots__/mock-fetch.test.js.snap +29 -0
  9. package/src/fetch/__tests__/fetch-request-matches-mock.test.js +99 -0
  10. package/src/fetch/__tests__/mock-fetch.test.js +84 -0
  11. package/src/fetch/fetch-request-matches-mock.js +43 -0
  12. package/src/fetch/mock-fetch.js +19 -0
  13. package/src/fetch/types.js +18 -0
  14. package/src/fixtures/__tests__/fixtures.test.js +66 -22
  15. package/src/fixtures/adapters/__tests__/adapter-group.test.js +24 -0
  16. package/src/fixtures/adapters/__tests__/adapter.test.js +6 -0
  17. package/src/fixtures/adapters/storybook.js +4 -1
  18. package/src/fixtures/fixtures.basic.stories.js +1 -1
  19. package/src/fixtures/fixtures.defaultwrapper.stories.js +1 -1
  20. package/src/fixtures/fixtures.js +47 -3
  21. package/src/fixtures/types.js +10 -1
  22. package/src/gql/__tests__/mock-gql-fetch.test.js +24 -15
  23. package/src/gql/__tests__/wb-data-integration.test.js +7 -4
  24. package/src/gql/mock-gql-fetch.js +9 -80
  25. package/src/gql/types.js +11 -10
  26. package/src/index.js +9 -3
  27. package/src/make-mock-response.js +150 -0
  28. package/src/mock-requester.js +75 -0
  29. package/src/response-impl.js +9 -0
  30. package/src/types.js +39 -0
  31. package/src/gql/make-gql-mock-response.js +0 -124
@@ -0,0 +1,99 @@
1
+ // @flow
2
+ import {Request} from "node-fetch";
3
+ import {fetchRequestMatchesMock} from "../fetch-request-matches-mock.js";
4
+
5
+ const TEST_URL = "http://example.com/foo?querya=1&queryb=elephants#fragment";
6
+
7
+ describe("#fetchRequestMatchesMock", () => {
8
+ it("should throw if mock is not valid", () => {
9
+ // Arrange
10
+ const mock = {
11
+ operation: {
12
+ id: "foo",
13
+ type: "query",
14
+ },
15
+ };
16
+
17
+ // Act
18
+ const underTest = () =>
19
+ fetchRequestMatchesMock((mock: any), TEST_URL, null);
20
+
21
+ // Assert
22
+ expect(underTest).toThrowErrorMatchingInlineSnapshot(
23
+ `"Unsupported mock operation: {\\"operation\\":{\\"id\\":\\"foo\\",\\"type\\":\\"query\\"}}"`,
24
+ );
25
+ });
26
+
27
+ it("should throw if input is not valid", () => {
28
+ // Arrange
29
+ const mock = "http://example.com/foo";
30
+
31
+ // Act
32
+ const underTest = () =>
33
+ fetchRequestMatchesMock(
34
+ mock,
35
+ ({not: "a valid request"}: any),
36
+ null,
37
+ );
38
+
39
+ // Assert
40
+ expect(underTest).toThrowErrorMatchingInlineSnapshot(
41
+ `"Unsupported input type"`,
42
+ );
43
+ });
44
+
45
+ describe.each([TEST_URL, new URL(TEST_URL), new Request(TEST_URL)])(
46
+ "for valid inputs",
47
+ (input) => {
48
+ it("should return false if mock is a string and it does not match the fetched URL", () => {
49
+ // Arrange
50
+ const mock = "http://example.com/bar";
51
+
52
+ // Act
53
+ const result = fetchRequestMatchesMock(mock, input, null);
54
+
55
+ // Assert
56
+ expect(result).toBe(false);
57
+ });
58
+
59
+ it("should return false if the mock is a regular expression and it doesn't match the fetched URL", () => {
60
+ // Arrange
61
+ const mock = /\/bar/;
62
+
63
+ // Act
64
+ const result = fetchRequestMatchesMock(mock, input, null);
65
+
66
+ // Assert
67
+ expect(result).toBe(false);
68
+ });
69
+
70
+ it("should return true if the mock is a string and matches the fetched URL", () => {
71
+ // Arrange
72
+ const mock = TEST_URL;
73
+
74
+ // Act
75
+ const result = fetchRequestMatchesMock(mock, input, null);
76
+
77
+ // Assert
78
+ expect(result).toBe(true);
79
+ });
80
+
81
+ it.each([
82
+ /http:\/\/example.com\/foo/,
83
+ /queryb=elephants/,
84
+ /^.*#fragment$/,
85
+ ])(
86
+ "should return true if the mock is a %s and matches the fetched URL",
87
+ (regex) => {
88
+ // Arrange
89
+
90
+ // Act
91
+ const result = fetchRequestMatchesMock(regex, input, null);
92
+
93
+ // Assert
94
+ expect(result).toBe(true);
95
+ },
96
+ );
97
+ },
98
+ );
99
+ });
@@ -0,0 +1,84 @@
1
+ // @flow
2
+ import {Request} from "node-fetch";
3
+ import {RespondWith} from "../../make-mock-response.js";
4
+ import {mockFetch} from "../mock-fetch.js";
5
+
6
+ describe("#mockFetch", () => {
7
+ it.each`
8
+ input | init
9
+ ${"http://example.com/foo"} | ${undefined}
10
+ ${new URL("http://example.com/foo")} | ${{method: "GET"}}
11
+ ${new Request("http://example.com/foo")} | ${{method: "POST"}}
12
+ `(
13
+ "should reject with a useful error when there are no matching mocks for %s",
14
+ async ({input, init}) => {
15
+ // Arrange
16
+ const mockFn = mockFetch();
17
+
18
+ // Act
19
+ const result = mockFn(input, init);
20
+
21
+ // Assert
22
+ await expect(result).rejects.toThrowErrorMatchingSnapshot();
23
+ },
24
+ );
25
+
26
+ describe("mockOperation", () => {
27
+ it("should match a similar operation", async () => {
28
+ // Arrange
29
+ const mockFn = mockFetch();
30
+ const operation = "http://example.com/foo";
31
+
32
+ // Act
33
+ mockFn.mockOperation(operation, RespondWith.text("TADA!"));
34
+ const result = mockFn(operation, {method: "GET"});
35
+
36
+ // Assert
37
+ await expect(result).resolves.toBeDefined();
38
+ });
39
+
40
+ it("should not match a different operation", async () => {
41
+ // Arrange
42
+ const mockFn = mockFetch();
43
+ const operation = "http://example.com/foo";
44
+
45
+ // Act
46
+ mockFn.mockOperation(operation, RespondWith.text("TADA!"));
47
+ const result = mockFn("http://example.com/bar", {method: "GET"});
48
+
49
+ // Assert
50
+ await expect(result).rejects.toThrowError();
51
+ });
52
+ });
53
+
54
+ describe("mockOperationOnce", () => {
55
+ it("should match once", async () => {
56
+ // Arrange
57
+ const mockFn = mockFetch();
58
+ const operation = "http://example.com/foo";
59
+
60
+ // Act
61
+ mockFn.mockOperationOnce(operation, RespondWith.text("TADA!"));
62
+ const result = mockFn(operation, {method: "GET"});
63
+
64
+ // Assert
65
+ await expect(result).resolves.toBeDefined();
66
+ });
67
+
68
+ it("should only match once", async () => {
69
+ // Arrange
70
+ const mockFn = mockFetch();
71
+ const operation = "http://example.com/foo";
72
+
73
+ // Act
74
+ mockFn.mockOperationOnce(operation, RespondWith.text("TADA!"));
75
+ const result = Promise.all([
76
+ mockFn(operation, {method: "GET"}),
77
+ mockFn(operation, {method: "POST"}),
78
+ ]);
79
+
80
+ // Assert
81
+ await expect(result).rejects.toThrowError();
82
+ });
83
+ });
84
+ });
@@ -0,0 +1,43 @@
1
+ // @flow
2
+ import type {FetchMockOperation} from "./types.js";
3
+
4
+ /**
5
+ * Get the URL from the given RequestInfo.
6
+ *
7
+ * Since we could be running in Node or in JSDOM, we don't check instance
8
+ * types, but just use a heuristic so that this works without knowing what
9
+ * was polyfilling things.
10
+ */
11
+ const getHref = (input: RequestInfo): string => {
12
+ if (typeof input === "string") {
13
+ return input;
14
+ } else if (typeof input.url === "string") {
15
+ return input.url;
16
+ } else if (typeof input.href === "string") {
17
+ return input.href;
18
+ } else {
19
+ throw new Error(`Unsupported input type`);
20
+ }
21
+ };
22
+
23
+ /**
24
+ * Determines if a given fetch invocation matches the given mock.
25
+ */
26
+ export const fetchRequestMatchesMock = (
27
+ mock: FetchMockOperation,
28
+ input: RequestInfo,
29
+ init: ?RequestOptions,
30
+ ): boolean => {
31
+ // Currently, we only match on the input portion.
32
+ // This can be a Request, a URL, or a string.
33
+ const href = getHref(input);
34
+
35
+ // Our mock operation is either a string for an exact match, or a regex.
36
+ if (typeof mock === "string") {
37
+ return href === mock;
38
+ } else if (mock instanceof RegExp) {
39
+ return mock.test(href);
40
+ } else {
41
+ throw new Error(`Unsupported mock operation: ${JSON.stringify(mock)}`);
42
+ }
43
+ };
@@ -0,0 +1,19 @@
1
+ // @flow
2
+ import {fetchRequestMatchesMock} from "./fetch-request-matches-mock.js";
3
+ import {mockRequester} from "../mock-requester.js";
4
+ import type {FetchMockFn, FetchMockOperation} from "./types.js";
5
+
6
+ /**
7
+ * A mock for the fetch function passed to GqlRouter.
8
+ */
9
+ export const mockFetch = (): FetchMockFn =>
10
+ mockRequester<FetchMockOperation, _>(
11
+ fetchRequestMatchesMock,
12
+ (input, init) =>
13
+ `Input: ${
14
+ typeof input === "string"
15
+ ? input
16
+ : JSON.stringify(input, null, 2)
17
+ }
18
+ Options: ${init == null ? "None" : JSON.stringify(init, null, 2)}`,
19
+ );
@@ -0,0 +1,18 @@
1
+ //@flow
2
+ import type {OperationMock} from "../types.js";
3
+ import type {MockResponse} from "../make-mock-response.js";
4
+
5
+ export type FetchMockOperation = RegExp | string;
6
+
7
+ type FetchMockOperationFn = (
8
+ operation: FetchMockOperation,
9
+ response: MockResponse<any>,
10
+ ) => FetchMockFn;
11
+
12
+ export type FetchMockFn = {|
13
+ (input: RequestInfo, init?: RequestOptions): Promise<Response>,
14
+ mockOperation: FetchMockOperationFn,
15
+ mockOperationOnce: FetchMockOperationFn,
16
+ |};
17
+
18
+ export type FetchMock = OperationMock<FetchMockOperation>;
@@ -1,4 +1,5 @@
1
1
  // @flow
2
+ import * as React from "react";
2
3
  import * as SetupModule from "../setup.js";
3
4
  import * as CombineOptionsModule from "../combine-options.js";
4
5
  import {fixtures} from "../fixtures.js";
@@ -11,6 +12,28 @@ describe("#fixtures", () => {
11
12
  jest.clearAllMocks();
12
13
  });
13
14
 
15
+ it("should declare a group on the configured adapter based off the given component", () => {
16
+ // Arrange
17
+ const fakeGroup = {
18
+ closeGroup: jest.fn(),
19
+ };
20
+ const adapter = {
21
+ declareGroup: jest.fn().mockReturnValue(fakeGroup),
22
+ name: "testadapter",
23
+ };
24
+ jest.spyOn(SetupModule, "getConfiguration").mockReturnValue({
25
+ adapter,
26
+ });
27
+
28
+ // Act
29
+ fixtures(() => "COMPONENT", jest.fn());
30
+
31
+ // Assert
32
+ expect(adapter.declareGroup).toHaveBeenCalledWith({
33
+ getDefaultTitle: expect.any(Function),
34
+ });
35
+ });
36
+
14
37
  it("should declare a group on the configured adapter with the given title and description", () => {
15
38
  // Arrange
16
39
  const fakeGroup = {
@@ -38,6 +61,7 @@ describe("#fixtures", () => {
38
61
  expect(adapter.declareGroup).toHaveBeenCalledWith({
39
62
  title: "TITLE",
40
63
  description: "DESCRIPTION",
64
+ getDefaultTitle: expect.any(Function),
41
65
  });
42
66
  });
43
67
 
@@ -63,11 +87,11 @@ describe("#fixtures", () => {
63
87
  },
64
88
  jest.fn(),
65
89
  );
90
+ const {getDefaultTitle} = adapter.declareGroup.mock.calls[0][0];
91
+ const result = getDefaultTitle();
66
92
 
67
93
  // Assert
68
- expect(adapter.declareGroup).toHaveBeenCalledWith({
69
- title: "DISPLAYNAME",
70
- });
94
+ expect(result).toBe("DISPLAYNAME");
71
95
  });
72
96
 
73
97
  it("should default the title to the component.name in the absence of component.displayName", () => {
@@ -87,17 +111,12 @@ describe("#fixtures", () => {
87
111
  };
88
112
 
89
113
  // Act
90
- fixtures(
91
- {
92
- component,
93
- },
94
- jest.fn(),
95
- );
114
+ fixtures(component, jest.fn());
115
+ const {getDefaultTitle} = adapter.declareGroup.mock.calls[0][0];
116
+ const result = getDefaultTitle();
96
117
 
97
118
  // Assert
98
- expect(adapter.declareGroup).toHaveBeenCalledWith({
99
- title: "FUNCTIONNAME",
100
- });
119
+ expect(result).toBe("FUNCTIONNAME");
101
120
  });
102
121
 
103
122
  it("should default the title to 'Component' in the absence of component.name", () => {
@@ -114,17 +133,12 @@ describe("#fixtures", () => {
114
133
  });
115
134
 
116
135
  // Act
117
- fixtures(
118
- {
119
- component: ({}: any),
120
- },
121
- jest.fn(),
122
- );
136
+ fixtures(() => "test", jest.fn());
137
+ const {getDefaultTitle} = adapter.declareGroup.mock.calls[0][0];
138
+ const result = getDefaultTitle();
123
139
 
124
140
  // Assert
125
- expect(adapter.declareGroup).toHaveBeenCalledWith({
126
- title: "Component",
127
- });
141
+ expect(result).toBe("Component");
128
142
  });
129
143
 
130
144
  it("should invoke the passed fn with function argument", () => {
@@ -271,7 +285,7 @@ describe("#fixtures", () => {
271
285
  });
272
286
 
273
287
  describe("injected fixture fn", () => {
274
- it("should call group.declareFixture with description and props getter", () => {
288
+ it("should call group.declareFixture with description, props getter, and component", () => {
275
289
  // Arrange
276
290
  const fakeGroup = {
277
291
  declareFixture: jest.fn(),
@@ -306,6 +320,36 @@ describe("#fixtures", () => {
306
320
  });
307
321
  });
308
322
 
323
+ it("should call group.declareFixture with component if component is forward ref", () => {
324
+ // Arrange
325
+ const fakeGroup = {
326
+ declareFixture: jest.fn(),
327
+ closeGroup: jest.fn(),
328
+ };
329
+ const adapter = {
330
+ declareGroup: jest.fn().mockReturnValue(fakeGroup),
331
+ name: "testadapter",
332
+ };
333
+ jest.spyOn(SetupModule, "getConfiguration").mockReturnValue({
334
+ adapter,
335
+ });
336
+ const component = React.forwardRef((props, ref) => (
337
+ <div {...props} ref={ref} />
338
+ ));
339
+
340
+ // Act
341
+ fixtures(component, (fixture) => {
342
+ fixture("FIXTURE_DESCRIPTION", {these: "areProps"});
343
+ });
344
+
345
+ // Assert
346
+ expect(fakeGroup.declareFixture).toHaveBeenCalledWith({
347
+ description: "FIXTURE_DESCRIPTION",
348
+ getProps: expect.any(Function),
349
+ component,
350
+ });
351
+ });
352
+
309
353
  it("should pass wrapper component to group.declareFixture", () => {
310
354
  // Arrange
311
355
  const fakeGroup = {
@@ -12,6 +12,9 @@ describe("AdapterGroup", () => {
12
12
  new AdapterGroup(badCloseGroupFn, {
13
13
  title: "TITLE",
14
14
  description: null,
15
+ getDefaultTitle: () => {
16
+ throw new Error("NOT IMPLEMENTED");
17
+ },
15
18
  });
16
19
 
17
20
  expect(act).toThrowErrorMatchingInlineSnapshot(
@@ -40,6 +43,9 @@ describe("AdapterGroup", () => {
40
43
  const groupOptions = {
41
44
  title: "TITLE",
42
45
  description: null,
46
+ getDefaultTitle: () => {
47
+ throw new Error("NOT IMPLEMENTED");
48
+ },
43
49
  };
44
50
  const adapterGroup = new AdapterGroup(closeGroupFn, groupOptions);
45
51
 
@@ -56,6 +62,9 @@ describe("AdapterGroup", () => {
56
62
  const groupOptions = {
57
63
  title: "TITLE",
58
64
  description: null,
65
+ getDefaultTitle: () => {
66
+ throw new Error("NOT IMPLEMENTED");
67
+ },
59
68
  };
60
69
  const adapterSpecificOptions = {
61
70
  adapterSpecificOption: "adapterSpecificOption",
@@ -79,6 +88,9 @@ describe("AdapterGroup", () => {
79
88
  const groupOptions = {
80
89
  title: "TITLE",
81
90
  description: "DESCRIPTION",
91
+ getDefaultTitle: () => {
92
+ throw new Error("NOT IMPLEMENTED");
93
+ },
82
94
  };
83
95
  const adapterGroup = new AdapterGroup(closeGroupFn, groupOptions);
84
96
  const fixture = {
@@ -103,6 +115,9 @@ describe("AdapterGroup", () => {
103
115
  const groupOptions = {
104
116
  title: "TITLE",
105
117
  description: null,
118
+ getDefaultTitle: () => {
119
+ throw new Error("NOT IMPLEMENTED");
120
+ },
106
121
  };
107
122
  const adapterGroup = new AdapterGroup(closeGroupFn, groupOptions);
108
123
  adapterGroup.closeGroup();
@@ -126,6 +141,9 @@ describe("AdapterGroup", () => {
126
141
  const groupOptions = {
127
142
  title: "TITLE",
128
143
  description: null,
144
+ getDefaultTitle: () => {
145
+ throw new Error("NOT IMPLEMENTED");
146
+ },
129
147
  };
130
148
  const adapterGroup = new AdapterGroup(
131
149
  closeGroupFn,
@@ -147,6 +165,9 @@ describe("AdapterGroup", () => {
147
165
  const groupOptions = {
148
166
  title: "TITLE",
149
167
  description: "DESCRIPTION",
168
+ getDefaultTitle: () => {
169
+ throw new Error("NOT IMPLEMENTED");
170
+ },
150
171
  };
151
172
  const adapterGroup = new AdapterGroup(closeGroupFn, groupOptions);
152
173
  const fixture1 = {
@@ -178,6 +199,9 @@ describe("AdapterGroup", () => {
178
199
  const groupOptions = {
179
200
  title: "TITLE",
180
201
  description: null,
202
+ getDefaultTitle: () => {
203
+ throw new Error("NOT IMPLEMENTED");
204
+ },
181
205
  };
182
206
  const adapterGroup = new AdapterGroup(closeGroupFn, groupOptions);
183
207
  adapterGroup.closeGroup();
@@ -56,6 +56,9 @@ describe("Adapter", () => {
56
56
  const options = {
57
57
  title: "group_title",
58
58
  description: "group_description",
59
+ getDefaultTitle: () => {
60
+ throw new Error("NOT IMPLEMENTED");
61
+ },
59
62
  };
60
63
  const adapterGroupSpy = jest
61
64
  .spyOn(AdapterGroupModule, "AdapterGroup")
@@ -82,6 +85,9 @@ describe("Adapter", () => {
82
85
  const result = adapter.declareGroup({
83
86
  title: "group_title",
84
87
  description: "group_description",
88
+ getDefaultTitle: () => {
89
+ throw new Error("NOT IMPLEMENTED");
90
+ },
85
91
  });
86
92
 
87
93
  // Assert
@@ -26,7 +26,7 @@ export type StorybookOptions = {|
26
26
  |};
27
27
 
28
28
  type DefaultExport = {|
29
- title: string,
29
+ title?: ?string,
30
30
  ...StorybookOptions,
31
31
  |};
32
32
 
@@ -47,6 +47,9 @@ export const getAdapter: AdapterFactory<StorybookOptions, Exports<any>> = (
47
47
  {
48
48
  title,
49
49
  description: groupDescription,
50
+ // We don't use the default title in Storybook as storybook
51
+ // will generate titles for us if we pass a nullish title.
52
+ getDefaultTitle: _,
50
53
  }: $ReadOnly<AdapterGroupOptions>,
51
54
  adapterOptions: ?$ReadOnly<StorybookOptions>,
52
55
  declaredFixtures: $ReadOnlyArray<AdapterFixtureOptions<TProps>>,
@@ -3,7 +3,7 @@ import * as React from "react";
3
3
 
4
4
  import {setupFixtures, fixtures, adapters} from "../index.js";
5
5
 
6
- // Normally would call setup from the storybook.main.js for a project.
6
+ // Normally would call setup from the storybook.preview.js for a project.
7
7
  setupFixtures({
8
8
  adapter: adapters.storybook(),
9
9
  });
@@ -3,7 +3,7 @@ import * as React from "react";
3
3
 
4
4
  import {setupFixtures, fixtures, adapters} from "../index.js";
5
5
 
6
- // Normally would call setup from the storybook.main.js for a project.
6
+ // Normally would call setup from the storybook.preview.js for a project.
7
7
  setupFixtures({
8
8
  adapter: adapters.storybook(),
9
9
  });
@@ -9,6 +9,45 @@ type FixtureProps<TProps: {...}> =
9
9
  | $ReadOnly<TProps>
10
10
  | ((options: $ReadOnly<GetPropsOptions>) => $ReadOnly<TProps>);
11
11
 
12
+ const normalizeOptions = <TProps: {...}>(
13
+ componentOrOptions:
14
+ | React.ComponentType<TProps>
15
+ | $ReadOnly<FixturesOptions<TProps>>,
16
+ ): $ReadOnly<FixturesOptions<TProps>> => {
17
+ // To differentiate between a React component and a FixturesOptions object,
18
+ // we have to do some type checking.
19
+ //
20
+ // Alternatives I considered were:
21
+ // - Use an additional parameter for the options and then do an arg number
22
+ // check, but that always makes typing a function harder and often breaks
23
+ // types. I didn't want that battle today.
24
+ // - Use a tuple when providing component and options with the first element
25
+ // being the component and the second being the options. However that
26
+ // feels like an obscure API even though it's really easy to do the
27
+ // typing.
28
+ if (
29
+ // Most React components, whether functional or class-based, are
30
+ // inherently functions in JavaScript, so a check for functions is
31
+ // usually sufficient.
32
+ typeof componentOrOptions === "function" ||
33
+ // However, the return of React.forwardRef is not a function,
34
+ // so we also have to cope with that.
35
+ // A forwardRef has $$typeof = Symbol(react.forward_ref) and a
36
+ // render function.
37
+ // $FlowIgnore[prop-missing]
38
+ typeof componentOrOptions.render === "function"
39
+ ) {
40
+ return {
41
+ // $FlowIgnore[incompatible-return]
42
+ component: componentOrOptions,
43
+ };
44
+ }
45
+ // We can't test for React.ComponentType at runtime.
46
+ // Let's assume our simple heuristic above is sufficient.
47
+ // $FlowIgnore[incompatible-return]
48
+ return componentOrOptions;
49
+ };
50
+
12
51
  /**
13
52
  * Describe a group of fixtures for a given component.
14
53
  *
@@ -28,7 +67,9 @@ type FixtureProps<TProps: {...}> =
28
67
  * its interface.
29
68
  */
30
69
  export const fixtures = <TProps: {...}>(
31
- options: $ReadOnly<FixturesOptions<TProps>>,
70
+ componentOrOptions:
71
+ | React.ComponentType<TProps>
72
+ | $ReadOnly<FixturesOptions<TProps>>,
32
73
  fn: (
33
74
  fixture: (
34
75
  description: string,
@@ -38,18 +79,21 @@ export const fixtures = <TProps: {...}>(
38
79
  ) => void,
39
80
  ): ?$ReadOnly<mixed> => {
40
81
  const {adapter, defaultAdapterOptions} = getConfiguration();
82
+
41
83
  const {
42
84
  title,
43
85
  component,
44
86
  description: groupDescription,
45
87
  defaultWrapper,
46
88
  additionalAdapterOptions,
47
- } = options;
89
+ } = normalizeOptions(componentOrOptions);
48
90
 
49
91
  // 1. Create a new adapter group.
50
92
  const group = adapter.declareGroup<TProps>({
51
- title: title || component.displayName || component.name || "Component",
93
+ title,
52
94
  description: groupDescription,
95
+ getDefaultTitle: () =>
96
+ component.displayName || component.name || "Component",
53
97
  });
54
98
 
55
99
  // 2. Invoke fn with a function that can add a new fixture.
@@ -81,13 +81,22 @@ export type AdapterFixtureOptions<TProps: {...}> = {|
81
81
  export type AdapterGroupOptions = {|
82
82
  /**
83
83
  * The title of the group.
84
+ *
85
+ * If omitted, the adapter is free to generate a default or ask for one
86
+ * using the passed getDefaultTitle() function.
84
87
  */
85
- +title: string,
88
+ +title: ?string,
86
89
 
87
90
  /**
88
91
  * Description of the group.
89
92
  */
90
93
  +description: ?string,
94
+
95
+ /**
96
+ * Function that will generate a default title if an adapter cannot
97
+ * generate its own.
98
+ */
99
+ +getDefaultTitle: () => string,
91
100
  |};
92
101
 
93
102
  /**