From 9bd0404bac48ef71481328810e6830b26d89a348 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 26 Jul 2023 10:19:45 +0200 Subject: [PATCH 01/15] start on tests --- .../src/utils/StoryIndexGenerator.test.ts | 1471 +++++++++++++++++ 1 file changed, 1471 insertions(+) create mode 100644 code/lib/core-server/src/utils/StoryIndexGenerator.test.ts diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts new file mode 100644 index 000000000000..65c425c0a723 --- /dev/null +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts @@ -0,0 +1,1471 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +/// ; + +/** + * @jest-environment node + */ + +import path from 'path'; +import fs from 'fs-extra'; +import { normalizeStoriesEntry } from '@storybook/core-common'; +import type { + Indexer, + NormalizedStoriesSpecifier, + StoryIndexEntry, + StoryIndexer, +} from '@storybook/types'; +import { loadCsf, getStorySortParameter } from '@storybook/csf-tools'; +import { toId } from '@storybook/csf'; +import { logger, once } from '@storybook/node-logger'; + +import type { StoryIndexGeneratorOptions } from './StoryIndexGenerator'; +import { StoryIndexGenerator } from './StoryIndexGenerator'; + +jest.mock('@storybook/csf-tools'); +jest.mock('@storybook/csf', () => { + const csf = jest.requireActual('@storybook/csf'); + return { + ...csf, + toId: jest.fn(csf.toId), + }; +}); + +jest.mock('@storybook/node-logger'); + +const toIdMock = toId as jest.Mock>; +const loadCsfMock = loadCsf as jest.Mock>; +const getStorySortParameterMock = getStorySortParameter as jest.Mock< + ReturnType +>; + +const csfIndexer: StoryIndexer = { + test: /\.stories\.mdx$/, + indexer: async (fileName, opts) => { + const code = (await fs.readFile(fileName, 'utf-8')).toString(); + return loadCsf(code, { ...opts, fileName }).parse(); + }, +}; + +const storiesMdxIndexer: StoryIndexer = { + test: /\.stories\.mdx$/, + indexer: async (fileName, opts) => { + let code = (await fs.readFile(fileName, 'utf-8')).toString(); + const { compile } = await import('@storybook/mdx2-csf'); + code = await compile(code, {}); + return loadCsf(code, { ...opts, fileName }).parse(); + }, +}; + +const plainStoryIndexer: Indexer = { + test: /\.stories\.(m?js|ts)x?$/, + index: (fileName, opts) => { + return [ + { + type: 'story', + importPath: fileName, + key: 'default', + }, + ]; + + const code = (await fs.readFile(fileName, 'utf-8')).toString(); + return loadCsf(code, { ...opts, fileName }).parse(); + }, +}; + +const options: StoryIndexGeneratorOptions = { + configDir: path.join(__dirname, '__mockdata__'), + workingDir: path.join(__dirname, '__mockdata__'), + storyIndexers: [], + indexers: [ + plainIndexer, + // storiesMdxIndexer, + // csfIndexer, + ], + storiesV2Compatibility: false, + storyStoreV7: true, + docs: { defaultName: 'docs', autodocs: false }, +}; + +describe('StoryIndexGenerator with deprecated indexer API', () => { + beforeEach(() => { + const actual = jest.requireActual('@storybook/csf-tools'); + loadCsfMock.mockImplementation(actual.loadCsf); + jest.mocked(logger.warn).mockClear(); + jest.mocked(once.warn).mockClear(); + }); + describe('extraction', () => { + const storiesSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.(ts|js|mjs|jsx)', + options + ); + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/docs2/*.mdx', + options + ); + + describe('single file specifier', () => { + it('extracts stories from the right files', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.js', + options + ); + + const generator = new StoryIndexGenerator([specifier], options); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag", + "story", + ], + "title": "A", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + }); + describe('non-recursive specifier', () => { + it('extracts stories from the right files', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/*/*.stories.(ts|js|mjs|jsx)', + options + ); + + const generator = new StoryIndexGenerator([specifier], options); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "nested-button--story-one": Object { + "id": "nested-button--story-one", + "importPath": "./src/nested/Button.stories.ts", + "name": "Story One", + "tags": Array [ + "component-tag", + "story", + ], + "title": "nested/Button", + "type": "story", + }, + "second-nested-g--story-one": Object { + "id": "second-nested-g--story-one", + "importPath": "./src/second-nested/G.stories.ts", + "name": "Story One", + "tags": Array [ + "story", + ], + "title": "second-nested/G", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + }); + describe('recursive specifier', () => { + it('extracts stories from the right files', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/**/*.stories.(ts|js|mjs|jsx)', + options + ); + + const generator = new StoryIndexGenerator([specifier], options); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag", + "story", + ], + "title": "A", + "type": "story", + }, + "b--story-one": Object { + "id": "b--story-one", + "importPath": "./src/B.stories.ts", + "name": "Story One", + "tags": Array [ + "autodocs", + "story", + ], + "title": "B", + "type": "story", + }, + "d--story-one": Object { + "id": "d--story-one", + "importPath": "./src/D.stories.jsx", + "name": "Story One", + "tags": Array [ + "autodocs", + "story", + ], + "title": "D", + "type": "story", + }, + "first-nested-deeply-f--story-one": Object { + "id": "first-nested-deeply-f--story-one", + "importPath": "./src/first-nested/deeply/F.stories.js", + "name": "Story One", + "tags": Array [ + "story", + ], + "title": "first-nested/deeply/F", + "type": "story", + }, + "h--story-one": Object { + "id": "h--story-one", + "importPath": "./src/H.stories.mjs", + "name": "Story One", + "tags": Array [ + "autodocs", + "story", + ], + "title": "H", + "type": "story", + }, + "nested-button--story-one": Object { + "id": "nested-button--story-one", + "importPath": "./src/nested/Button.stories.ts", + "name": "Story One", + "tags": Array [ + "component-tag", + "story", + ], + "title": "nested/Button", + "type": "story", + }, + "second-nested-g--story-one": Object { + "id": "second-nested-g--story-one", + "importPath": "./src/second-nested/G.stories.ts", + "name": "Story One", + "tags": Array [ + "story", + ], + "title": "second-nested/G", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + }); + + describe('mdx tagged components', () => { + it('adds docs entry with docs enabled', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/nested/Page.stories.mdx', + options + ); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + }); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "page--docs": Object { + "id": "page--docs", + "importPath": "./src/nested/Page.stories.mdx", + "name": "docs", + "storiesImports": Array [], + "tags": Array [ + "stories-mdx", + "docs", + ], + "title": "Page", + "type": "docs", + }, + "page--story-one": Object { + "id": "page--story-one", + "importPath": "./src/nested/Page.stories.mdx", + "name": "StoryOne", + "tags": Array [ + "stories-mdx", + "story", + ], + "title": "Page", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + }); + + describe('autodocs', () => { + const autodocsOptions = { + ...options, + docs: { ...options.docs, autodocs: 'tag' as const }, + }; + it('generates an entry per CSF file with the autodocs tag', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/**/*.stories.(ts|js|mjs|jsx)', + options + ); + + const generator = new StoryIndexGenerator([specifier], autodocsOptions); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag", + "story", + ], + "title": "A", + "type": "story", + }, + "b--docs": Object { + "id": "b--docs", + "importPath": "./src/B.stories.ts", + "name": "docs", + "storiesImports": Array [], + "tags": Array [ + "autodocs", + "docs", + ], + "title": "B", + "type": "docs", + }, + "b--story-one": Object { + "id": "b--story-one", + "importPath": "./src/B.stories.ts", + "name": "Story One", + "tags": Array [ + "autodocs", + "story", + ], + "title": "B", + "type": "story", + }, + "d--docs": Object { + "id": "d--docs", + "importPath": "./src/D.stories.jsx", + "name": "docs", + "storiesImports": Array [], + "tags": Array [ + "autodocs", + "docs", + ], + "title": "D", + "type": "docs", + }, + "d--story-one": Object { + "id": "d--story-one", + "importPath": "./src/D.stories.jsx", + "name": "Story One", + "tags": Array [ + "autodocs", + "story", + ], + "title": "D", + "type": "story", + }, + "first-nested-deeply-f--story-one": Object { + "id": "first-nested-deeply-f--story-one", + "importPath": "./src/first-nested/deeply/F.stories.js", + "name": "Story One", + "tags": Array [ + "story", + ], + "title": "first-nested/deeply/F", + "type": "story", + }, + "h--docs": Object { + "id": "h--docs", + "importPath": "./src/H.stories.mjs", + "name": "docs", + "storiesImports": Array [], + "tags": Array [ + "autodocs", + "docs", + ], + "title": "H", + "type": "docs", + }, + "h--story-one": Object { + "id": "h--story-one", + "importPath": "./src/H.stories.mjs", + "name": "Story One", + "tags": Array [ + "autodocs", + "story", + ], + "title": "H", + "type": "story", + }, + "nested-button--story-one": Object { + "id": "nested-button--story-one", + "importPath": "./src/nested/Button.stories.ts", + "name": "Story One", + "tags": Array [ + "component-tag", + "story", + ], + "title": "nested/Button", + "type": "story", + }, + "second-nested-g--story-one": Object { + "id": "second-nested-g--story-one", + "importPath": "./src/second-nested/G.stories.ts", + "name": "Story One", + "tags": Array [ + "story", + ], + "title": "second-nested/G", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + + const autodocsTrueOptions = { + ...autodocsOptions, + docs: { + ...autodocsOptions.docs, + autodocs: true, + }, + }; + it('generates an entry for every CSF file when docsOptions.autodocs = true', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/**/*.stories.(ts|js|mjs|jsx)', + options + ); + + const generator = new StoryIndexGenerator([specifier], autodocsTrueOptions); + await generator.initialize(); + + expect(Object.keys((await generator.getIndex()).entries)).toMatchInlineSnapshot(` + Array [ + "a--docs", + "a--story-one", + "b--docs", + "b--story-one", + "d--docs", + "d--story-one", + "h--docs", + "h--story-one", + "first-nested-deeply-f--docs", + "first-nested-deeply-f--story-one", + "nested-button--docs", + "nested-button--story-one", + "second-nested-g--docs", + "second-nested-g--story-one", + ] + `); + }); + + it('adds the autodocs tag to the autogenerated docs entries', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/**/*.stories.(ts|js|mjs|jsx)', + options + ); + + const generator = new StoryIndexGenerator([specifier], autodocsTrueOptions); + await generator.initialize(); + + const index = await generator.getIndex(); + expect(index.entries['first-nested-deeply-f--docs'].tags).toEqual( + expect.arrayContaining(['autodocs']) + ); + }); + + it('throws an error if you attach a named MetaOf entry which clashes with a tagged autodocs entry', async () => { + const csfSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/B.stories.ts', + options + ); + + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './errors/MetaOfClashingDefaultName.mdx', + options + ); + + const generator = new StoryIndexGenerator([csfSpecifier, docsSpecifier], autodocsOptions); + await generator.initialize(); + + await expect(generator.getIndex()).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unable to index ./errors/MetaOfClashingDefaultName.mdx,./src/B.stories.ts"` + ); + }); + + it('throws an error if you attach a unnamed MetaOf entry with the same name as the CSF file that clashes with a tagged autodocs entry', async () => { + const csfSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/B.stories.ts', + options + ); + + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './errors/B.mdx', + options + ); + + const generator = new StoryIndexGenerator([csfSpecifier, docsSpecifier], autodocsOptions); + await generator.initialize(); + + await expect(generator.getIndex()).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unable to index ./errors/B.mdx,./src/B.stories.ts"` + ); + }); + + it('allows you to create a second unnamed MetaOf entry that does not clash with autodocs', async () => { + const csfSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/B.stories.ts', + options + ); + + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './errors/MetaOfNoName.mdx', + options + ); + + const generator = new StoryIndexGenerator([csfSpecifier, docsSpecifier], autodocsOptions); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "b--docs": Object { + "id": "b--docs", + "importPath": "./src/B.stories.ts", + "name": "docs", + "storiesImports": Array [], + "tags": Array [ + "autodocs", + "docs", + ], + "title": "B", + "type": "docs", + }, + "b--metaofnoname": Object { + "id": "b--metaofnoname", + "importPath": "./errors/MetaOfNoName.mdx", + "name": "MetaOfNoName", + "storiesImports": Array [ + "./src/B.stories.ts", + ], + "tags": Array [ + "attached-mdx", + "docs", + ], + "title": "B", + "type": "docs", + }, + "b--story-one": Object { + "id": "b--story-one", + "importPath": "./src/B.stories.ts", + "name": "Story One", + "tags": Array [ + "autodocs", + "story", + ], + "title": "B", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + it('allows you to create a second MetaOf entry with a different name to autodocs', async () => { + const csfSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/B.stories.ts', + options + ); + + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './errors/MetaOfName.mdx', + options + ); + + const generator = new StoryIndexGenerator([csfSpecifier, docsSpecifier], autodocsOptions); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "b--docs": Object { + "id": "b--docs", + "importPath": "./src/B.stories.ts", + "name": "docs", + "storiesImports": Array [], + "tags": Array [ + "autodocs", + "docs", + ], + "title": "B", + "type": "docs", + }, + "b--name": Object { + "id": "b--name", + "importPath": "./errors/MetaOfName.mdx", + "name": "name", + "storiesImports": Array [ + "./src/B.stories.ts", + ], + "tags": Array [ + "attached-mdx", + "docs", + ], + "title": "B", + "type": "docs", + }, + "b--story-one": Object { + "id": "b--story-one", + "importPath": "./src/B.stories.ts", + "name": "Story One", + "tags": Array [ + "autodocs", + "story", + ], + "title": "B", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + + it('allows you to override autodocs with MetaOf if it is automatic', async () => { + const csfSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.js', + options + ); + + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './errors/A.mdx', + options + ); + + const generator = new StoryIndexGenerator( + [csfSpecifier, docsSpecifier], + autodocsTrueOptions + ); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--docs": Object { + "id": "a--docs", + "importPath": "./errors/A.mdx", + "name": "docs", + "storiesImports": Array [ + "./src/A.stories.js", + ], + "tags": Array [ + "attached-mdx", + "docs", + ], + "title": "A", + "type": "docs", + }, + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag", + "story", + ], + "title": "A", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + + it('generates a combined entry if there are two stories files for the same title', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './duplicate/*.stories.(ts|js|mjs|jsx)', + options + ); + + const generator = new StoryIndexGenerator([specifier], autodocsOptions); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "duplicate-a--docs": Object { + "id": "duplicate-a--docs", + "importPath": "./duplicate/A.stories.js", + "name": "docs", + "storiesImports": Array [ + "./duplicate/SecondA.stories.js", + ], + "tags": Array [ + "autodocs", + "docs", + ], + "title": "duplicate/A", + "type": "docs", + }, + "duplicate-a--story-one": Object { + "id": "duplicate-a--story-one", + "importPath": "./duplicate/A.stories.js", + "name": "Story One", + "tags": Array [ + "autodocs", + "story", + ], + "title": "duplicate/A", + "type": "story", + }, + "duplicate-a--story-two": Object { + "id": "duplicate-a--story-two", + "importPath": "./duplicate/SecondA.stories.js", + "name": "Story Two", + "tags": Array [ + "autodocs", + "story", + ], + "title": "duplicate/A", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + + // https://github.com/storybookjs/storybook/issues/19142 + it('does not generate a docs page entry if there are no stories in the CSF file', async () => { + const csfSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './errors/NoStories.stories.ts', + options + ); + + const generator = new StoryIndexGenerator([csfSpecifier], autodocsOptions); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object {}, + "v": 4, + } + `); + }); + }); + + describe('docs specifier', () => { + it('creates correct docs entries', async () => { + const generator = new StoryIndexGenerator([storiesSpecifier, docsSpecifier], options); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--metaof": Object { + "id": "a--metaof", + "importPath": "./src/docs2/MetaOf.mdx", + "name": "MetaOf", + "storiesImports": Array [ + "./src/A.stories.js", + ], + "tags": Array [ + "attached-mdx", + "docs", + ], + "title": "A", + "type": "docs", + }, + "a--second-docs": Object { + "id": "a--second-docs", + "importPath": "./src/docs2/SecondMetaOf.mdx", + "name": "Second Docs", + "storiesImports": Array [ + "./src/A.stories.js", + ], + "tags": Array [ + "attached-mdx", + "docs", + ], + "title": "A", + "type": "docs", + }, + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag", + "story", + ], + "title": "A", + "type": "story", + }, + "componentreference--docs": Object { + "id": "componentreference--docs", + "importPath": "./src/docs2/ComponentReference.mdx", + "name": "docs", + "storiesImports": Array [], + "tags": Array [ + "unattached-mdx", + "docs", + ], + "title": "ComponentReference", + "type": "docs", + }, + "docs2-yabbadabbadooo--docs": Object { + "id": "docs2-yabbadabbadooo--docs", + "importPath": "./src/docs2/Title.mdx", + "name": "docs", + "storiesImports": Array [], + "tags": Array [ + "unattached-mdx", + "docs", + ], + "title": "docs2/Yabbadabbadooo", + "type": "docs", + }, + "notitle--docs": Object { + "id": "notitle--docs", + "importPath": "./src/docs2/NoTitle.mdx", + "name": "docs", + "storiesImports": Array [], + "tags": Array [ + "unattached-mdx", + "docs", + ], + "title": "NoTitle", + "type": "docs", + }, + }, + "v": 4, + } + `); + }); + + it('does not append title prefix if meta references a CSF file', async () => { + const generator = new StoryIndexGenerator( + [ + storiesSpecifier, + normalizeStoriesEntry( + { directory: './src/docs2', files: '**/*.mdx', titlePrefix: 'titlePrefix' }, + options + ), + ], + options + ); + await generator.initialize(); + + // NOTE: `toMatchInlineSnapshot` on objects sorts the keys, but in actuality, they are + // not sorted by default. + expect(Object.values((await generator.getIndex()).entries).map((e) => e.title)) + .toMatchInlineSnapshot(` + Array [ + "A", + "titlePrefix/ComponentReference", + "A", + "titlePrefix/NoTitle", + "A", + "titlePrefix/docs2/Yabbadabbadooo", + ] + `); + }); + + it('Allows you to override default name for docs files', async () => { + const generator = new StoryIndexGenerator([storiesSpecifier, docsSpecifier], { + ...options, + docs: { + ...options.docs, + defaultName: 'Info', + }, + }); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--metaof": Object { + "id": "a--metaof", + "importPath": "./src/docs2/MetaOf.mdx", + "name": "MetaOf", + "storiesImports": Array [ + "./src/A.stories.js", + ], + "tags": Array [ + "attached-mdx", + "docs", + ], + "title": "A", + "type": "docs", + }, + "a--second-docs": Object { + "id": "a--second-docs", + "importPath": "./src/docs2/SecondMetaOf.mdx", + "name": "Second Docs", + "storiesImports": Array [ + "./src/A.stories.js", + ], + "tags": Array [ + "attached-mdx", + "docs", + ], + "title": "A", + "type": "docs", + }, + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag", + "story", + ], + "title": "A", + "type": "story", + }, + "componentreference--info": Object { + "id": "componentreference--info", + "importPath": "./src/docs2/ComponentReference.mdx", + "name": "Info", + "storiesImports": Array [], + "tags": Array [ + "unattached-mdx", + "docs", + ], + "title": "ComponentReference", + "type": "docs", + }, + "docs2-yabbadabbadooo--info": Object { + "id": "docs2-yabbadabbadooo--info", + "importPath": "./src/docs2/Title.mdx", + "name": "Info", + "storiesImports": Array [], + "tags": Array [ + "unattached-mdx", + "docs", + ], + "title": "docs2/Yabbadabbadooo", + "type": "docs", + }, + "notitle--info": Object { + "id": "notitle--info", + "importPath": "./src/docs2/NoTitle.mdx", + "name": "Info", + "storiesImports": Array [], + "tags": Array [ + "unattached-mdx", + "docs", + ], + "title": "NoTitle", + "type": "docs", + }, + }, + "v": 4, + } + `); + }); + + it('pulls the attached story file to the front of the list', async () => { + const generator = new StoryIndexGenerator( + [ + normalizeStoriesEntry('./src/A.stories.js', options), + normalizeStoriesEntry('./src/B.stories.ts', options), + normalizeStoriesEntry('./complex/TwoStoryReferences.mdx', options), + ], + options + ); + await generator.initialize(); + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag", + "story", + ], + "title": "A", + "type": "story", + }, + "b--story-one": Object { + "id": "b--story-one", + "importPath": "./src/B.stories.ts", + "name": "Story One", + "tags": Array [ + "autodocs", + "story", + ], + "title": "B", + "type": "story", + }, + "b--twostoryreferences": Object { + "id": "b--twostoryreferences", + "importPath": "./complex/TwoStoryReferences.mdx", + "name": "TwoStoryReferences", + "storiesImports": Array [ + "./src/B.stories.ts", + "./src/A.stories.js", + ], + "tags": Array [ + "attached-mdx", + "docs", + ], + "title": "B", + "type": "docs", + }, + }, + "v": 4, + } + `); + }); + }); + + describe('errors', () => { + it('when docs dependencies are missing', async () => { + const generator = new StoryIndexGenerator( + [normalizeStoriesEntry('./src/docs2/MetaOf.mdx', options)], + options + ); + await generator.initialize(); + await expect(() => generator.getIndex()).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unable to index ./src/docs2/MetaOf.mdx"` + ); + }); + }); + + describe('warnings', () => { + it('when entries do not match any files', async () => { + const generator = new StoryIndexGenerator( + [normalizeStoriesEntry('./src/docs2/wrong.js', options)], + options + ); + await generator.initialize(); + await generator.getIndex(); + + expect(once.warn).toHaveBeenCalledTimes(1); + const logMessage = jest.mocked(once.warn).mock.calls[0][0]; + expect(logMessage).toContain(`No story files found for the specified pattern`); + }); + }); + + describe('duplicates', () => { + it('errors when two MDX entries reference the same CSF file without a name', async () => { + const docsErrorSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './errors/**/A.mdx', + options + ); + + const generator = new StoryIndexGenerator( + [storiesSpecifier, docsSpecifier, docsErrorSpecifier], + options + ); + await generator.initialize(); + + await expect(generator.getIndex()).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unable to index ./errors/A.mdx,./errors/duplicate/A.mdx"` + ); + }); + + it('errors when a MDX entry has the same name as a story', async () => { + const docsErrorSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './errors/MetaOfClashingName.mdx', + options + ); + + const generator = new StoryIndexGenerator( + [storiesSpecifier, docsSpecifier, docsErrorSpecifier], + options + ); + await generator.initialize(); + + await expect(generator.getIndex()).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unable to index ./src/A.stories.js,./errors/MetaOfClashingName.mdx"` + ); + }); + + it('errors when a story has the default docs name', async () => { + const docsErrorSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './errors/A.mdx', + options + ); + + const generator = new StoryIndexGenerator( + [storiesSpecifier, docsSpecifier, docsErrorSpecifier], + { + ...options, + docs: { ...options.docs, defaultName: 'Story One' }, + } + ); + await generator.initialize(); + + await expect(generator.getIndex()).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unable to index ./src/A.stories.js,./errors/A.mdx"` + ); + }); + it('errors when two duplicate stories exists, with duplicated entries details', async () => { + const generator = new StoryIndexGenerator([storiesSpecifier, docsSpecifier], { + ...options, + }); + await generator.initialize(); + const mockEntry: StoryIndexEntry = { + id: 'StoryId', + name: 'StoryName', + title: 'ComponentTitle', + importPath: 'Path', + type: 'story', + }; + expect(() => { + generator.chooseDuplicate(mockEntry, { ...mockEntry, importPath: 'DifferentPath' }); + }).toThrowErrorMatchingInlineSnapshot(`"Duplicate stories with id: StoryId"`); + }); + + it('DOES NOT error when the same MDX file matches two specifiers', async () => { + const generator = new StoryIndexGenerator( + [storiesSpecifier, docsSpecifier, docsSpecifier], + options + ); + await generator.initialize(); + + expect(Object.keys((await generator.getIndex()).entries)).toMatchInlineSnapshot(` + Array [ + "a--story-one", + "componentreference--docs", + "a--metaof", + "notitle--docs", + "a--second-docs", + "docs2-yabbadabbadooo--docs", + ] + `); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('DOES NOT throw when the same CSF file matches two specifiers', async () => { + const generator = new StoryIndexGenerator([storiesSpecifier, storiesSpecifier], { + ...options, + }); + await generator.initialize(); + expect(Object.keys((await generator.getIndex()).entries)).toMatchInlineSnapshot(` + Array [ + "a--story-one", + ] + `); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + }); + }); + + describe('sorting', () => { + it('runs a user-defined sort function', async () => { + const storiesSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/**/*.stories.(ts|js|mjs|jsx)', + options + ); + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/docs2/*.mdx', + options + ); + + const generator = new StoryIndexGenerator([docsSpecifier, storiesSpecifier], options); + await generator.initialize(); + + (getStorySortParameter as jest.Mock).mockReturnValueOnce({ + order: ['docs2', 'D', 'B', 'nested', 'A', 'second-nested', 'first-nested/deeply'], + }); + + expect(Object.keys((await generator.getIndex()).entries)).toMatchInlineSnapshot(` + Array [ + "docs2-yabbadabbadooo--docs", + "d--story-one", + "b--story-one", + "nested-button--story-one", + "a--metaof", + "a--second-docs", + "a--story-one", + "second-nested-g--story-one", + "componentreference--docs", + "notitle--docs", + "h--story-one", + "first-nested-deeply-f--story-one", + ] + `); + }); + }); + + describe('caching', () => { + describe('no invalidation', () => { + it('does not extract csf files a second time', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/**/*.stories.(ts|js|mjs|jsx)', + options + ); + + loadCsfMock.mockClear(); + const generator = new StoryIndexGenerator([specifier], options); + await generator.initialize(); + await generator.getIndex(); + expect(loadCsfMock).toHaveBeenCalledTimes(7); + + loadCsfMock.mockClear(); + await generator.getIndex(); + expect(loadCsfMock).not.toHaveBeenCalled(); + }); + + it('does not extract docs files a second time', async () => { + const storiesSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.(ts|js|mjs|jsx)', + options + ); + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/docs2/*.mdx', + options + ); + + const generator = new StoryIndexGenerator([storiesSpecifier, docsSpecifier], options); + await generator.initialize(); + await generator.getIndex(); + expect(toId).toHaveBeenCalledTimes(6); + + toIdMock.mockClear(); + await generator.getIndex(); + expect(toId).not.toHaveBeenCalled(); + }); + + it('does not call the sort function a second time', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/**/*.stories.(ts|js|mjs|jsx)', + options + ); + + const sortFn = jest.fn(); + getStorySortParameterMock.mockReturnValue(sortFn); + const generator = new StoryIndexGenerator([specifier], options); + await generator.initialize(); + await generator.getIndex(); + expect(sortFn).toHaveBeenCalled(); + + sortFn.mockClear(); + await generator.getIndex(); + expect(sortFn).not.toHaveBeenCalled(); + }); + }); + + describe('file changed', () => { + it('calls extract csf file for just the one file', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/**/*.stories.(ts|js|mjs|jsx)', + options + ); + + loadCsfMock.mockClear(); + const generator = new StoryIndexGenerator([specifier], options); + await generator.initialize(); + await generator.getIndex(); + expect(loadCsfMock).toHaveBeenCalledTimes(7); + + generator.invalidate(specifier, './src/B.stories.ts', false); + + loadCsfMock.mockClear(); + await generator.getIndex(); + expect(loadCsfMock).toHaveBeenCalledTimes(1); + }); + + it('calls extract docs file for just the one file', async () => { + const storiesSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.(ts|js|mjs|jsx)', + options + ); + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/docs2/*.mdx', + options + ); + + const generator = new StoryIndexGenerator([storiesSpecifier, docsSpecifier], options); + await generator.initialize(); + await generator.getIndex(); + expect(toId).toHaveBeenCalledTimes(6); + + generator.invalidate(docsSpecifier, './src/docs2/Title.mdx', false); + + toIdMock.mockClear(); + await generator.getIndex(); + expect(toId).toHaveBeenCalledTimes(1); + }); + + it('calls extract for a csf file and any of its docs dependents', async () => { + const storiesSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.(ts|js|mjs|jsx)', + options + ); + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/docs2/*.mdx', + options + ); + + const generator = new StoryIndexGenerator([storiesSpecifier, docsSpecifier], options); + await generator.initialize(); + await generator.getIndex(); + expect(toId).toHaveBeenCalledTimes(6); + + generator.invalidate(storiesSpecifier, './src/A.stories.js', false); + + toIdMock.mockClear(); + await generator.getIndex(); + expect(toId).toHaveBeenCalledTimes(3); + }); + + it('does call the sort function a second time', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/**/*.stories.(ts|js|mjs|jsx)', + options + ); + + const sortFn = jest.fn(); + getStorySortParameterMock.mockReturnValue(sortFn); + const generator = new StoryIndexGenerator([specifier], options); + await generator.initialize(); + await generator.getIndex(); + expect(sortFn).toHaveBeenCalled(); + + generator.invalidate(specifier, './src/B.stories.ts', false); + + sortFn.mockClear(); + await generator.getIndex(); + expect(sortFn).toHaveBeenCalled(); + }); + }); + + describe('file removed', () => { + it('does not extract csf files a second time', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/**/*.stories.(ts|js|mjs|jsx)', + options + ); + + loadCsfMock.mockClear(); + const generator = new StoryIndexGenerator([specifier], options); + await generator.initialize(); + await generator.getIndex(); + expect(loadCsfMock).toHaveBeenCalledTimes(7); + + generator.invalidate(specifier, './src/B.stories.ts', true); + + loadCsfMock.mockClear(); + await generator.getIndex(); + expect(loadCsfMock).not.toHaveBeenCalled(); + }); + + it('does call the sort function a second time', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/**/*.stories.(ts|js|mjs|jsx)', + options + ); + + const sortFn = jest.fn(); + getStorySortParameterMock.mockReturnValue(sortFn); + const generator = new StoryIndexGenerator([specifier], options); + await generator.initialize(); + await generator.getIndex(); + expect(sortFn).toHaveBeenCalled(); + + generator.invalidate(specifier, './src/B.stories.ts', true); + + sortFn.mockClear(); + await generator.getIndex(); + expect(sortFn).toHaveBeenCalled(); + }); + + it('does not include the deleted stories in results', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/**/*.stories.(ts|js|mjs|jsx)', + options + ); + + loadCsfMock.mockClear(); + const generator = new StoryIndexGenerator([specifier], options); + await generator.initialize(); + await generator.getIndex(); + expect(loadCsfMock).toHaveBeenCalledTimes(7); + + generator.invalidate(specifier, './src/B.stories.ts', true); + + expect(Object.keys((await generator.getIndex()).entries)).not.toContain('b--story-one'); + }); + + it('does not include the deleted docs in results', async () => { + const storiesSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.(ts|js|mjs|jsx)', + options + ); + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/docs2/*.mdx', + options + ); + + const generator = new StoryIndexGenerator([docsSpecifier, storiesSpecifier], options); + await generator.initialize(); + await generator.getIndex(); + expect(toId).toHaveBeenCalledTimes(6); + + expect(Object.keys((await generator.getIndex()).entries)).toContain('notitle--docs'); + + generator.invalidate(docsSpecifier, './src/docs2/NoTitle.mdx', true); + + expect(Object.keys((await generator.getIndex()).entries)).not.toContain('notitle--docs'); + }); + + it('cleans up properly on dependent docs deletion', async () => { + const storiesSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.(ts|js|mjs|jsx)', + options + ); + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/docs2/*.mdx', + options + ); + + const generator = new StoryIndexGenerator([docsSpecifier, storiesSpecifier], options); + await generator.initialize(); + await generator.getIndex(); + expect(toId).toHaveBeenCalledTimes(6); + + expect(Object.keys((await generator.getIndex()).entries)).toContain('a--metaof'); + + generator.invalidate(docsSpecifier, './src/docs2/MetaOf.mdx', true); + + expect(Object.keys((await generator.getIndex()).entries)).not.toContain('a--metaof'); + + // this will throw if MetaOf is not removed from A's dependents + generator.invalidate(storiesSpecifier, './src/A.stories.js', false); + }); + }); + }); +}); From 213bb01cd1c3bca43765b5100d8f56407c35317b Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 26 Jul 2023 13:36:07 +0200 Subject: [PATCH 02/15] refactor extractStories to deprecated function --- .../src/utils/StoryIndexGenerator.test.ts | 16 +++++---- .../src/utils/StoryIndexGenerator.ts | 34 ++++++++++++++++--- code/lib/types/src/modules/indexer.ts | 2 +- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts index 65c425c0a723..49df0491fa2f 100644 --- a/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts @@ -58,17 +58,19 @@ const storiesMdxIndexer: StoryIndexer = { const plainStoryIndexer: Indexer = { test: /\.stories\.(m?js|ts)x?$/, - index: (fileName, opts) => { + index: async (fileName) => { return [ { type: 'story', importPath: fileName, - key: 'default', + key: 'primary', + }, + { + type: 'story', + importPath: fileName, + key: 'secondary', }, ]; - - const code = (await fs.readFile(fileName, 'utf-8')).toString(); - return loadCsf(code, { ...opts, fileName }).parse(); }, }; @@ -77,7 +79,7 @@ const options: StoryIndexGeneratorOptions = { workingDir: path.join(__dirname, '__mockdata__'), storyIndexers: [], indexers: [ - plainIndexer, + plainStoryIndexer, // storiesMdxIndexer, // csfIndexer, ], @@ -86,7 +88,7 @@ const options: StoryIndexGeneratorOptions = { docs: { defaultName: 'docs', autodocs: false }, }; -describe('StoryIndexGenerator with deprecated indexer API', () => { +describe('StoryIndexGenerator', () => { beforeEach(() => { const actual = jest.requireActual('@storybook/csf-tools'); loadCsfMock.mockImplementation(actual.loadCsf); diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.ts index dfa5ec3a3b53..a70dd7400080 100644 --- a/code/lib/core-server/src/utils/StoryIndexGenerator.ts +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.ts @@ -20,6 +20,7 @@ import type { StoryId, StoryName, Indexer, + IndexerOptions, } from '@storybook/types'; import { userOrAutoTitleFromSpecifier, sortStoriesV7 } from '@storybook/preview-api'; import { commonGlobOptions, normalizeStoryPath } from '@storybook/core-common'; @@ -252,17 +253,42 @@ export class StoryIndexGenerator { async extractStories(specifier: NormalizedStoriesSpecifier, absolutePath: Path) { const relativePath = path.relative(this.options.workingDir, absolutePath); - const entries = [] as IndexEntry[]; const importPath = slash(normalizeStoryPath(relativePath)); const makeTitle = (userTitle?: string) => { return userOrAutoTitleFromSpecifier(importPath, specifier, userTitle); }; - const storyIndexer = this.options.storyIndexers.find((ind) => ind.test.exec(absolutePath)); - if (!storyIndexer) { + const indexer = (this.options.indexers as StoryIndexer[]) + .concat(this.options.storyIndexers) + .find((ind) => ind.test.exec(absolutePath)); + // TODO: Do we want to throw when both a deprecated and a new indexer match? + if (!indexer) { throw new Error(`No matching indexer found for ${absolutePath}`); } - const csf = await storyIndexer.indexer(absolutePath, { makeTitle }); + if (indexer.indexer) { + return this.extractStoriesFromDeprecatedIndexer({ + indexer: indexer.indexer, + indexerOptions: { makeTitle }, + absolutePath, + importPath, + }); + } + } + + async extractStoriesFromDeprecatedIndexer({ + indexer, + indexerOptions, + absolutePath, + importPath, + }: { + indexer: StoryIndexer['indexer']; + indexerOptions: IndexerOptions; + absolutePath: Path; + importPath: Path; + }) { + const csf = await indexer(absolutePath, indexerOptions); + + const entries = []; const componentTags = csf.meta.tags || []; csf.stories.forEach(({ id, name, tags: storyTags, parameters }) => { diff --git a/code/lib/types/src/modules/indexer.ts b/code/lib/types/src/modules/indexer.ts index ab59c40b63c7..6c44da894569 100644 --- a/code/lib/types/src/modules/indexer.ts +++ b/code/lib/types/src/modules/indexer.ts @@ -124,7 +124,7 @@ export type DocsIndexInput = BaseIndexInput & { storiesImports?: Path[]; }; -export type IndexInput = StoryIndexEntry | DocsIndexEntry; +export type IndexInput = StoryIndexInput | DocsIndexInput; export interface V3CompatIndexEntry extends Omit { kind: ComponentTitle; From 1031683fd6e05757684d4c9f7372cc6165c936fd Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 26 Jul 2023 14:57:43 +0200 Subject: [PATCH 03/15] support minimal and full indexer inputs --- .../src/utils/StoryIndexGenerator.test.ts | 305 +++++++++++++++++- .../src/utils/StoryIndexGenerator.ts | 35 +- 2 files changed, 327 insertions(+), 13 deletions(-) diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts index 49df0491fa2f..97788812dc9f 100644 --- a/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts @@ -56,19 +56,27 @@ const storiesMdxIndexer: StoryIndexer = { }, }; -const plainStoryIndexer: Indexer = { +const fullStoryIndexer: Indexer = { test: /\.stories\.(m?js|ts)x?$/, index: async (fileName) => { return [ { - type: 'story', + key: 'StoryOne', + id: 'a--story-one', + name: 'Story One', + title: 'A', + tags: ['story-tag-from-indexer'], importPath: fileName, - key: 'primary', + type: 'story', }, { - type: 'story', + key: 'StoryOne', + id: 'a--story-two', + name: 'Story Two', + title: 'A', + tags: ['story-tag-from-indexer'], importPath: fileName, - key: 'secondary', + type: 'story', }, ]; }, @@ -79,7 +87,7 @@ const options: StoryIndexGeneratorOptions = { workingDir: path.join(__dirname, '__mockdata__'), storyIndexers: [], indexers: [ - plainStoryIndexer, + fullStoryIndexer, // storiesMdxIndexer, // csfIndexer, ], @@ -92,8 +100,7 @@ describe('StoryIndexGenerator', () => { beforeEach(() => { const actual = jest.requireActual('@storybook/csf-tools'); loadCsfMock.mockImplementation(actual.loadCsf); - jest.mocked(logger.warn).mockClear(); - jest.mocked(once.warn).mockClear(); + jest.clearAllMocks(); }); describe('extraction', () => { const storiesSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( @@ -105,6 +112,275 @@ describe('StoryIndexGenerator', () => { options ); + describe.only('indexers', () => { + it('extracts stories from full indexer inputs', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.js', + options + ); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + indexers: [fullStoryIndexer], + }); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag-from-indexer", + "story", + ], + "title": "A", + "type": "story", + }, + "a--story-two": Object { + "id": "a--story-two", + "importPath": "./src/A.stories.js", + "name": "Story Two", + "tags": Array [ + "story-tag-from-indexer", + "story", + ], + "title": "A", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + + it('extracts stories from minimal indexer inputs', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.js', + options + ); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + importPath: fileName, + type: 'story', + }, + ], + }, + ], + }); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story", + ], + "title": "A", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + + it('auto-generates title from indexer inputs without title', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.js', + options + ); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + id: 'a--story-one', + name: 'Story One', + tags: ['story-tag-from-indexer'], + importPath: fileName, + type: 'story', + }, + ], + }, + ], + }); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag-from-indexer", + "story", + ], + "title": "A", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + + it('auto-generates name from indexer inputs without name', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.js', + options + ); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + id: 'a--story-one', + title: 'A', + tags: ['story-tag-from-indexer'], + importPath: fileName, + type: 'story', + }, + ], + }, + ], + }); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag-from-indexer", + "story", + ], + "title": "A", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + + it('auto-generates id from name and title inputs', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.js', + options + ); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + name: 'Story One', + title: 'A', + tags: ['story-tag-from-indexer'], + importPath: fileName, + type: 'story', + }, + ], + }, + ], + }); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag-from-indexer", + "story", + ], + "title": "A", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + + it('auto-generates id, title and name from key input', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.js', + options + ); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + tags: ['story-tag-from-indexer'], + importPath: fileName, + type: 'story', + }, + ], + }, + ], + }); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag-from-indexer", + "story", + ], + "title": "A", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + }); + describe('single file specifier', () => { it('extracts stories from the right files', async () => { const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( @@ -123,7 +399,18 @@ describe('StoryIndexGenerator', () => { "importPath": "./src/A.stories.js", "name": "Story One", "tags": Array [ - "story-tag", + "story-tag-from-indexer", + "story", + ], + "title": "A", + "type": "story", + }, + "a--story-two": Object { + "id": "a--story-two", + "importPath": "./src/A.stories.js", + "name": "Story Two", + "tags": Array [ + "story-tag-from-indexer", "story", ], "title": "A", diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.ts index a70dd7400080..31450f7a2d93 100644 --- a/code/lib/core-server/src/utils/StoryIndexGenerator.ts +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.ts @@ -26,7 +26,7 @@ import { userOrAutoTitleFromSpecifier, sortStoriesV7 } from '@storybook/preview- import { commonGlobOptions, normalizeStoryPath } from '@storybook/core-common'; import { deprecate, logger, once } from '@storybook/node-logger'; import { getStorySortParameter } from '@storybook/csf-tools'; -import { toId } from '@storybook/csf'; +import { storyNameFromExport, toId } from '@storybook/csf'; import { analyze } from '@storybook/docs-mdx'; import dedent from 'ts-dedent'; import { autoName } from './autoName'; @@ -251,28 +251,55 @@ export class StoryIndexGenerator { ); } - async extractStories(specifier: NormalizedStoriesSpecifier, absolutePath: Path) { + async extractStories( + specifier: NormalizedStoriesSpecifier, + absolutePath: Path + ): Promise { const relativePath = path.relative(this.options.workingDir, absolutePath); const importPath = slash(normalizeStoryPath(relativePath)); - const makeTitle = (userTitle?: string) => { + const defaultMakeTitle = (userTitle?: string) => { return userOrAutoTitleFromSpecifier(importPath, specifier, userTitle); }; const indexer = (this.options.indexers as StoryIndexer[]) .concat(this.options.storyIndexers) .find((ind) => ind.test.exec(absolutePath)); + // TODO: Do we want to throw when both a deprecated and a new indexer match? + if (!indexer) { throw new Error(`No matching indexer found for ${absolutePath}`); } if (indexer.indexer) { return this.extractStoriesFromDeprecatedIndexer({ indexer: indexer.indexer, - indexerOptions: { makeTitle }, + indexerOptions: { makeTitle: defaultMakeTitle }, absolutePath, importPath, }); } + + const indexInputs = await indexer.index(importPath, { makeTitle: defaultMakeTitle }); + + const entries: StoryIndexEntry[] = indexInputs.map((input) => { + const name = input.name ?? storyNameFromExport(input.key); + const title = input.title ?? defaultMakeTitle(); + const id = input.id ?? toId(title, name); + return { + type: 'story', + id, + name, + title, + importPath, + tags: (input.tags || []).concat('story'), + }; + }); + + return { + entries, + dependents: [], + type: 'stories', + }; } async extractStoriesFromDeprecatedIndexer({ From fd54fa2bf7b97b2df62a82c3c43c194b5adf28aa Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 26 Jul 2023 15:32:28 +0200 Subject: [PATCH 04/15] add basic csf indexer to tests --- .../src/utils/StoryIndexGenerator.test.ts | 114 ++++++++---------- .../src/utils/StoryIndexGenerator.ts | 3 +- 2 files changed, 52 insertions(+), 65 deletions(-) diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts index 97788812dc9f..38fb00207b3f 100644 --- a/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts @@ -38,47 +38,43 @@ const getStorySortParameterMock = getStorySortParameter as jest.Mock< ReturnType >; -const csfIndexer: StoryIndexer = { - test: /\.stories\.mdx$/, - indexer: async (fileName, opts) => { - const code = (await fs.readFile(fileName, 'utf-8')).toString(); - return loadCsf(code, { ...opts, fileName }).parse(); - }, -}; - const storiesMdxIndexer: StoryIndexer = { test: /\.stories\.mdx$/, - indexer: async (fileName, opts) => { + index: async (fileName, opts) => { let code = (await fs.readFile(fileName, 'utf-8')).toString(); const { compile } = await import('@storybook/mdx2-csf'); code = await compile(code, {}); - return loadCsf(code, { ...opts, fileName }).parse(); + const csf = loadCsf(code, { ...opts, fileName }).parse(); + + // eslint-disable-next-line no-underscore-dangle + return Object.entries(csf._stories).map(([key, story]) => ({ + key, + id: story.id, + name: story.name, + title: csf.meta.title, + importPath: fileName, + type: 'story', + tags: story.tags ?? csf.meta.tags, + })); }, }; -const fullStoryIndexer: Indexer = { +const csfIndexer: Indexer = { test: /\.stories\.(m?js|ts)x?$/, - index: async (fileName) => { - return [ - { - key: 'StoryOne', - id: 'a--story-one', - name: 'Story One', - title: 'A', - tags: ['story-tag-from-indexer'], - importPath: fileName, - type: 'story', - }, - { - key: 'StoryOne', - id: 'a--story-two', - name: 'Story Two', - title: 'A', - tags: ['story-tag-from-indexer'], - importPath: fileName, - type: 'story', - }, - ]; + index: async (fileName, options) => { + const code = (await fs.readFile(fileName, 'utf-8')).toString(); + const csf = loadCsf(code, { ...options, fileName }).parse(); + + // eslint-disable-next-line no-underscore-dangle + return Object.entries(csf._stories).map(([key, story]) => ({ + key, + id: story.id, + name: story.name, + title: csf.meta.title, + importPath: fileName, + type: 'story', + tags: story.tags ?? csf.meta.tags, + })); }, }; @@ -86,11 +82,7 @@ const options: StoryIndexGeneratorOptions = { configDir: path.join(__dirname, '__mockdata__'), workingDir: path.join(__dirname, '__mockdata__'), storyIndexers: [], - indexers: [ - fullStoryIndexer, - // storiesMdxIndexer, - // csfIndexer, - ], + indexers: [csfIndexer, storiesMdxIndexer], storiesV2Compatibility: false, storyStoreV7: true, docs: { defaultName: 'docs', autodocs: false }, @@ -100,7 +92,8 @@ describe('StoryIndexGenerator', () => { beforeEach(() => { const actual = jest.requireActual('@storybook/csf-tools'); loadCsfMock.mockImplementation(actual.loadCsf); - jest.clearAllMocks(); + jest.mocked(logger.warn).mockClear(); + jest.mocked(once.warn).mockClear(); }); describe('extraction', () => { const storiesSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( @@ -112,7 +105,7 @@ describe('StoryIndexGenerator', () => { options ); - describe.only('indexers', () => { + describe('indexers', () => { it('extracts stories from full indexer inputs', async () => { const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( './src/A.stories.js', @@ -121,7 +114,22 @@ describe('StoryIndexGenerator', () => { const generator = new StoryIndexGenerator([specifier], { ...options, - indexers: [fullStoryIndexer], + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + id: 'a--story-one', + name: 'Story One', + title: 'A', + tags: ['story-tag-from-indexer'], + importPath: fileName, + type: 'story', + }, + ], + }, + ], }); await generator.initialize(); @@ -139,17 +147,6 @@ describe('StoryIndexGenerator', () => { "title": "A", "type": "story", }, - "a--story-two": Object { - "id": "a--story-two", - "importPath": "./src/A.stories.js", - "name": "Story Two", - "tags": Array [ - "story-tag-from-indexer", - "story", - ], - "title": "A", - "type": "story", - }, }, "v": 4, } @@ -399,18 +396,7 @@ describe('StoryIndexGenerator', () => { "importPath": "./src/A.stories.js", "name": "Story One", "tags": Array [ - "story-tag-from-indexer", - "story", - ], - "title": "A", - "type": "story", - }, - "a--story-two": Object { - "id": "a--story-two", - "importPath": "./src/A.stories.js", - "name": "Story Two", - "tags": Array [ - "story-tag-from-indexer", + "story-tag", "story", ], "title": "A", @@ -557,7 +543,7 @@ describe('StoryIndexGenerator', () => { }); }); - describe('mdx tagged components', () => { + describe.only('mdx tagged components', () => { it('adds docs entry with docs enabled', async () => { const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( './src/nested/Page.stories.mdx', diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.ts index 31450f7a2d93..fce7897411cb 100644 --- a/code/lib/core-server/src/utils/StoryIndexGenerator.ts +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.ts @@ -279,7 +279,7 @@ export class StoryIndexGenerator { }); } - const indexInputs = await indexer.index(importPath, { makeTitle: defaultMakeTitle }); + const indexInputs = await indexer.index(absolutePath, { makeTitle: defaultMakeTitle }); const entries: StoryIndexEntry[] = indexInputs.map((input) => { const name = input.name ?? storyNameFromExport(input.key); @@ -559,6 +559,7 @@ export class StoryIndexGenerator { try { const errorEntries = storiesList.filter((entry) => entry.type === 'error'); + console.dir(errorEntries); if (errorEntries.length) throw new MultipleIndexingError(errorEntries.map((entry) => (entry as ErrorEntry).err)); From d01d9a609c5a24438c1309ba33fba1fbdb198b9f Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 26 Jul 2023 16:01:22 +0200 Subject: [PATCH 05/15] add docs entries based on tags --- .../src/utils/StoryIndexGenerator.test.ts | 2 +- .../src/utils/StoryIndexGenerator.ts | 38 +++++++++++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts index 38fb00207b3f..84935a2a2810 100644 --- a/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts @@ -543,7 +543,7 @@ describe('StoryIndexGenerator', () => { }); }); - describe.only('mdx tagged components', () => { + describe('mdx tagged components', () => { it('adds docs entry with docs enabled', async () => { const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( './src/nested/Page.stories.mdx', diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.ts index fce7897411cb..f2536c6c4fd2 100644 --- a/code/lib/core-server/src/utils/StoryIndexGenerator.ts +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.ts @@ -254,7 +254,7 @@ export class StoryIndexGenerator { async extractStories( specifier: NormalizedStoriesSpecifier, absolutePath: Path - ): Promise { + ): Promise { const relativePath = path.relative(this.options.workingDir, absolutePath); const importPath = slash(normalizeStoryPath(relativePath)); const defaultMakeTitle = (userTitle?: string) => { @@ -281,20 +281,50 @@ export class StoryIndexGenerator { const indexInputs = await indexer.index(absolutePath, { makeTitle: defaultMakeTitle }); - const entries: StoryIndexEntry[] = indexInputs.map((input) => { + const entries: (StoryIndexEntry | DocsCacheEntry)[] = indexInputs.map((input) => { const name = input.name ?? storyNameFromExport(input.key); const title = input.title ?? defaultMakeTitle(); const id = input.id ?? toId(title, name); + const tags = (input.tags || []).concat('story'); + return { type: 'story', id, name, title, importPath, - tags: (input.tags || []).concat('story'), + tags, }; }); + const { autodocs } = this.options.docs; + // We need a docs entry attached to the CSF file if either: + // a) autodocs is globally enabled + // b) we have autodocs enabled for this file + // c) it is a stories.mdx transpiled to CSF + const isStoriesMdx = entries.some((entry) => entry.tags.includes(STORIES_MDX_TAG)); + const createDocEntry = + autodocs === true || + (autodocs === 'tag' && entries.some((entry) => entry.tags.includes(AUTODOCS_TAG))) || + isStoriesMdx; + + if (createDocEntry) { + const name = this.options.docs.defaultName; + // TODO: how to get "component title" or "component tags" when we only have direct stories here? + const { title } = entries[0]; + const { tags } = indexInputs[0]; + const id = toId(title, name); + entries.unshift({ + id, + title, + name, + importPath, + type: 'docs', + tags: [...tags, 'docs', ...(!isStoriesMdx ? [AUTODOCS_TAG] : [])], + storiesImports: [], + }); + } + return { entries, dependents: [], @@ -559,7 +589,7 @@ export class StoryIndexGenerator { try { const errorEntries = storiesList.filter((entry) => entry.type === 'error'); - console.dir(errorEntries); + if (errorEntries.length) throw new MultipleIndexingError(errorEntries.map((entry) => (entry as ErrorEntry).err)); From 992efe41c77ece322cd34fff43249ce16e38a268 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 26 Jul 2023 21:15:40 +0200 Subject: [PATCH 06/15] support autodocs --- code/lib/core-server/src/utils/StoryIndexGenerator.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.ts index f2536c6c4fd2..4f9de29654aa 100644 --- a/code/lib/core-server/src/utils/StoryIndexGenerator.ts +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.ts @@ -302,17 +302,16 @@ export class StoryIndexGenerator { // a) autodocs is globally enabled // b) we have autodocs enabled for this file // c) it is a stories.mdx transpiled to CSF + const hasAutodocsTag = entries.some((entry) => entry.tags.includes(AUTODOCS_TAG)); const isStoriesMdx = entries.some((entry) => entry.tags.includes(STORIES_MDX_TAG)); const createDocEntry = - autodocs === true || - (autodocs === 'tag' && entries.some((entry) => entry.tags.includes(AUTODOCS_TAG))) || - isStoriesMdx; + autodocs === true || (autodocs === 'tag' && hasAutodocsTag) || isStoriesMdx; if (createDocEntry) { const name = this.options.docs.defaultName; // TODO: how to get "component title" or "component tags" when we only have direct stories here? const { title } = entries[0]; - const { tags } = indexInputs[0]; + const tags = indexInputs[0].tags || []; const id = toId(title, name); entries.unshift({ id, @@ -320,7 +319,7 @@ export class StoryIndexGenerator { name, importPath, type: 'docs', - tags: [...tags, 'docs', ...(!isStoriesMdx ? [AUTODOCS_TAG] : [])], + tags: [...tags, 'docs', ...(!hasAutodocsTag && !isStoriesMdx ? [AUTODOCS_TAG] : [])], storiesImports: [], }); } From 24f4f668ced507de0f83447196fe68449bb55924 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 26 Jul 2023 21:31:52 +0200 Subject: [PATCH 07/15] test duplicate indexers are allowed --- .../StoryIndexGenerator.deprecated.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.deprecated.test.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.deprecated.test.ts index 7d1d18b6ca83..38614f6aabd9 100644 --- a/code/lib/core-server/src/utils/StoryIndexGenerator.deprecated.test.ts +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.deprecated.test.ts @@ -1152,6 +1152,40 @@ describe('StoryIndexGenerator with deprecated indexer API', () => { expect(logger.warn).not.toHaveBeenCalled(); }); + + it('DOES NOT throw when the same CSF file is indexed by both a deprecated and current indexer', async () => { + const generator = new StoryIndexGenerator([storiesSpecifier], { + ...options, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName, options) => { + const code = (await fs.readFile(fileName, 'utf-8')).toString(); + const csf = loadCsf(code, { ...options, fileName }).parse(); + + // eslint-disable-next-line no-underscore-dangle + return Object.entries(csf._stories).map(([key, story]) => ({ + key, + id: story.id, + name: story.name, + title: csf.meta.title, + importPath: fileName, + type: 'story', + tags: story.tags ?? csf.meta.tags, + })); + }, + }, + ], + }); + await generator.initialize(); + expect(Object.keys((await generator.getIndex()).entries)).toMatchInlineSnapshot(` + Array [ + "a--story-one", + ] + `); + + expect(logger.warn).not.toHaveBeenCalled(); + }); }); }); From 18aced2e7dfd401a770173333bfd12eb2f682348 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 26 Jul 2023 21:44:39 +0200 Subject: [PATCH 08/15] cleanup --- code/lib/core-server/src/utils/StoryIndexGenerator.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.ts index 4f9de29654aa..f23ea1346b2a 100644 --- a/code/lib/core-server/src/utils/StoryIndexGenerator.ts +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.ts @@ -265,8 +265,6 @@ export class StoryIndexGenerator { .concat(this.options.storyIndexers) .find((ind) => ind.test.exec(absolutePath)); - // TODO: Do we want to throw when both a deprecated and a new indexer match? - if (!indexer) { throw new Error(`No matching indexer found for ${absolutePath}`); } From e84041f28f8f2fb3e056f10b7e84a970a2ce1dec Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 26 Jul 2023 22:54:25 +0200 Subject: [PATCH 09/15] use new indexers in stories-json tests --- .../src/utils/StoryIndexGenerator.test.ts | 9 +--- .../src/utils/stories-json.test.ts | 53 ++++++++++++++----- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts index 84935a2a2810..d52221cca5b1 100644 --- a/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts @@ -8,12 +8,7 @@ import path from 'path'; import fs from 'fs-extra'; import { normalizeStoriesEntry } from '@storybook/core-common'; -import type { - Indexer, - NormalizedStoriesSpecifier, - StoryIndexEntry, - StoryIndexer, -} from '@storybook/types'; +import type { Indexer, NormalizedStoriesSpecifier, StoryIndexEntry } from '@storybook/types'; import { loadCsf, getStorySortParameter } from '@storybook/csf-tools'; import { toId } from '@storybook/csf'; import { logger, once } from '@storybook/node-logger'; @@ -38,7 +33,7 @@ const getStorySortParameterMock = getStorySortParameter as jest.Mock< ReturnType >; -const storiesMdxIndexer: StoryIndexer = { +const storiesMdxIndexer: Indexer = { test: /\.stories\.mdx$/, index: async (fileName, opts) => { let code = (await fs.readFile(fileName, 'utf-8')).toString(); diff --git a/code/lib/core-server/src/utils/stories-json.test.ts b/code/lib/core-server/src/utils/stories-json.test.ts index fd5c927c5d35..7fdbc763e5b3 100644 --- a/code/lib/core-server/src/utils/stories-json.test.ts +++ b/code/lib/core-server/src/utils/stories-json.test.ts @@ -6,7 +6,7 @@ import Watchpack from 'watchpack'; import path from 'path'; import debounce from 'lodash/debounce.js'; import { STORY_INDEX_INVALIDATED } from '@storybook/core-events'; -import type { StoryIndex } from '@storybook/types'; +import type { Indexer, StoryIndex } from '@storybook/types'; import { loadCsf } from '@storybook/csf-tools'; import { normalizeStoriesEntry } from '@storybook/core-common'; @@ -39,16 +39,44 @@ const normalizedStories = [ ), ]; -const csfIndexer = async (fileName: string, opts: any) => { - const code = (await fs.readFile(fileName, 'utf-8')).toString(); - return loadCsf(code, { ...opts, fileName }).parse(); +const storiesMdxIndexer: Indexer = { + test: /\.stories\.mdx$/, + index: async (fileName, opts) => { + let code = (await fs.readFile(fileName, 'utf-8')).toString(); + const { compile } = await import('@storybook/mdx2-csf'); + code = await compile(code, {}); + const csf = loadCsf(code, { ...opts, fileName }).parse(); + + // eslint-disable-next-line no-underscore-dangle + return Object.entries(csf._stories).map(([key, story]) => ({ + key, + id: story.id, + name: story.name, + title: csf.meta.title, + importPath: fileName, + type: 'story', + tags: story.tags ?? csf.meta.tags, + })); + }, }; -const storiesMdxIndexer = async (fileName: string, opts: any) => { - let code = (await fs.readFile(fileName, 'utf-8')).toString(); - const { compile } = await import('@storybook/mdx2-csf'); - code = await compile(code, {}); - return loadCsf(code, { ...opts, fileName }).parse(); +const csfIndexer: Indexer = { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName, options) => { + const code = (await fs.readFile(fileName, 'utf-8')).toString(); + const csf = loadCsf(code, { ...options, fileName }).parse(); + + // eslint-disable-next-line no-underscore-dangle + return Object.entries(csf._stories).map(([key, story]) => ({ + key, + id: story.id, + name: story.name, + title: csf.meta.title, + importPath: fileName, + type: 'story', + tags: story.tags ?? csf.meta.tags, + })); + }, }; const getInitializedStoryIndexGenerator = async ( @@ -56,11 +84,8 @@ const getInitializedStoryIndexGenerator = async ( inputNormalizedStories = normalizedStories ) => { const options: StoryIndexGeneratorOptions = { - storyIndexers: [ - { test: /\.stories\.mdx$/, indexer: storiesMdxIndexer }, - { test: /\.stories\.(m?js|ts)x?$/, indexer: csfIndexer }, - ], - indexers: [], + storyIndexers: [], + indexers: [csfIndexer, storiesMdxIndexer], configDir: workingDir, workingDir, storiesV2Compatibility: false, From d5cb5e9106c32365f1f8512a914038874de5ef6d Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 27 Jul 2023 10:10:50 +0200 Subject: [PATCH 10/15] extract index tests to separate unit test file --- .../src/utils/StoryIndexGenerator.test.ts | 273 --------- .../utils/__tests__/index-extraction.test.ts | 530 ++++++++++++++++++ 2 files changed, 530 insertions(+), 273 deletions(-) create mode 100644 code/lib/core-server/src/utils/__tests__/index-extraction.test.ts diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts index d52221cca5b1..15af3e19ebb0 100644 --- a/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.test.ts @@ -100,279 +100,6 @@ describe('StoryIndexGenerator', () => { options ); - describe('indexers', () => { - it('extracts stories from full indexer inputs', async () => { - const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/A.stories.js', - options - ); - - const generator = new StoryIndexGenerator([specifier], { - ...options, - indexers: [ - { - test: /\.stories\.(m?js|ts)x?$/, - index: async (fileName) => [ - { - key: 'StoryOne', - id: 'a--story-one', - name: 'Story One', - title: 'A', - tags: ['story-tag-from-indexer'], - importPath: fileName, - type: 'story', - }, - ], - }, - ], - }); - await generator.initialize(); - - expect(await generator.getIndex()).toMatchInlineSnapshot(` - Object { - "entries": Object { - "a--story-one": Object { - "id": "a--story-one", - "importPath": "./src/A.stories.js", - "name": "Story One", - "tags": Array [ - "story-tag-from-indexer", - "story", - ], - "title": "A", - "type": "story", - }, - }, - "v": 4, - } - `); - }); - - it('extracts stories from minimal indexer inputs', async () => { - const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/A.stories.js', - options - ); - - const generator = new StoryIndexGenerator([specifier], { - ...options, - indexers: [ - { - test: /\.stories\.(m?js|ts)x?$/, - index: async (fileName) => [ - { - key: 'StoryOne', - importPath: fileName, - type: 'story', - }, - ], - }, - ], - }); - await generator.initialize(); - - expect(await generator.getIndex()).toMatchInlineSnapshot(` - Object { - "entries": Object { - "a--story-one": Object { - "id": "a--story-one", - "importPath": "./src/A.stories.js", - "name": "Story One", - "tags": Array [ - "story", - ], - "title": "A", - "type": "story", - }, - }, - "v": 4, - } - `); - }); - - it('auto-generates title from indexer inputs without title', async () => { - const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/A.stories.js', - options - ); - - const generator = new StoryIndexGenerator([specifier], { - ...options, - indexers: [ - { - test: /\.stories\.(m?js|ts)x?$/, - index: async (fileName) => [ - { - key: 'StoryOne', - id: 'a--story-one', - name: 'Story One', - tags: ['story-tag-from-indexer'], - importPath: fileName, - type: 'story', - }, - ], - }, - ], - }); - await generator.initialize(); - - expect(await generator.getIndex()).toMatchInlineSnapshot(` - Object { - "entries": Object { - "a--story-one": Object { - "id": "a--story-one", - "importPath": "./src/A.stories.js", - "name": "Story One", - "tags": Array [ - "story-tag-from-indexer", - "story", - ], - "title": "A", - "type": "story", - }, - }, - "v": 4, - } - `); - }); - - it('auto-generates name from indexer inputs without name', async () => { - const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/A.stories.js', - options - ); - - const generator = new StoryIndexGenerator([specifier], { - ...options, - indexers: [ - { - test: /\.stories\.(m?js|ts)x?$/, - index: async (fileName) => [ - { - key: 'StoryOne', - id: 'a--story-one', - title: 'A', - tags: ['story-tag-from-indexer'], - importPath: fileName, - type: 'story', - }, - ], - }, - ], - }); - await generator.initialize(); - - expect(await generator.getIndex()).toMatchInlineSnapshot(` - Object { - "entries": Object { - "a--story-one": Object { - "id": "a--story-one", - "importPath": "./src/A.stories.js", - "name": "Story One", - "tags": Array [ - "story-tag-from-indexer", - "story", - ], - "title": "A", - "type": "story", - }, - }, - "v": 4, - } - `); - }); - - it('auto-generates id from name and title inputs', async () => { - const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/A.stories.js', - options - ); - - const generator = new StoryIndexGenerator([specifier], { - ...options, - indexers: [ - { - test: /\.stories\.(m?js|ts)x?$/, - index: async (fileName) => [ - { - key: 'StoryOne', - name: 'Story One', - title: 'A', - tags: ['story-tag-from-indexer'], - importPath: fileName, - type: 'story', - }, - ], - }, - ], - }); - await generator.initialize(); - - expect(await generator.getIndex()).toMatchInlineSnapshot(` - Object { - "entries": Object { - "a--story-one": Object { - "id": "a--story-one", - "importPath": "./src/A.stories.js", - "name": "Story One", - "tags": Array [ - "story-tag-from-indexer", - "story", - ], - "title": "A", - "type": "story", - }, - }, - "v": 4, - } - `); - }); - - it('auto-generates id, title and name from key input', async () => { - const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/A.stories.js', - options - ); - - const generator = new StoryIndexGenerator([specifier], { - ...options, - indexers: [ - { - test: /\.stories\.(m?js|ts)x?$/, - index: async (fileName) => [ - { - key: 'StoryOne', - tags: ['story-tag-from-indexer'], - importPath: fileName, - type: 'story', - }, - ], - }, - ], - }); - await generator.initialize(); - - expect(await generator.getIndex()).toMatchInlineSnapshot(` - Object { - "entries": Object { - "a--story-one": Object { - "id": "a--story-one", - "importPath": "./src/A.stories.js", - "name": "Story One", - "tags": Array [ - "story-tag-from-indexer", - "story", - ], - "title": "A", - "type": "story", - }, - }, - "v": 4, - } - `); - }); - }); - describe('single file specifier', () => { it('extracts stories from the right files', async () => { const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( diff --git a/code/lib/core-server/src/utils/__tests__/index-extraction.test.ts b/code/lib/core-server/src/utils/__tests__/index-extraction.test.ts new file mode 100644 index 000000000000..4056d8d739c0 --- /dev/null +++ b/code/lib/core-server/src/utils/__tests__/index-extraction.test.ts @@ -0,0 +1,530 @@ +/// ; + +/** + * @jest-environment node + */ + +import path from 'path'; +import { normalizeStoriesEntry } from '@storybook/core-common'; +import type { NormalizedStoriesSpecifier } from '@storybook/types'; +import { logger, once } from '@storybook/node-logger'; + +import type { StoryIndexGeneratorOptions } from '../StoryIndexGenerator'; +import { AUTODOCS_TAG, STORIES_MDX_TAG, StoryIndexGenerator } from '../StoryIndexGenerator'; + +jest.mock('@storybook/node-logger'); + +const options: StoryIndexGeneratorOptions = { + configDir: path.join(__dirname, '..', '__mockdata__'), + workingDir: path.join(__dirname, '..', '__mockdata__'), + storyIndexers: [], + indexers: [], + storiesV2Compatibility: false, + storyStoreV7: true, + docs: { defaultName: 'docs', autodocs: false }, +}; + +describe('story extraction', () => { + it('extracts stories from full indexer inputs', async () => { + const relativePath = './src/A.stories.js'; + const absolutePath = path.join(options.workingDir, relativePath); + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry(relativePath, options); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + id: 'a--story-one', + name: 'Story One', + title: 'A', + tags: ['story-tag-from-indexer'], + importPath: fileName, + type: 'story', + }, + ], + }, + ], + }); + const result = await generator.extractStories(specifier, absolutePath); + + expect(result).toMatchInlineSnapshot(` + Object { + "dependents": Array [], + "entries": Array [ + Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag-from-indexer", + "story", + ], + "title": "A", + "type": "story", + }, + ], + "type": "stories", + } + `); + }); + + it('extracts stories from minimal indexer inputs', async () => { + const relativePath = './src/first-nested/deeply/F.stories.js'; + const absolutePath = path.join(options.workingDir, relativePath); + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry(relativePath, options); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + importPath: fileName, + type: 'story', + }, + ], + }, + ], + }); + const result = await generator.extractStories(specifier, absolutePath); + + expect(result).toMatchInlineSnapshot(` + Object { + "dependents": Array [], + "entries": Array [ + Object { + "id": "f--story-one", + "importPath": "./src/first-nested/deeply/F.stories.js", + "name": "Story One", + "tags": Array [ + "story", + ], + "title": "F", + "type": "story", + }, + ], + "type": "stories", + } + `); + }); + + it('auto-generates title from indexer inputs without title', async () => { + const relativePath = './src/first-nested/deeply/F.stories.js'; + const absolutePath = path.join(options.workingDir, relativePath); + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry(relativePath, options); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + id: 'a--story-one', + name: 'Story One', + tags: ['story-tag-from-indexer'], + importPath: fileName, + type: 'story', + }, + ], + }, + ], + }); + const result = await generator.extractStories(specifier, absolutePath); + + expect(result).toMatchInlineSnapshot(` + Object { + "dependents": Array [], + "entries": Array [ + Object { + "id": "a--story-one", + "importPath": "./src/first-nested/deeply/F.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag-from-indexer", + "story", + ], + "title": "F", + "type": "story", + }, + ], + "type": "stories", + } + `); + }); + + it('auto-generates name from indexer inputs without name', async () => { + const relativePath = './src/A.stories.js'; + const absolutePath = path.join(options.workingDir, relativePath); + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry(relativePath, options); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + id: 'a--story-one', + title: 'A', + tags: ['story-tag-from-indexer'], + importPath: fileName, + type: 'story', + }, + ], + }, + ], + }); + const result = await generator.extractStories(specifier, absolutePath); + + expect(result).toMatchInlineSnapshot(` + Object { + "dependents": Array [], + "entries": Array [ + Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag-from-indexer", + "story", + ], + "title": "A", + "type": "story", + }, + ], + "type": "stories", + } + `); + }); + + it('auto-generates id from name and title inputs', async () => { + const relativePath = './src/A.stories.js'; + const absolutePath = path.join(options.workingDir, relativePath); + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry(relativePath, options); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + name: 'Story One', + title: 'A', + tags: ['story-tag-from-indexer'], + importPath: fileName, + type: 'story', + }, + ], + }, + ], + }); + const result = await generator.extractStories(specifier, absolutePath); + + expect(result).toMatchInlineSnapshot(` + Object { + "dependents": Array [], + "entries": Array [ + Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag-from-indexer", + "story", + ], + "title": "A", + "type": "story", + }, + ], + "type": "stories", + } + `); + }); + + it('auto-generates id, title and name from key input', async () => { + const relativePath = './src/A.stories.js'; + const absolutePath = path.join(options.workingDir, relativePath); + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry(relativePath, options); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + tags: ['story-tag-from-indexer'], + importPath: fileName, + type: 'story', + }, + ], + }, + ], + }); + const result = await generator.extractStories(specifier, absolutePath); + + expect(result).toMatchInlineSnapshot(` + Object { + "dependents": Array [], + "entries": Array [ + Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag-from-indexer", + "story", + ], + "title": "A", + "type": "story", + }, + ], + "type": "stories", + } + `); + }); +}); +describe('docs entries from story extraction', () => { + it('adds docs entry when autodocs is globally enabled', async () => { + const relativePath = './src/A.stories.js'; + const absolutePath = path.join(options.workingDir, relativePath); + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry(relativePath, options); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + docs: { defaultName: 'docs', autodocs: true }, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + id: 'a--story-one', + name: 'Story One', + title: 'A', + tags: ['story-tag-from-indexer'], + importPath: fileName, + type: 'story', + }, + ], + }, + ], + }); + const result = await generator.extractStories(specifier, absolutePath); + + expect(result).toMatchInlineSnapshot(` + Object { + "dependents": Array [], + "entries": Array [ + Object { + "id": "a--docs", + "importPath": "./src/A.stories.js", + "name": "docs", + "storiesImports": Array [], + "tags": Array [ + "story-tag-from-indexer", + "docs", + "autodocs", + ], + "title": "A", + "type": "docs", + }, + Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "story-tag-from-indexer", + "story", + ], + "title": "A", + "type": "story", + }, + ], + "type": "stories", + } + `); + }); + it(`adds docs entry when autodocs is "tag" and an entry has the "${AUTODOCS_TAG}" tag`, async () => { + const relativePath = './src/A.stories.js'; + const absolutePath = path.join(options.workingDir, relativePath); + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry(relativePath, options); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + docs: { defaultName: 'docs', autodocs: 'tag' }, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + id: 'a--story-one', + name: 'Story One', + title: 'A', + tags: [AUTODOCS_TAG, 'story-tag-from-indexer'], + importPath: fileName, + type: 'story', + }, + ], + }, + ], + }); + const result = await generator.extractStories(specifier, absolutePath); + + expect(result).toMatchInlineSnapshot(` + Object { + "dependents": Array [], + "entries": Array [ + Object { + "id": "a--docs", + "importPath": "./src/A.stories.js", + "name": "docs", + "storiesImports": Array [], + "tags": Array [ + "autodocs", + "story-tag-from-indexer", + "docs", + ], + "title": "A", + "type": "docs", + }, + Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "autodocs", + "story-tag-from-indexer", + "story", + ], + "title": "A", + "type": "story", + }, + ], + "type": "stories", + } + `); + }); + it(`DOES NOT adds docs entry when autodocs is false and an entry has the "${AUTODOCS_TAG}" tag`, async () => { + const relativePath = './src/A.stories.js'; + const absolutePath = path.join(options.workingDir, relativePath); + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry(relativePath, options); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + docs: { defaultName: 'docs', autodocs: false }, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + id: 'a--story-one', + name: 'Story One', + title: 'A', + tags: [AUTODOCS_TAG, 'story-tag-from-indexer'], + importPath: fileName, + type: 'story', + }, + ], + }, + ], + }); + const result = await generator.extractStories(specifier, absolutePath); + + expect(result).toMatchInlineSnapshot(` + Object { + "dependents": Array [], + "entries": Array [ + Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "autodocs", + "story-tag-from-indexer", + "story", + ], + "title": "A", + "type": "story", + }, + ], + "type": "stories", + } + `); + }); + it(`adds docs entry when an entry has the "${STORIES_MDX_TAG}" tag`, async () => { + const relativePath = './src/A.stories.js'; + const absolutePath = path.join(options.workingDir, relativePath); + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry(relativePath, options); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + docs: { defaultName: 'docs', autodocs: false }, + indexers: [ + { + test: /\.stories\.(m?js|ts)x?$/, + index: async (fileName) => [ + { + key: 'StoryOne', + id: 'a--story-one', + name: 'Story One', + title: 'A', + tags: [STORIES_MDX_TAG, 'story-tag-from-indexer'], + importPath: fileName, + type: 'story', + }, + ], + }, + ], + }); + const result = await generator.extractStories(specifier, absolutePath); + + expect(result).toMatchInlineSnapshot(` + Object { + "dependents": Array [], + "entries": Array [ + Object { + "id": "a--docs", + "importPath": "./src/A.stories.js", + "name": "docs", + "storiesImports": Array [], + "tags": Array [ + "stories-mdx", + "story-tag-from-indexer", + "docs", + ], + "title": "A", + "type": "docs", + }, + Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "tags": Array [ + "stories-mdx", + "story-tag-from-indexer", + "story", + ], + "title": "A", + "type": "story", + }, + ], + "type": "stories", + } + `); + }); +}); From 2215a8af1e6d33c3136a9def44446ea43afde0eb Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 27 Jul 2023 11:11:29 +0200 Subject: [PATCH 11/15] export DeprecatedIndexer --- code/lib/types/src/modules/indexer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/lib/types/src/modules/indexer.ts b/code/lib/types/src/modules/indexer.ts index 6c44da894569..1789585347e0 100644 --- a/code/lib/types/src/modules/indexer.ts +++ b/code/lib/types/src/modules/indexer.ts @@ -72,7 +72,7 @@ export type Indexer = BaseIndexer & { indexer?: never; }; -type DeprecatedIndexer = BaseIndexer & { +export type DeprecatedIndexer = BaseIndexer & { indexer: (fileName: string, options: IndexerOptions) => Promise; index?: never; }; From 1ed123d16ee4ccd3c807adfdeb039490d3c32aca Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 27 Jul 2023 11:19:18 +0200 Subject: [PATCH 12/15] fix type check --- .../lib/core-server/src/utils/__tests__/index-extraction.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/code/lib/core-server/src/utils/__tests__/index-extraction.test.ts b/code/lib/core-server/src/utils/__tests__/index-extraction.test.ts index 4056d8d739c0..a9de04473d39 100644 --- a/code/lib/core-server/src/utils/__tests__/index-extraction.test.ts +++ b/code/lib/core-server/src/utils/__tests__/index-extraction.test.ts @@ -7,7 +7,6 @@ import path from 'path'; import { normalizeStoriesEntry } from '@storybook/core-common'; import type { NormalizedStoriesSpecifier } from '@storybook/types'; -import { logger, once } from '@storybook/node-logger'; import type { StoryIndexGeneratorOptions } from '../StoryIndexGenerator'; import { AUTODOCS_TAG, STORIES_MDX_TAG, StoryIndexGenerator } from '../StoryIndexGenerator'; From 52f4acad0edda061fba83b48b9f65bac14535761 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 27 Jul 2023 11:29:21 +0200 Subject: [PATCH 13/15] revert eslint rule --- code/addons/essentials/src/themes/preview.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/code/addons/essentials/src/themes/preview.ts b/code/addons/essentials/src/themes/preview.ts index 4abc0dfe9b00..08db0c7bf907 100644 --- a/code/addons/essentials/src/themes/preview.ts +++ b/code/addons/essentials/src/themes/preview.ts @@ -1 +1,2 @@ +// eslint-disable-next-line import/export export * from '@storybook/addon-themes/preview'; From 9c39f2283951a717c91bce263d9eb7c7c2b4ea4b Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 27 Jul 2023 11:38:28 +0200 Subject: [PATCH 14/15] revert eslint rule --- code/addons/interactions/src/components/MatcherResult.tsx | 1 + code/addons/interactions/src/components/MethodCall.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/code/addons/interactions/src/components/MatcherResult.tsx b/code/addons/interactions/src/components/MatcherResult.tsx index 4f5368bd46ba..a8a1e00a63f7 100644 --- a/code/addons/interactions/src/components/MatcherResult.tsx +++ b/code/addons/interactions/src/components/MatcherResult.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react/no-array-index-key */ import React from 'react'; import { styled, typography } from '@storybook/theming'; import { Node } from './MethodCall'; diff --git a/code/addons/interactions/src/components/MethodCall.tsx b/code/addons/interactions/src/components/MethodCall.tsx index bbf2d6e3018b..a12aaa4f1a29 100644 --- a/code/addons/interactions/src/components/MethodCall.tsx +++ b/code/addons/interactions/src/components/MethodCall.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react/no-array-index-key */ import { ObjectInspector } from '@devtools-ds/object-inspector'; import type { Call, CallRef, ElementRef } from '@storybook/instrumenter'; import { useTheme } from '@storybook/theming'; From 0d5a537f705e123181edbe6b0cd08c8d59b2d43d Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 27 Jul 2023 12:40:28 +0200 Subject: [PATCH 15/15] streamline errors to use invariant --- .../src/utils/StoryIndexGenerator.ts | 73 +++++++++++-------- .../src/utils/stories-json.test.ts | 12 +-- 2 files changed, 47 insertions(+), 38 deletions(-) diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.ts index e52912cf660e..bbd5234e3cdd 100644 --- a/code/lib/core-server/src/utils/StoryIndexGenerator.ts +++ b/code/lib/core-server/src/utils/StoryIndexGenerator.ts @@ -183,10 +183,12 @@ export class StoryIndexGenerator { await Promise.all( this.specifiers.map(async (specifier) => { const entry = this.specifierToCache.get(specifier); - if (!entry) - throw new Error( - `specifier ${specifier} does not have a matching cache entry in specifierToCache` - ); + invariant( + entry, + `specifier does not have a matching cache entry in specifierToCache: ${JSON.stringify( + specifier + )}` + ); return Promise.all( Object.keys(entry).map(async (absolutePath) => { if (entry[absolutePath] && !overwrite) return; @@ -233,10 +235,12 @@ export class StoryIndexGenerator { return this.specifiers.flatMap((specifier) => { const cache = this.specifierToCache.get(specifier); - if (!cache) - throw new Error( - `specifier ${specifier} does not have a matching cache entry in specifierToCache` - ); + invariant( + cache, + `specifier does not have a matching cache entry in specifierToCache: ${JSON.stringify( + specifier + )}` + ); return Object.values(cache).flatMap((entry): (IndexEntry | ErrorEntry)[] => { if (!entry) return []; if (entry.type === 'docs') return [entry]; @@ -272,10 +276,10 @@ export class StoryIndexGenerator { const importPath = slash(normalizeStoryPath(relativePath)); const defaultMakeTitle = (userTitle?: string) => { const title = userOrAutoTitleFromSpecifier(importPath, specifier, userTitle); - if (!title) - throw new Error( - "makeTitle created an undefined title. This happens when a specifier's doesn't have any matches in its fileName" - ); + invariant( + title, + "makeTitle created an undefined title. This happens when a specifier's doesn't have any matches in its fileName" + ); return title; }; @@ -283,9 +287,8 @@ export class StoryIndexGenerator { .concat(this.options.storyIndexers) .find((ind) => ind.test.exec(absolutePath)); - if (!indexer) { - throw new Error(`No matching indexer found for ${absolutePath}`); - } + invariant(indexer, `No matching indexer found for ${absolutePath}`); + if (indexer.indexer) { return this.extractStoriesFromDeprecatedIndexer({ indexer: indexer.indexer, @@ -407,9 +410,10 @@ export class StoryIndexGenerator { async extractDocs(specifier: NormalizedStoriesSpecifier, absolutePath: Path) { const relativePath = path.relative(this.options.workingDir, absolutePath); try { - if (!this.options.storyStoreV7) { - throw new Error(`You cannot use \`.mdx\` files without using \`storyStoreV7\`.`); - } + invariant( + this.options.storyStoreV7, + `You cannot use \`.mdx\` files without using \`storyStoreV7\`.` + ); const normalizedPath = normalizeStoryPath(relativePath); const importPath = slash(normalizedPath); @@ -462,15 +466,15 @@ export class StoryIndexGenerator { sortedDependencies = [dep, ...dependencies.filter((d) => d !== dep)]; }); - if (!csfEntry) - throw new Error( - dedent`Could not find or load CSF file at path "${result.of}" referenced by \`of={}\` in docs file "${relativePath}". + invariant( + csfEntry, + dedent`Could not find or load CSF file at path "${result.of}" referenced by \`of={}\` in docs file "${relativePath}". - - Does that file exist? - - If so, is it a CSF file (\`.stories.*\`)? - - If so, is it matched by the \`stories\` glob in \`main.js\`? - - If so, has the file successfully loaded in Storybook and are its stories visible?` - ); + - Does that file exist? + - If so, is it a CSF file (\`.stories.*\`)? + - If so, is it matched by the \`stories\` glob in \`main.js\`? + - If so, has the file successfully loaded in Storybook and are its stories visible?` + ); } // Track that we depend on this for easy invalidation later. @@ -480,12 +484,12 @@ export class StoryIndexGenerator { const title = csfEntry?.title || userOrAutoTitleFromSpecifier(importPath, specifier, result.title); - if (!title) - throw new Error( - "makeTitle created an undefined title. This happens when a specifier's doesn't have any matches in its fileName" - ); + invariant( + title, + "makeTitle created an undefined title. This happens when a specifier's doesn't have any matches in its fileName" + ); const { defaultName } = this.options.docs; - if (!defaultName) throw new Error('expected a defaultName property in options.docs'); + invariant(defaultName, 'expected a defaultName property in options.docs'); const name = result.name || @@ -680,7 +684,12 @@ export class StoryIndexGenerator { invalidate(specifier: NormalizedStoriesSpecifier, importPath: Path, removed: boolean) { const absolutePath = slash(path.resolve(this.options.workingDir, importPath)); const cache = this.specifierToCache.get(specifier); - if (!cache) throw new Error(`no `); + invariant( + cache, + `specifier does not have a matching cache entry in specifierToCache: ${JSON.stringify( + specifier + )}` + ); const cacheEntry = cache[absolutePath]; if (cacheEntry && cacheEntry.type === 'stories') { const { dependents } = cacheEntry; diff --git a/code/lib/core-server/src/utils/stories-json.test.ts b/code/lib/core-server/src/utils/stories-json.test.ts index 7fdbc763e5b3..018caa6e69db 100644 --- a/code/lib/core-server/src/utils/stories-json.test.ts +++ b/code/lib/core-server/src/utils/stories-json.test.ts @@ -766,12 +766,12 @@ describe('useStoriesJson', () => { expect(send).toHaveBeenCalledTimes(1); expect(send.mock.calls[0][0]).toMatchInlineSnapshot(` "Unable to index files: - - ./src/docs2/ComponentReference.mdx: You cannot use \`.mdx\` files without using \`storyStoreV7\`. - - ./src/docs2/MetaOf.mdx: You cannot use \`.mdx\` files without using \`storyStoreV7\`. - - ./src/docs2/NoTitle.mdx: You cannot use \`.mdx\` files without using \`storyStoreV7\`. - - ./src/docs2/SecondMetaOf.mdx: You cannot use \`.mdx\` files without using \`storyStoreV7\`. - - ./src/docs2/Template.mdx: You cannot use \`.mdx\` files without using \`storyStoreV7\`. - - ./src/docs2/Title.mdx: You cannot use \`.mdx\` files without using \`storyStoreV7\`." + - ./src/docs2/ComponentReference.mdx: Invariant failed: You cannot use \`.mdx\` files without using \`storyStoreV7\`. + - ./src/docs2/MetaOf.mdx: Invariant failed: You cannot use \`.mdx\` files without using \`storyStoreV7\`. + - ./src/docs2/NoTitle.mdx: Invariant failed: You cannot use \`.mdx\` files without using \`storyStoreV7\`. + - ./src/docs2/SecondMetaOf.mdx: Invariant failed: You cannot use \`.mdx\` files without using \`storyStoreV7\`. + - ./src/docs2/Template.mdx: Invariant failed: You cannot use \`.mdx\` files without using \`storyStoreV7\`. + - ./src/docs2/Title.mdx: Invariant failed: You cannot use \`.mdx\` files without using \`storyStoreV7\`." `); });