///
/**
* UIG-13: an app's catalogue. Every case below is one a real app has, or one
* that broke the first draft of the reader against Peek and Ship:
*
* - a file's description sits at the very top, above its imports (Ship's style);
* - a part draws nothing and is only ever used as a tag (a listener, a provider);
* - `export { A, B } from '@estiva-app/ui'` is one statement handing on two parts;
* - a card is only drawn by the list in its own file;
* - a page is loaded with `lazy(() => import('./Page'))`, and the app itself with
* `const { default: App } = await import('./App.tsx')`;
* - a part is reached through a barrel file;
* - a part's type comes from a file that imports an app's data package.
*/
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it } from 'vitest'
import { buildAppRegistry } from './app'
import { findInRegistries, formatFindings } from './find'
import { validateRegistry, type Registry, type RegistryEntry } from './schema'
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const packageRegistry = JSON.parse(readFileSync(join(repoRoot, 'registry.json'), 'utf8')) as Registry
const made: string[] = []
afterAll(() => {
for (const dir of made) rmSync(dir, { recursive: true, force: true })
})
/** Write an app into a fresh folder and return the folder. */
function app(files: Record, manifest: object = { name: 'fixture-app', version: '1.2.3', scripts: { storybook: 'storybook dev -p 6123' } }): string {
const dir = mkdtempSync(join(tmpdir(), 'uig13-'))
made.push(dir)
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
for (const [path, text] of Object.entries(files)) {
mkdirSync(dirname(join(dir, path)), { recursive: true })
writeFileSync(join(dir, path), text)
}
return dir
}
const FIXTURE: Record = {
// Comments in a tsconfig, as Peek writes them.
'tsconfig.app.json': '{\n // the alias\n "compilerOptions": { "paths": { "@/*": ["./src/*"] } }\n}\n',
'src/main.tsx': "void (async () => {\n const { default: App } = await import('./App.tsx')\n void App\n})()\n",
'src/App.tsx': [
"import { Frame } from '@/components/Frame'",
"import { TopicSync } from '@/components/Sync'",
"import { CtxProvider } from '@/components/Ctx'",
"import { HomePage } from './pages/HomePage'",
'',
'/** The whole app: its frame and its one page. */',
'export default function App() {',
' return ',
'}',
'',
].join('\n'),
'src/components/ui/Button.tsx': "/** Hands on the package's Button. */\nexport { Button, type ButtonProps } from '@estiva-app/ui'\n",
'src/components/ui/Menu.tsx': "export { Menu, MenuItem } from '@estiva-app/ui'\n",
'src/components/Frame.tsx': [
'/**',
' * The frame every page sits in, with room for a side panel.',
' *',
' * It is written once, at the top, above the imports.',
' */',
"import { EmptyState } from '@estiva-app/ui'",
"import type { ReactNode } from 'react'",
'',
'export interface FrameProps {',
' /** What sits inside. */',
' children?: ReactNode',
" side?: 'left' | 'right'",
'}',
'',
'export function Frame({ children }: FrameProps) {',
' return
{children ?? }
',
'}',
'',
].join('\n'),
'src/components/Frame.stories.tsx': [
"import type { Meta, StoryObj } from '@storybook/react-vite'",
"import { Frame } from './Frame'",
'const meta = {',
" title: 'Layout/Frame',",
' component: Frame,',
'} satisfies Meta',
'export default meta',
'export const Default: StoryObj = {}',
'export const WithSidebar: StoryObj = {}',
'',
].join('\n'),
'src/components/Card.tsx': [
"import { Button } from '@/components/ui/Button'",
'',
'interface CardProps {',
' title: string',
'}',
'',
'/** One row inside a card. */',
'export function CardRow({ title }: CardProps) {',
' return
{title}
',
'}',
'',
'/**',
' * A card that lists its rows and a button.',
" * @registry namesake: the fixture's own card",
' */',
'export function Card({ title }: CardProps) {',
' return
',
'}',
'',
].join('\n'),
'src/components/Card.test.tsx': "import { Card } from './Card'\nvoid Card\n",
'src/api/types.ts': "import type { Doc } from 'convex/react'\nexport type Item = Doc & { at: number }\n",
'src/components/timelineTypes.ts': "import type { Item } from '@/api/types'\nexport interface TimelineProps {\n /** The things that happened, oldest first. */\n items: Item[]\n}\n",
'src/components/Timeline.tsx': [
"import type { TimelineProps } from '@/components/timelineTypes'",
'',
'/** What happened to a thing, as a timeline. */',
'export function Timeline({ items }: TimelineProps) {',
' return {items.map((i) => )}',
'}',
'',
].join('\n'),
'src/components/index.ts': "export { Timeline } from './Timeline'\n",
'src/components/Sync.tsx': "import { useEffect } from 'react'\n\n/** Keeps the topic list in step with the relay, and draws nothing. */\nexport function TopicSync() {\n useEffect(() => {}, [])\n return null\n}\n",
'src/components/Ctx.tsx': "import { createContext } from 'react'\n\nconst Ctx = createContext(0)\n/** Gives every part below it the number. */\nexport const CtxProvider = Ctx.Provider\nexport const ThemeContext = createContext('light')\n",
'src/components/Lonely.tsx': '/** A part nothing uses. */\nexport function Lonely() {\n return \n}\n',
'src/components/Shown.tsx': '/** A part only its story uses. */\nexport function Shown() {\n return \n}\n',
'src/components/Shown.stories.tsx': "import { Shown } from './Shown'\nconst meta = { title: 'Shown', component: Shown }\nexport default meta\nexport const Plain = {}\n",
'src/components/Wide.tsx': '/**\n * A general strip of things.\n * @registry reusable: general; one screen uses it so far\n */\nexport function Wide() {\n return \n}\n',
'src/components/EmptyState.tsx': "import { EmptyState as Shared } from '@estiva-app/ui'\n\n/**\n * The package's empty state, with this app's own default words.\n * @registry namesake: the words are this app's\n */\nexport function EmptyState() {\n return \n}\n",
'src/pages/Lazy.tsx': '/** A page loaded only when it is opened. */\nexport default function Lazy() {\n return \n}\n',
'src/pages/HomePage.tsx': [
"import { lazy } from 'react'",
"import { Card } from '@/components/Card'",
"import { Timeline } from '../components/Timeline'",
"import { Wide } from '@/components/Wide'",
"import { EmptyState } from '@/components/EmptyState'",
"const Lazy = lazy(() => import('./Lazy'))",
'',
'/** The first page. */',
'export function HomePage() {',
' return <>>',
'}',
'',
].join('\n'),
'src/pages/Other.tsx': [
"import { Frame } from '@/components/Frame'",
"import { Timeline } from '@/components'",
"import { EmptyState } from '@/components/EmptyState'",
'',
'/** A second page. */',
'export function Other() {',
' return ',
'}',
'',
].join('\n'),
'src/hooks.tsx': "/** A hook, in a .tsx file. */\nexport function useThing() {\n return 1\n}\n",
}
const root = app(FIXTURE)
const registry = buildAppRegistry({ root, repo: 'fixture', packageRegistry })
const entry = (name: string): RegistryEntry => {
const found = registry.entries.find((candidate) => candidate.name === name)
if (!found) throw new Error(`no entry called ${name}`)
return found
}
describe("an app's catalogue", () => {
it('validates against the schema it was built for', () => {
expect(validateRegistry(registry)).toEqual([])
expect(registry.builtFrom).toMatchObject({ kind: 'app', repo: 'fixture', package: 'fixture-app', packageVersion: '1.2.3', typeExports: 0 })
expect(registry.storybook.devUrl).toBe('http://localhost:6123')
})
// C6: UIG-19's "adding an unclassified component fails the build — proved", proved.
it('fails a part with no class, and gives every part the builder finds one', () => {
const unclassified = structuredClone(registry)
const one = unclassified.entries.find((e) => e.app?.class === 'one-off')!
delete (one.app as Partial>).class
expect(validateRegistry(unclassified)).toContain(`entries[${unclassified.entries.indexOf(one)}] (${one.name}).app.class is undefined`)
const bare = structuredClone(registry)
bare.entries[0].app = null
expect(validateRegistry(bare).join('\n')).toContain('app is missing on an app entry')
// A part added to the app is classified the moment it exists.
const dir = app({ ...FIXTURE, 'src/components/Added.tsx': '/** A part nobody has sorted. */\nexport function Added() {\n return \n}\n' })
const added = buildAppRegistry({ root: dir, repo: 'fixture', packageRegistry })
expect(added.entries.find((e) => e.name === 'Added')?.app?.class).toBe('unused')
expect(added.entries.every((e) => ['re-export', 'reusable', 'one-off', 'promote-candidate', 'unused'].includes(e.app?.class ?? ''))).toBe(true)
})
it('counts every .tsx outside stories and tests, and says why a file holds no part', () => {
// main.tsx, App.tsx, 2 pass-ons, Frame, Card, Timeline, Sync, Ctx, Lonely, Shown, Wide, EmptyState, Lazy, HomePage, Other, hooks.tsx
expect(registry.builtFrom.files).toBe(17)
expect(registry.filesWithoutParts).toEqual([
{ file: 'src/hooks.tsx', reason: 'It exports no part, only useThing.' },
{ file: 'src/main.tsx', reason: 'It exports nothing.' },
])
expect(registry.entries.length).toBe(registry.builtFrom.exports)
})
it('finds parts that draw nothing, by their use as a tag, and leaves a context alone', () => {
expect(entry('TopicSync').app?.usedIn).toEqual(['src/App.tsx'])
expect(entry('CtxProvider').purpose).toBe('Gives every part below it the number.')
expect(registry.entries.some((candidate) => candidate.name === 'ThemeContext')).toBe(false)
expect(registry.entries.some((candidate) => candidate.name === 'useThing')).toBe(false)
})
it('lists a pass-on once per name, even two in one statement, and says whose it is', () => {
const menu = entry('Menu')
expect(menu.app).toMatchObject({ class: 're-export', handsOn: 'Menu', written: false })
expect(entry('MenuItem').app?.class).toBe('re-export')
expect(menu.purposeFrom).toBe('package')
expect(entry('Button').importPath).toBe('@/components/ui/Button')
})
it('reads a description written at the very top of a one-part file, above the imports', () => {
expect(entry('Frame').purpose).toBe('The frame every page sits in, with room for a side panel.')
expect(entry('Frame').purposeFrom).toBe('file')
})
it("counts a part's own file when another part there draws it, and ignores tests", () => {
expect(entry('CardRow').app).toMatchObject({ class: 'one-off', usedIn: ['src/components/Card.tsx'] })
expect(entry('Card').app?.usedIn).toEqual(['src/pages/HomePage.tsx'])
})
it('follows a barrel, a lazy page and a dynamic import of the default', () => {
expect(entry('Timeline').app?.usedIn).toEqual(['src/pages/HomePage.tsx', 'src/pages/Other.tsx'])
expect(entry('Lazy').app).toMatchObject({ class: 'one-off', usedIn: ['src/pages/HomePage.tsx'], defaultExport: true })
expect(entry('App').app).toMatchObject({ class: 'one-off', usedIn: ['src/main.tsx'], defaultExport: true })
})
it("ties a part to its app through a type that comes from the app's data", () => {
const timeline = entry('Timeline')
expect(timeline.app).toMatchObject({ class: 'reusable', tiedTo: ['src/components/timelineTypes.ts'] })
expect(timeline.props).toEqual([{ name: 'items', takes: 'Item[]', required: true, note: 'The things that happened, oldest first.' }])
})
it('offers a reusable part that uses only what the package has as a candidate to move there', () => {
expect(entry('Frame').app).toMatchObject({ class: 'promote-candidate', tiedTo: [], usedIn: ['src/App.tsx', 'src/pages/Other.tsx'] })
expect(entry('Frame').variants).toEqual([{ prop: 'side', values: ['left', 'right'] }])
})
it('says a part is unused, and whether only its stories reach it', () => {
expect(entry('Lonely').app).toMatchObject({ class: 'unused', reason: 'Nothing uses it.' })
expect(entry('Shown').app).toMatchObject({ class: 'unused', reason: 'No file of the app uses it; only its stories do.' })
})
// B9: an app part owns what the part it is owns, followed through the app's own parts.
it('gives an app part what the part it returns owns, and a page nothing of its dialogs', () => {
const dir = app({
'src/ui/Floating.tsx': "/** Hands on the package's Popover. */\nexport { Popover } from '@estiva-app/ui'\n",
'src/DeleteDialog.tsx': "import { ConfirmDialog } from '@estiva-app/ui'\n/** Asks before deleting. */\nexport function DeleteDialog() {\n return {}} onOpenChange={() => {}} />\n}\n",
'src/Ask.tsx': "import { DeleteDialog } from './DeleteDialog'\n/** The app's own wrapper of it. */\nexport function Ask() {\n return open ? : null\n}\n",
'src/Page.tsx': "import { Ask } from './Ask'\n/** A page that opens it. */\nexport function Page() {\n return (\n <>\n Page\n \n >\n )\n}\n",
'src/main.tsx': "import { Page } from './Page'\nimport { Popover } from './ui/Floating'\nexport const all = [, ]\n",
})
const entries = buildAppRegistry({ root: dir, repo: 'x', packageRegistry }).entries
const ids = (name: string) => entries.find((e) => e.name === name)!.ownsBehaviours.map((owned) => owned.id)
expect(ids('DeleteDialog')).toEqual(expect.arrayContaining(['portal', 'focus', 'scroll-lock']))
expect(ids('DeleteDialog')).not.toContain('tab-stop')
expect(ids('Ask')).toEqual(ids('DeleteDialog'))
expect(ids('Page')).toEqual([])
expect(ids('Popover')).toEqual(packageRegistry.entries.find((e) => e.name === 'Popover')!.ownsBehaviours.map((owned) => owned.id))
})
it('takes a kind written beside the part, with its reason', () => {
expect(entry('Wide').app).toMatchObject({ class: 'reusable', written: true, reason: 'general; one screen uses it so far', usedIn: ['src/pages/HomePage.tsx'] })
expect(entry('Wide').purpose).toBe('A general strip of things.')
})
it("names the package's part of the same name", () => {
expect(entry('EmptyState').app?.packageNamesake).toBe('EmptyState')
expect(entry('Button').app?.packageNamesake).toBe(null)
})
it("links the story that shows a part, with Storybook's own ids", () => {
expect(entry('Frame')).toMatchObject({ storyId: 'layout-frame--default', docsId: 'layout-frame--docs' })
// Card appears in no story of its own.
expect(entry('Card')).toMatchObject({ storyId: null, docsId: null })
})
})
// Found by writing Peek's and Ship's first descriptions, 18 September: each of these
// left a part with a description the builder could not see, or a pass-on counted as a part.
describe('what the first descriptions found', () => {
const faults = buildAppRegistry({
root: app({
// Peek's Skeleton.tsx: imported from the package, then exported on a line of its own.
'src/Skeleton.tsx': "import { SkeletonBar, SkeletonRow, SkeletonList as Shared } from '@estiva-app/ui'\n\n/**\n * Grey rows in the shape of a list, while it loads.\n * @registry namesake: the fixture says so\n */\nexport function SkeletonList() {\n return
\n}\n\n/** Peek's name for the package's list placeholder. */\nexport const SkeletonSidebarList = Shared\n\nexport { SkeletonBar, SkeletonRow }\n",
// A file with its own description at the very top, and one on its part.
'src/Row.tsx': "/**\n * Everything about rows.\n */\nimport { useState } from 'react'\n\n/** One row of a list, with a hover state of its own. */\nexport function Row() {\n const [on] = useState(false)\n return
{String(on)}
\n}\n",
// A class, described above itself, with its props from what it extends.
'src/Boundary.tsx': "import { Component, type ReactNode } from 'react'\n\ninterface Props {\n children?: ReactNode\n /** What to say when a part crashes. */\n label: string\n}\n\n/** Catches a part that crashes and says so, so the rest keeps working. */\nexport class Boundary extends Component {\n render() {\n return
{this.props.children}
\n }\n}\n",
// A barrel in two lines: it hands Row on, and does not use it.
'src/parts.ts': "import { Row } from './Row'\nexport { Row }\n",
'src/Page.tsx': "import { Row } from './parts'\nimport { Boundary } from './Boundary'\nimport { SkeletonList, SkeletonBar } from './Skeleton'\n\n/** The one page. */\nexport function Page() {\n return \n}\n",
// Peek's SearchInput and EmptyState: typed by the package's own props, under another local name.
'src/Search.tsx': "import { SearchInput as UiSearchInput, type SearchInputProps as UiSearchInputProps } from '@estiva-app/ui'\n\n/** The package's search field, with this app's own placeholder. */\nexport function Search({ placeholder = 'Find', ...rest }: UiSearchInputProps) {\n return \n}\n",
'src/Empty.tsx': "import { EmptyState as Shared, type EmptyStateProps } from '@estiva-app/ui'\n\ntype Props = Omit & { message?: string }\n\n/** The package's empty state, with this app's own default words. */\nexport function Empty({ message = 'Nothing', ...rest }: Props) {\n return \n}\n",
// Found by the code review: a default by name, a default written as an expression,
// and a property that happens to share a part's name.
'src/AsDefault.tsx': "/** A page handed out as its file's default, by name. */\nfunction Quiet() {\n return \n}\nexport { Quiet as default }\n",
'src/memo-page.tsx': "import { memo } from 'react'\n\n/** A page written as memo of an arrow, straight into the default. */\nexport default memo(({ title }: { title: string }) => {title})\n",
'src/Loader.tsx': "import { lazy } from 'react'\nimport Row from './TopicRow'\nimport Hidden from './rows'\n\nconst Quiet = lazy(() => import('./AsDefault'))\nconst Memo = lazy(() => import('./memo-page'))\n\n/** Loads two pages when they are opened. */\nexport function Loader() {\n return <>>\n}\n",
// Found by the second review: a part named and also its file's memo'd default; a default
// that only wraps a local part; and the first of two parts, described right above itself.
'src/TopicRow.tsx': "import { memo } from 'react'\n\n/** One topic as a row of a list. */\nexport function TopicRow() {\n return \n}\n\nexport default memo(TopicRow)\n",
'src/rows.tsx': "import { memo } from 'react'\n\nfunction HiddenRow() {\n return \n}\n\n/** A row handed out only as its file's default, wrapped in memo. */\nexport default memo(HiddenRow)\n",
'src/Pair.tsx': "import { useState } from 'react'\n\n/** The first of two parts, described right above itself. */\nexport function First() {\n const [a] = useState(0)\n return {a}\n}\n\n/** The second of two parts, described right above itself. */\nexport function Second() {\n return \n}\n",
'src/Slots.tsx': "/** A header nothing draws. */\nexport function Header() {\n return \n}\n\nexport interface Slots {\n Header: string\n}\n\nexport class Frame {\n Header = 1\n}\n",
// Peek's SlashMenu: naming itself for React's tools is not a use of itself.
'src/Menu.tsx': "import { forwardRef } from 'react'\n\n/** The menu a slash opens while typing. */\nexport const SlashMenu = forwardRef((_, ref) => )\nSlashMenu.displayName = 'SlashMenu'\n",
}),
repo: 'faults',
packageRegistry,
})
const one = (name: string) => faults.entries.find((e) => e.name === name)
it('an import exported on its own line is a pass-on, not a part', () => {
expect(one('SkeletonBar')?.app).toMatchObject({ class: 're-export', handsOn: 'SkeletonBar' })
expect(one('SkeletonRow')?.app?.class).toBe('re-export')
expect(one('SkeletonList')?.purpose).toBe('Grey rows in the shape of a list, while it loads.')
})
// Found making the promote page: Peek's SkeletonSidebarList is the package's SkeletonList.
it("`export const X = ImportedPart` is the package's part under a second name, handed on", () => {
expect(one('SkeletonSidebarList')?.app).toMatchObject({ class: 're-export', handsOn: 'SkeletonList' })
expect(one('SkeletonSidebarList')?.purpose).toBe("The package's SkeletonList, handed on as SkeletonSidebarList so the app can import it from src/Skeleton.")
})
it("a part's own comment counts when the file has its own at the top", () => {
expect(one('Row')).toMatchObject({ purpose: 'One row of a list, with a hover state of its own.', purposeFrom: 'comment' })
})
it('a class is read, comment and props', () => {
expect(one('Boundary')?.purpose).toBe('Catches a part that crashes and says so, so the rest keeps working.')
expect(one('Boundary')?.props.map((prop) => prop.name)).toEqual(['children', 'label'])
})
it('a file that only hands a part on is not a place that uses it', () => {
expect(one('Row')?.app?.usedIn).toEqual(['src/Page.tsx'])
expect(validateRegistry(faults)).toEqual([])
})
// Found by react-docgen, the second props reader.
it("a wrapper typed by the package's props takes what the package's part takes", () => {
const shared = (name: string) => packageRegistry.entries.find((entry) => entry.name === name)!
expect(one('Search')?.props).toEqual(shared('SearchInput').props)
const empty = one('Empty')!.props
expect(empty.find((prop) => prop.name === 'message')).toEqual({ name: 'message', takes: 'text', required: false, note: null })
expect(empty.map((prop) => prop.name).sort()).toEqual(shared('EmptyState').props.map((prop) => prop.name).sort())
})
// Found by the code review.
it('`export { Quiet as default }` is a part, and a lazy page loading it uses it', () => {
expect(one('Quiet')).toMatchObject({ purpose: "A page handed out as its file's default, by name." })
expect(one('Quiet')?.app).toMatchObject({ usedIn: ['src/Loader.tsx'], defaultExport: true })
})
it('a default written as an expression is a part, named after its file, with its props', () => {
expect(one('MemoPage')).toMatchObject({ purpose: 'A page written as memo of an arrow, straight into the default.', importPath: 'src/memo-page' })
expect(one('MemoPage')?.app?.usedIn).toEqual(['src/Loader.tsx'])
expect(one('MemoPage')?.props.map((prop) => prop.name)).toEqual(['title'])
})
it("a property of a type or a class that shares a part's name is not a use of it", () => {
expect(one('Header')?.app).toMatchObject({ class: 'unused', usedIn: [] })
expect(faults.entries.some((entry) => entry.name === 'Frame')).toBe(false)
})
// Found by the second review.
it('`export default memo(TopicRow)` beside `export function TopicRow` is one part, and a default import uses it', () => {
expect(faults.entries.filter((entry) => entry.name === 'TopicRow')).toHaveLength(1)
expect(one('TopicRow')?.app?.usedIn).toEqual(['src/Loader.tsx'])
})
it('a default that only wraps a local part is that part, with the comment on the export', () => {
expect(one('HiddenRow')).toMatchObject({ purpose: "A row handed out only as its file's default, wrapped in memo.", sourceFile: 'src/rows.tsx' })
expect(one('HiddenRow')?.app?.usedIn).toEqual(['src/Loader.tsx'])
})
it("the first of several parts keeps the comment written right above it", () => {
expect(one('First')).toMatchObject({ purpose: 'The first of two parts, described right above itself.', purposeFrom: 'comment' })
})
// Found by the second counter.
it('setting displayName on a part is not a use of it', () => {
expect(one('SlashMenu')?.app).toMatchObject({ class: 'unused', usedIn: [] })
})
})
// The third /code-review pass, 18 September: ten more ways a real file can mislead the
// catalogue, each confirmed there on a throwaway app, each one case here.
describe('what the third review found', () => {
const third = buildAppRegistry({
root: app({
'tsconfig.json': '{ "compilerOptions": { "baseUrl": "." } }\n',
'src/Choice.tsx': "/** A choice, taken as text or as a number. */\nexport function Choice(props: { value: string }): JSX.Element\nexport function Choice(props: { value: number }): JSX.Element\nexport function Choice(props: { value: string | number }) {\n return {String(props.value)}\n}\n",
'src/Kbd.tsx': '/**\n * One key, drawn as a keycap.\n * @registry namesake: the fixture says so\n */\nexport function Kbd() {\n return \n}\n\n/** Keys in a row. */\nexport function KbdRow() {\n return \n}\n',
'src/Guard.tsx': "import { Component } from 'react'\n\ninterface Props {\n /** What it guards. */\n label: string\n}\n\n/** Something else in the file. */\nexport function Other() {\n return \n}\n\n/** Catches what crashes below it. */\nexport default class extends Component {\n render() {\n return
{this.props.label}
\n }\n}\n",
'src/List.tsx': "import { memo } from 'react'\n\n/** One row of the list. */\nexport function Row() {\n return \n}\n\n/** The list, drawing its rows. */\nexport default memo(function List() {\n return
\n})\n",
'src/parts/Card.tsx': '/**\n * A card.\n * @registry namesake: the fixture says so\n */\nexport function Card() {\n return \n}\n',
'src/parts/index.ts': "export * as Parts from './Card'\n",
'src/UsesCard.tsx': "import { Parts } from './parts'\n\n/** Draws a card through a namespace. */\nexport function UsesCard() {\n return \n}\n",
'src/Badge.tsx': '/** A small badge. */\nexport function Badge() {\n return \n}\n',
'src/UsesBadge.tsx': "import { Badge } from 'src/Badge'\n\n/** Draws a badge, imported through baseUrl. */\nexport function UsesBadge() {\n return \n}\n",
'src/Settings.tsx': '/** The settings page. */\nexport function SettingsPage() {\n return \n}\n\n/** One settings row. */\nexport function SettingsRow() {\n return \n}\n',
'src/routes.tsx': "import { lazy } from 'react'\n\nconst Page = lazy(() => import('./Settings').then((m) => ({ default: m.SettingsPage })))\n\n/** The routes. */\nexport function Routes() {\n return \n}\n",
'src/Icon.tsx': '/** An icon nothing uses. */\nexport function Icon() {\n return \n}\n\n/** An item that draws the icon it is given. */\nexport function Item({ icon: Icon }: { icon: () => JSX.Element }) {\n return \n}\n',
'src/profile/index.tsx': "/** The profile folder's page. */\nexport default function () {\n return \n}\n",
'src/Wrapped.tsx': "import { memo } from 'react'\nimport { Avatar } from '@estiva-app/ui'\n\n/** The package's avatar, memoised for long lists. */\nexport default memo(Avatar)\n",
'src/Tag.tsx': "import type { FC } from 'react'\n\ninterface TagProps {\n /** The words on the tag. */\n label: string\n}\n\n/** A tag, typed as a function component. */\nexport const Tag: FC = ({ label }) => {label}\n",
// Found by the narrow pass on those fixes.
'src/routesTable.ts': 'export const routes: unknown[] = []\n',
'src/router.tsx': "import { createBrowserRouter } from 'react-router-dom'\nimport { routes } from './routesTable'\n\nexport default createBrowserRouter(routes)\n",
'src/Chip.tsx': "import { forwardRef, memo, type ComponentType } from 'react'\n\n/**\n * A chip nothing uses.\n * @registry namesake: the fixture says so\n */\nexport function Chip() {\n return \n}\n\nexport default memo(forwardRef(Chip)) as ComponentType\n",
}),
repo: 'third',
packageRegistry,
})
const one = (name: string) => third.entries.find((entry) => entry.name === name)
it('builds and validates', () => {
expect(validateRegistry(third)).toEqual([])
})
it('an overloaded part is one entry, described by its first signature', () => {
expect(third.entries.filter((entry) => entry.name === 'Choice')).toHaveLength(1)
expect(one('Choice')).toMatchObject({ purpose: 'A choice, taken as text or as a number.' })
expect(one('Choice')?.props.map((prop) => prop.name)).toEqual(['value'])
})
it('in a file with no imports, the first part keeps its own comment', () => {
expect(one('Kbd')).toMatchObject({ purpose: 'One key, drawn as a keycap.', purposeFrom: 'comment' })
})
it('an unnamed default class is a part, named after its file, with its comment and props', () => {
expect(one('Guard')).toMatchObject({ purpose: 'Catches what crashes below it.' })
expect(one('Guard')?.props.map((prop) => prop.name)).toEqual(['label'])
})
it('a use inside a default written as an expression counts', () => {
expect(one('Row')?.app?.usedIn).toEqual(['src/List.tsx'])
expect(one('List')?.purpose).toBe('The list, drawing its rows.')
})
it('`export * as Parts` is followed to the parts', () => {
expect(one('Card')?.app?.usedIn).toEqual(['src/UsesCard.tsx'])
})
it("an import through tsconfig's baseUrl is the app's own file, not a package", () => {
expect(one('Badge')?.app?.usedIn).toEqual(['src/UsesBadge.tsx'])
expect(one('UsesBadge')?.app?.tiedTo).toEqual([])
})
it('a lazy page that picks one name uses that part only', () => {
expect(one('SettingsPage')?.app?.usedIn).toEqual(['src/routes.tsx'])
expect(one('SettingsRow')?.app?.usedIn).toEqual([])
})
it("a local name that shadows a part's is not a use of it", () => {
expect(one('Icon')?.app).toMatchObject({ class: 'unused', usedIn: [] })
})
it("an unnamed default in a folder's index.tsx is named after the folder", () => {
expect(one('Profile')).toMatchObject({ purpose: "The profile folder's page.", sourceFile: 'src/profile/index.tsx' })
})
it("a part typed `FC` takes the annotation's props", () => {
expect(one('Tag')?.props).toEqual([{ name: 'label', takes: 'text', required: true, note: 'The words on the tag.' }])
})
it('a default that wraps something that is not a part adds no part', () => {
expect(third.entries.some((entry) => entry.sourceFile === 'src/router.tsx')).toBe(false)
expect(third.filesWithoutParts.map((file) => file.file)).toContain('src/router.tsx')
})
it('a default that hands a part out, however wrapped, is not a use of it', () => {
expect(third.entries.filter((entry) => entry.name === 'Chip')).toHaveLength(1)
expect(one('Chip')?.app).toMatchObject({ class: 'unused', usedIn: [] })
})
it('memo of a package part is a new part of the app, not a pass-on', () => {
expect(one('Wrapped')?.app).toMatchObject({ class: 'unused', handsOn: null, defaultExport: true })
expect(one('Wrapped')?.purpose).toBe("The package's avatar, memoised for long lists.")
expect(third.entries.some((entry) => entry.name === 'Avatar')).toBe(false)
})
})
describe('an app the catalogue refuses', () => {
const refused = (files: Record) => {
try {
buildAppRegistry({ root: app(files), repo: 'x', packageRegistry })
return ''
} catch (error) {
return (error as Error).message
}
}
it('names every part with no description, and where to write it', () => {
const message = refused({
'src/One.tsx': 'export function One() {\n return \n}\n',
'src/Two.tsx': 'export function Alpha() {\n return \n}\nexport function Beta() {\n return \n}\n',
})
expect(message).toContain('One in src/One.tsx has no one-line description. Write one as a /** … */ comment directly above it, or at the top of the file.')
// A file of two parts: the top of the file would describe neither.
expect(message).toContain('Alpha in src/Two.tsx has no one-line description. Write one as a /** … */ comment directly above it.')
expect(message).toContain('Beta in src/Two.tsx')
})
it('a kind nobody may write', () => {
expect(refused({ 'src/One.tsx': '/**\n * One.\n * @registry unused: gone\n */\nexport function One() {\n return \n}\n' })).toMatch(/the kinds a person may write are reusable, one-off, promote-candidate/)
})
it("a part with a package part's name and no reason it is kept", () => {
const pill = "import { Button } from '@estiva-app/ui'\n\n/** Who is in this. */\nexport function EmptyState() {\n return \n}\n"
expect(refused({ 'src/EmptyState.tsx': pill })).toContain(
`EmptyState in src/EmptyState.tsx has the name of the package's EmptyState. Use the package's, or write why this one is kept: "@registry namesake: " in its /** … */ comment.`,
)
// The reason, and nothing else, lets it through; a kind written beside it still counts.
const kept = pill.replace('/** Who is in this. */', '/**\n * Who is in this.\n * @registry reusable: general\n * @registry namesake: the words are this app\'s\n */')
expect(refused({ 'src/EmptyState.tsx': kept })).toBe('')
expect(refused({ 'src/EmptyState.tsx': pill.replace('Who is in this.', 'Who is in this. @registry namesake:') })).toMatch(/has the name of the package's EmptyState/)
})
it("not a pass-on of the package's own part", () => {
expect(refused({ 'src/EmptyState.tsx': "export { EmptyState } from '@estiva-app/ui'\n" })).toBe('')
})
it('export * from a package, which hides what it hands on', () => {
expect(refused({ 'src/All.tsx': "export * from '@estiva-app/ui'\n" })).toMatch(/hands on everything from '@estiva-app\/ui' with export \*/)
})
})
describe('validateRegistry, on an app', () => {
const broken = (change: (copy: Registry) => void) => {
const copy = JSON.parse(JSON.stringify(registry)) as Registry
change(copy)
return validateRegistry(copy).join(' ')
}
it('refuses a kind its evidence does not support', () => {
expect(broken((copy) => (copy.entries.find((e) => e.name === 'Card')!.app!.class = 'reusable'))).toMatch(/Card\)\.app\.class is reusable, but its evidence \(1 uses, 0 ties\) says one-off/)
})
it('refuses a file that is neither a part nor listed', () => {
expect(broken((copy) => copy.filesWithoutParts.pop())).toMatch(/but the app has 17 files/)
})
it('refuses a pass-on that says nothing of whose it is', () => {
expect(broken((copy) => (copy.entries.find((e) => e.name === 'Menu')!.app!.handsOn = null))).toMatch(/handsOn is missing on a pass-on/)
})
it('allows two parts of one name in two files of an app', () => {
const copy = JSON.parse(JSON.stringify(registry)) as Registry
const twin = { ...copy.entries.find((e) => e.name === 'Lonely')!, sourceFile: 'src/components/Lonely2.tsx' }
copy.entries.push(twin)
copy.builtFrom.exports += 1
copy.builtFrom.files! += 1
expect(validateRegistry(copy)).toEqual([])
})
})
describe('one search over the package and the apps', () => {
it("answers with the app's part, and never lists a pass-on as a part of its own", () => {
const found = findInRegistries([packageRegistry, registry], 'timeline')
expect(found[0].entry).toMatchObject({ name: 'Timeline', repo: 'fixture' })
expect(findInRegistries([packageRegistry, registry], 'menu', { limit: 50 }).every((f) => f.entry.app?.class !== 're-export')).toBe(true)
})
it("puts the package's part first when an app's matches as well", () => {
const found = findInRegistries([packageRegistry, registry], 'EmptyState', { limit: 10 }).map((f) => `${f.entry.repo}:${f.entry.name}`)
expect(found).toContain('fixture:EmptyState')
expect(found.indexOf('estiva-ui:EmptyState')).toBeLessThan(found.indexOf('fixture:EmptyState'))
})
it("puts a package part whose name carries a word before an app's part answering as many (UIG-20)", () => {
// "panel header" put an app's own `Header` above the package's `ContainerHeader`: its whole name is one of the words.
const header = buildAppRegistry({
root: app({ 'src/Header.tsx': '/** The top row of a panel: its title and its buttons. */\nexport function Header() {\n return \n}\n' }),
repo: 'fixture',
})
const found = findInRegistries([packageRegistry, header], 'panel header', { limit: 5 }).map((f) => `${f.entry.repo}:${f.entry.name}`)
// Since 0.37.0 an app's panel header is Panel's (26 September): the package answers first either way.
expect(found[0]).toBe('estiva-ui:Panel')
expect(found).toContain('estiva-ui:ContainerHeader')
expect(found).toContain('fixture:Header')
})
it("does not let a package part the words reach only through its notes bury the app's answer", () => {
const composer = buildAppRegistry({
root: app({ 'src/Composer.tsx': '/** Where a comment is written and sent. */\nexport function Composer() {\n return \n}\n' }),
repo: 'fixture',
})
// The package's one box for writing is CommentBox (ComposeBox went into it, 21 October); the app's own composer still answers
// first, above the parts the words reach only through notes.
const found = findInRegistries([packageRegistry, composer], 'comment composer', { limit: 10 }).map((f) => `${f.entry.repo}:${f.entry.name}`)
expect(found[0]).toBe('fixture:Composer')
const triggers = found.indexOf('estiva-ui:ComposerTriggers')
if (triggers >= 0) expect(found.indexOf('fixture:Composer')).toBeLessThan(triggers)
})
it('says which apps hand a package part on, and how a default is imported', () => {
const text = formatFindings([packageRegistry, registry], findInRegistries([packageRegistry, registry], 'Button', { limit: 1 }), 'Button')
expect(text).toContain('fixture hands it on from @/components/ui/Button')
const lazy = formatFindings([packageRegistry, registry], findInRegistries([packageRegistry, registry], 'Lazy', { limit: 1 }), 'Lazy')
expect(lazy).toContain("import Lazy from '@/pages/Lazy'")
})
})
// The command, as an app runs it: `dist/` is what `npm test` builds first.
const cli = join(repoRoot, 'dist', 'registry', 'cli.js')
describe.skipIf(!existsSync(cli))('estiva-ui in an app', () => {
const run = (args: string[], cwd: string) => execFileSync(process.execPath, [cli, ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
it('check prints what it found, and fails on a part with no description', () => {
const bare = app({ 'src/One.tsx': 'export function One() {\n return \n}\n' })
expect(() => run(['check'], bare)).toThrow(/One in src\/One\.tsx has no one-line description/)
})
it('check fails a reusable part with no usage page, naming the page to write (UIG-19)', () => {
// The fixture's reusable parts have no pages: it was written before the contract ran in `check`.
let failed: { stdout?: string; stderr?: string } = {}
try {
run(['check', '--repo', 'fixture'], root)
} catch (error) {
failed = error as { stdout?: string; stderr?: string }
}
expect(failed.stdout).toMatch(/^fixture: 17 parts in 15 files \(2 more hold none\)/)
expect(failed.stderr).toContain('Timeline (reusable, src/components/Timeline.tsx) has no usage page: write src/components/Timeline.mdx')
const one = app({ 'src/One.tsx': '/** One, drawn once. */\nexport function One() {\n return \n}\n', 'node_modules/@estiva-app/ui/skill/estiva-ui.md': '# The skill\n' })
run(['skill'], one)
expect(run(['check'], one)).toContain('usage pages: all 0 reusable parts keep the contract')
})
it('skill writes a loader that reads the installed package, and check fails when it is missing or differs (UIG-20)', () => {
const two = app({ 'src/Two.tsx': '/** Two, drawn once. */\nexport function Two() {\n return \n}\n', 'node_modules/@estiva-app/ui/skill/estiva-ui.md': '# The skill\n' })
const failure = (cwd: string) => {
try {
run(['check'], cwd)
} catch (error) {
return (error as { stderr?: string }).stderr ?? ''
}
return ''
}
expect(failure(two)).toContain('.claude/skills/estiva-ui/SKILL.md is missing: run "estiva-ui skill" and commit it')
run(['skill'], two)
const loader = readFileSync(join(two, '.claude', 'skills', 'estiva-ui', 'SKILL.md'), 'utf8')
expect(loader).toMatch(/^---\nname: estiva-ui\ndescription: "Use before building/)
expect(loader).toContain('!`cat "${CLAUDE_PROJECT_DIR}/node_modules/@estiva-app/ui/skill/estiva-ui.md"`')
expect(JSON.parse(readFileSync(join(two, '.claude', 'settings.json'), 'utf8')).permissions.allow).toContain('Bash(npm run ui:find *)')
// Its text is the package's, never a copy.
expect(loader).not.toContain('# The skill')
expect(run(['check'], two)).toContain('claude skill: the loader reads the package’s text')
writeFileSync(join(two, '.claude', 'skills', 'estiva-ui', 'SKILL.md'), `${loader}\nA line of our own.\n`)
expect(failure(two)).toContain('SKILL.md is not what the package writes')
})
it('skill --out writes a loader for a folder that holds the repositories, reading an app inside it', () => {
const holder = mkdtempSync(join(tmpdir(), 'uig20-holder-'))
made.push(holder)
mkdirSync(join(holder, 'one', 'node_modules', '@estiva-app', 'ui', 'skill'), { recursive: true })
writeFileSync(join(holder, 'one', 'package.json'), JSON.stringify({ name: 'one' }))
writeFileSync(join(holder, 'one', 'node_modules', '@estiva-app', 'ui', 'skill', 'estiva-ui.md'), '# The skill\n')
run(['skill', '--out', '..'], join(holder, 'one'))
const loader = readFileSync(join(holder, '.claude', 'skills', 'estiva-ui', 'SKILL.md'), 'utf8')
expect(loader).toContain('!`cat "${CLAUDE_PROJECT_DIR}/one/node_modules/@estiva-app/ui/skill/estiva-ui.md"`')
// Claude Code refuses a path that climbs out with `..`, so a folder that does not hold the app is refused here.
const elsewhere = app({ 'node_modules/@estiva-app/ui/skill/estiva-ui.md': '# The skill\n' })
expect(() => run(['skill', '--out', holder], elsewhere)).toThrow(/does not hold/)
})
it("find searches the app and the package, and never takes an app's own registry.json for the package's", () => {
// What `estiva-ui build` leaves behind in an app, and git ignores.
run(['build'], root)
expect(existsSync(join(root, 'registry.json'))).toBe(true)
const out = run(['find', 'timeline'], root)
expect(out).toContain('Timeline · used in several places · fixture-app')
expect(run(['find', 'popover'], root)).toContain('Popover · component · estiva-ui')
})
it('find brings in an app beside it with --also, and says when one is not there', () => {
const other = app({ 'src/Orbit.tsx': '/** A ring of avatars around a centre. */\nexport function Orbit() {\n return \n}\n' }, { name: 'other-app' })
const out = execFileSync(process.execPath, [cli, 'find', 'orbit', '--also', `other=${other}`, '--also', 'gone=./nowhere'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
expect(out).toContain('Orbit · used nowhere in the app · other')
})
})