///
/**
* The hand-written map of a Storybook (audit A3, widened by R16). Every case is a
* real one: Peek's Huddles heading, kept in its Introduction and `HEADINGS` after
* FOL-23 deleted the stories, and its "49 parts worth reusing" when the catalogue
* built 41; Ship's `storySort.order` naming a Feedback heading it does not have and
* leaving out Pages, and its Introduction's bullets; the package's order leaving
* out Layout; and a Storybook with no map at all, which has nothing to rot.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import type { Registry } from './schema'
import { indexHeadings, introductionCount, introductionHeadings, orderedHeadings, sidebarHeadings, storyMapProblems } from './storymap'
const made: string[] = []
afterAll(() => {
for (const dir of made) rmSync(dir, { recursive: true, force: true })
})
function app(files: Record): string {
const dir = mkdtempSync(join(tmpdir(), 'storymap-'))
made.push(dir)
for (const [path, text] of Object.entries(files)) {
mkdirSync(dirname(join(dir, path)), { recursive: true })
writeFileSync(join(dir, path), text)
}
return dir
}
/** Only the classes matter here: the Introduction counts reusable and promote-candidate parts. */
const catalogue = (...classes: string[]) => ({ entries: classes.map((cls) => ({ app: { class: cls } })) }) as unknown as Registry
const STORY = (title: string) => `import type { Meta } from '@storybook/react-vite'\nconst meta = {\n title: '${title}',\n} satisfies Meta\nexport default meta\n`
const HEADINGS_PREVIEW = (headings: string[]) =>
`export default {\n parameters: {\n options: {\n storySort: (a, b) => {\n const HEADINGS = [\n${headings.map((h) => ` '${h}', // someone's heading\n`).join('')} ]\n return 0\n },\n },\n },\n}\n`
const ORDER_PREVIEW = (headings: string[]) =>
`export default {\n parameters: {\n options: {\n // Docs first; the rest follows\n storySort: {\n order: [${headings.map((h) => (h === 'Docs' ? "'Docs', ['Introduction']" : `'${h}'`)).join(', ')}],\n },\n },\n },\n}\n`
const TABLE_INTRO = (count: number, headings: string[]) =>
`import { Meta } from '@storybook/addon-docs/blocks'\n\n\n\nOf everything the app owns, **${count} parts worth reusing** carry a written rule.\n\n| Heading | What |\n|---|---|\n${headings.map((h) => `| **${h}** | About ${h}. |`).join('\n')}\n`
const BULLET_INTRO = (groups: string[][]) =>
`\n\n## How the sidebar is organised\n\n${groups.map((g) => `- **${g.join(' · ')}** — about them.`).join('\n')}\n\n## Conventions\n\n- **Stories first.** A component exists in Storybook first.\n`
const PEEK_TODAY = {
'src/stories/Introduction.mdx': TABLE_INTRO(2, ['Docs', 'Primitives', 'Huddles']),
'src/components/ui/Badge.stories.tsx': STORY('Primitives/Badge'),
'src/components/ui/Chip.mdx': `import { Meta } from '@storybook/addon-docs/blocks'\n\n\n\n# Chip\n`,
'.storybook/preview.tsx': HEADINGS_PREVIEW(['Docs', 'Primitives', 'Huddles']),
}
describe('reading the map', () => {
it('reads the sidebar from story titles and page titles in either quote, in src and stories', () => {
expect([...sidebarHeadings(app({ ...PEEK_TODAY, 'stories/Tokens.mdx': "\n" }))].sort()).toEqual(['Docs', 'Primitives', 'Tokens'])
})
it("reads Storybook's own index, whatever way a title was written", () => {
expect([...indexHeadings({ a: { title: 'Pages/Desk' }, b: { title: 'Docs/Introduction' }, c: {} })].sort()).toEqual(['Docs', 'Pages'])
})
it('reads a HEADINGS list and a storySort order, skipping comments and nested lists', () => {
expect(orderedHeadings(HEADINGS_PREVIEW(['Docs', 'Pages']))).toEqual(['Docs', 'Pages'])
expect(orderedHeadings(ORDER_PREVIEW(['Docs', 'Primitives', 'Feedback']))).toEqual(['Docs', 'Primitives', 'Feedback'])
expect(orderedHeadings('export default {}')).toBeNull()
})
it("reads the Introduction's table rows and its bullets, and its stated count", () => {
expect([...introductionHeadings(TABLE_INTRO(49, ['Docs', 'Huddles']))]).toEqual(['Docs', 'Huddles'])
// A bullet with no dash after its bold words is prose, not a heading.
expect([...introductionHeadings(BULLET_INTRO([['Docs'], ['Primitives', 'Feedback', 'Inputs']]))]).toEqual(['Docs', 'Primitives', 'Feedback', 'Inputs'])
expect(introductionCount(TABLE_INTRO(49, ['Docs']))).toBe(49)
expect(introductionCount('# No number here')).toBeNull()
})
})
describe('storyMapProblems', () => {
it("finds Peek's three stale lines: a heading in both maps with no story, and the wrong count", () => {
expect(storyMapProblems(app(PEEK_TODAY), { registry: catalogue('reusable', 'reusable', 'reusable', 'local') })).toEqual([
'.storybook/preview.tsx: in the heading order but no story uses them: Huddles',
'src/stories/Introduction.mdx: the page names, the sidebar does not have: Huddles',
'src/stories/Introduction.mdx: says 2 parts worth reusing; the catalogue builds 3',
])
})
it("finds Ship's: an order and bullets naming Feedback, which it has not got, and leaving out Pages", () => {
const dir = app({
'.storybook/preview.tsx': ORDER_PREVIEW(['Docs', 'Primitives', 'Feedback']),
'src/stories/Introduction.mdx': BULLET_INTRO([['Docs'], ['Primitives', 'Feedback']]),
})
expect(storyMapProblems(dir, { real: new Set(['Docs', 'Primitives', 'Pages']) })).toEqual([
'.storybook/preview.tsx: not in the heading order, so they fall to the bottom of the sidebar: Pages',
'.storybook/preview.tsx: in the heading order but no story uses them: Feedback',
'src/stories/Introduction.mdx: the sidebar has, the page does not name: Pages',
'src/stories/Introduction.mdx: the page names, the sidebar does not have: Feedback',
])
})
it("finds the package's order leaving out Layout, from a Storybook index", () => {
const dir = app({ '.storybook/preview.tsx': ORDER_PREVIEW(['Docs', 'Primitives']) })
expect(storyMapProblems(dir, { real: indexHeadings({ a: { title: 'Layout/ListColumn' }, b: { title: 'Primitives/Button' }, c: { title: 'Docs/Introduction' } }) })).toEqual([
'.storybook/preview.tsx: not in the heading order, so they fall to the bottom of the sidebar: Layout',
])
})
it('passes when both maps match the Storybook and the count matches the catalogue', () => {
const dir = app({
...PEEK_TODAY,
'src/stories/Introduction.mdx': TABLE_INTRO(2, ['Docs', 'Primitives']),
'.storybook/preview.tsx': HEADINGS_PREVIEW(['Docs', 'Primitives']),
})
expect(storyMapProblems(dir, { registry: catalogue('reusable', 'promote-candidate', 'local') })).toEqual([])
})
it('has nothing to check in a repository that keeps no map', () => {
expect(storyMapProblems(app({ 'src/Badge.stories.tsx': STORY('Parts/Badge') }), { registry: catalogue('reusable') })).toEqual([])
})
})