///
/**
* UIG-19: the usage-page contract every app's `gate` job runs, and the story
* links. Each case is one the three repos actually have, or one that got past
* the copies this replaces:
*
* - a page that lost its `When not` (the ticket's own example);
* - Peek's pages, which never checked the order or the code under `How`;
* - a reusable part drawn only inside another story, whose page says so on a
* **Seen in** line (Katerina, 21 September);
* - a story file named after a part whose stories draw its `…View` half, in the
* same file — FilesPanel and TopicDetailsDialog in Peek, which the catalogue
* used to call drawn nowhere;
* - a Windows checkout, read with CRLF.
*/
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { buildAppRegistry } from './app'
import { helpers } from '../gates/status'
import { contractProblems, hasSeenIn, linkProblems, nameableParts, nameProblems, pageProblem, whenNotProblem } from './contract'
import type { Registry } from './schema'
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(), 'uig19-'))
made.push(dir)
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fixture-app', version: '1.0.0' }))
for (const [path, text] of Object.entries(files)) {
mkdirSync(dirname(join(dir, path)), { recursive: true })
writeFileSync(join(dir, path), text)
}
return dir
}
const PAGE = [
"import { Meta } from '@storybook/addon-docs/blocks'",
'',
"",
'',
'# Badge',
'',
'A small count beside a name.',
'',
'## When',
'',
'A count.',
'',
'## When not',
'',
'A word.',
'',
'No alternative: a count has only this badge.',
'',
'## How',
'',
'```tsx',
'',
'```',
'',
'## What it owns',
'',
'Nothing. It only draws.',
'',
].join('\n')
const part = (name: string, cls = 'reusable') =>
`/**\n * ${name}, for the fixture.\n * @registry ${cls}: the fixture says so\n * @registry namesake: the fixture says so\n */\nexport function ${name}() {\n return ${name}\n}\n`
describe('a usage page', () => {
it('keeps the contract with an opening line and the four sections in order', () => {
expect(pageProblem(PAGE)).toBeNull()
})
it('fails without "When not"', () => {
expect(pageProblem(PAGE.replace('## When not', '## Not when'))).toBe('no "When not" section')
})
it('fails with its sections out of order', () => {
const swapped = PAGE.replace('## When not\n\nA word.\n\nNo alternative: a count has only this badge.\n', '').replace('## What it owns', '## When not\n\nA word.\n\n## What it owns')
expect(pageProblem(swapped)).toMatch(/^sections out of order/)
})
it('fails with no opening line', () => {
expect(pageProblem(PAGE.replace('A small count beside a name.\n', ''))).toMatch(/^no opening line/)
})
// R18: the code must be under How itself; one in a later section passed.
it('fails with no code under How, even with code further down', () => {
expect(pageProblem(PAGE.replace('```tsx\n\n```', 'Pass a count.'))).toBe('no code under "How"')
const later = PAGE.replace('```tsx\n\n```', 'Pass a count.').replace('Nothing. It only draws.', '```tsx\n\n```')
expect(pageProblem(later)).toBe('no code under "How"')
})
// B8: When not names the part to use instead, or says there is none.
it('asks When not to name another real part, or to say there is none', () => {
const known = new Set(['Badge', 'Once', 'Chip'])
const whenNot = (body: string) => PAGE.replace('A word.\n\nNo alternative: a count has only this badge.', body)
expect(whenNotProblem(PAGE, ['Badge'], known)).toBeNull()
expect(whenNotProblem(whenNot('A word. That is a **Chip**.'), ['Badge'], known)).toBeNull()
expect(whenNotProblem(whenNot('- A word.\n\nNo alternative: a count has only this.'), ['Badge'], known)).toBeNull()
expect(whenNotProblem(whenNot('A word.'), ['Badge'], known)).toMatch(/^"When not" names no other part/)
// Its own name, a name nothing has, and "No alternative:" mid-line do not count.
expect(whenNotProblem(whenNot('Not **Badge** twice, nor a `Widget`. See: No alternative: here.'), ['Badge'], known)).not.toBeNull()
})
it('reads a Windows checkout', () => {
expect(pageProblem(PAGE.replace(/\n/g, '\r\n'))).toBeNull()
})
it('knows a Seen in line, and only at the start of a line', () => {
expect(hasSeenIn('**Seen in** — *Messages/ConversationCard*.')).toBe(true)
expect(hasSeenIn('It is **Seen in** two places.')).toBe(false)
})
})
describe('an app against the contract', () => {
it('passes a reusable part with a page and a story, and asks nothing of a one-off', () => {
const dir = app({
'src/Badge.tsx': part('Badge'),
'src/Badge.mdx': PAGE,
'src/Badge.stories.tsx': "import { Badge } from './Badge'\nconst meta = { title: 'Parts/Badge', component: Badge }\nexport default meta\nexport const Default = {}\n",
'src/Once.tsx': part('Once', 'one-off'),
})
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([])
})
// UIG-26: gates:status knew only the app's own parts, and read six of Peek's pages as broken that CI passed.
it('lets a page name a package part, in estiva-ui check and in gates:status alike', async () => {
const dir = app({
'src/Badge.tsx': part('Badge'),
'src/Badge.mdx': PAGE.replace('No alternative: a count has only this badge.', 'A framed column is `Panel`.'),
'src/Badge.stories.tsx': "import { Badge } from './Badge'\nconst meta = { title: 'Parts/Badge', component: Badge }\nexport default meta\nexport const Default = {}\n",
})
const registry = buildAppRegistry({ root: dir, repo: 'fixture' })
expect(contractProblems(registry, dir)).toEqual([expect.stringContaining('"When not" names no other part')])
const pkg = JSON.parse(readFileSync(join(process.cwd(), 'registry.json'), 'utf8')) as Registry
expect(contractProblems(registry, dir, nameableParts(registry, pkg))).toEqual([])
expect(await helpers(dir).contract('.')).toMatchObject({ result: 'pass' })
})
it('names a reusable part with no page, and the file to write', () => {
const dir = app({ 'src/Badge.tsx': part('Badge') })
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([
'Badge (reusable, src/Badge.tsx) has no usage page: write src/Badge.mdx, with an opening line, then When, When not, How, What it owns',
])
})
it('names a page that lost a section', () => {
const dir = app({ 'src/Badge.tsx': part('Badge'), 'src/Badge.mdx': PAGE.replace('## When not', '## Not when') })
const problems = contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)
expect(problems).toContain('Badge (reusable, src/Badge.tsx): src/Badge.mdx has no "When not" section')
})
it('names a reusable part drawn nowhere, and passes it once its page says where it is seen', () => {
const dir = app({ 'src/Badge.tsx': part('Badge'), 'src/Badge.mdx': PAGE })
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([
'Badge (reusable, src/Badge.tsx) is drawn nowhere: give it a story, or write a "**Seen in**" line on src/Badge.mdx naming the stories that draw it, or why none can',
])
writeFileSync(join(dir, 'src/Badge.mdx'), PAGE.replace('A small count beside a name.\n', 'A small count beside a name.\n\n**Seen in** — *Parts/Card*, beside the title.\n'))
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([])
})
it("links a part with no story to its page by the page's title, and names one that links to nothing (B10)", () => {
const seen = PAGE.replace('A small count beside a name.\n', 'A small count beside a name.\n\n**Seen in** — *Parts/Card*, beside the title.\n')
const dir = app({ 'src/Badge.tsx': part('Badge'), 'src/Badge.mdx': seen })
const registry = buildAppRegistry({ root: dir, repo: 'fixture' })
expect(registry.entries.find((e) => e.name === 'Badge')).toMatchObject({ docsId: 'parts-badge--docs', storyId: null })
writeFileSync(join(dir, 'src/Badge.mdx'), seen.replace("", ''))
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([
'Badge (reusable, src/Badge.tsx) links to nothing in Storybook: give src/Badge.mdx a ``, or give the part a story',
])
})
it("counts a story file named after the part that draws its view, from the part's own file", () => {
const dir = app({
'src/Panel.tsx': `${part('Panel')}\n/** The half that only draws. */\nexport function PanelView() {\n return view
\n}\n`,
'src/Panel.stories.tsx': "import { PanelView } from './Panel'\nconst meta = { title: 'Parts/Panel', component: PanelView }\nexport default meta\nexport const Open = {}\n",
})
const entry = buildAppRegistry({ root: dir, repo: 'fixture' }).entries.find((e) => e.name === 'Panel')
expect(entry?.storyId).toBe('parts-panel--open')
})
})
describe('story links', () => {
it('names a link its Storybook does not have, and passes the ones it has', () => {
const dir = app({
'src/Badge.tsx': part('Badge'),
'src/Badge.stories.tsx': "import { Badge } from './Badge'\nconst meta = { title: 'Parts/Badge', component: Badge }\nexport default meta\nexport const Default = {}\n",
})
const registry = buildAppRegistry({ root: dir, repo: 'fixture' })
expect(linkProblems(registry, new Set(['parts-badge--default', 'parts-badge--docs']))).toEqual([])
expect(linkProblems(registry, new Set(['parts-badge--docs']))).toEqual([
'Badge (src/Badge.tsx) links to a story its Storybook does not have: parts-badge--default',
])
})
})
// R14, the re-review after the audit before UIG-26. Every case is Peek's or Ship's own:
// StartTopicDialog sent readers to CreateTopicDialog and RepliesRow to HuddleCard, both
// deleted by FOL-23; EmptyState's Seen in named a story on two lines; Ship's Attachment
// named the package's story by a title it had lost.
describe('nameProblems', () => {
const page = (whenNot: string, seenIn = '') =>
PAGE.replace('A word.', whenNot).replace('A small count beside a name.', `A small count beside a name.${seenIn ? `\n\n${seenIn}` : ''}`)
const registry = (dir: string) => ({ entries: [{ name: 'Badge', docPage: 'src/Badge.mdx' }] }) as unknown as Parameters[0]
const known = new Set(['Badge', 'Chip'])
it('names a part under When not that no catalogue has, and passes one that exists', () => {
const dir = app({ 'src/Badge.mdx': page('A topic someone is invited to: that is **CreateTopicDialog**; a label is `Chip`.') })
expect(nameProblems(registry(dir), dir, { known })).toEqual(['src/Badge.mdx: "When not" names CreateTopicDialog, which is no part of the package or this app'])
})
it('reads a Seen in line against the Storybook, a title wrapped across lines too, and the package ids', () => {
const seen = '**Seen in** — *Parts/Badge*, *To be removed/\nTopicActivity* and\n*Media/AttachmentCard*.'
const dir = app({ 'src/Badge.mdx': page('A word.', seen) })
const titles = new Set(['Parts/Badge'])
expect(nameProblems(registry(dir), dir, { known, titles, packageIds: ['components-attachmentcard--document'] })).toEqual([
'src/Badge.mdx: "Seen in" names To be removed/TopicActivity, which no Storybook has',
'src/Badge.mdx: "Seen in" names Media/AttachmentCard, which no Storybook has',
])
expect(nameProblems(registry(dir), dir, { known, titles: new Set(['Parts/Badge', 'To be removed/TopicActivity']), packageIds: ['media-attachmentcard--default'] })).toEqual([])
})
it('leaves the Seen in line alone when there is no Storybook index to read', () => {
const dir = app({ 'src/Badge.mdx': page('A word.', '**Seen in** — *Gone/Story*.') })
expect(nameProblems(registry(dir), dir, { known })).toEqual([])
})
})