@estiva-app/ui 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/Banner.d.ts +20 -2
- package/dist/Banner.d.ts.map +1 -1
- package/dist/ContainerHeader.d.ts +25 -0
- package/dist/ContainerHeader.d.ts.map +1 -0
- package/dist/EmptyState.d.ts +10 -1
- package/dist/EmptyState.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-ZGJ2J5NU.js → chunk-EA33NP5B.js} +275 -11
- package/dist/gates/chunk-EA33NP5B.js.map +7 -0
- package/dist/gates/{chunk-AUXD4GCY.js → chunk-GTQZEHPC.js} +15 -1
- package/dist/gates/chunk-GTQZEHPC.js.map +7 -0
- package/dist/gates/cli.js +1 -1
- package/dist/gates/create-app.d.ts +2 -0
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/index.js +25 -3
- package/dist/gates/index.js.map +2 -2
- package/dist/gates/status.d.ts +2 -0
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +69 -48
- package/dist/index.js.map +4 -4
- package/dist/registry/app-DQI6WLHR.js +10 -0
- package/dist/registry/app.d.ts +21 -0
- package/dist/registry/app.d.ts.map +1 -0
- package/dist/registry/build-LGFCCOLR.js +27 -0
- package/dist/registry/build-LGFCCOLR.js.map +7 -0
- package/dist/registry/build.d.ts +68 -2
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-MRSBS5OP.js → chunk-E4JNV7PC.js} +68 -12
- package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
- package/dist/registry/{chunk-QDYGB3QN.js → chunk-NJN4MQAM.js} +60 -15
- package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
- package/dist/registry/{chunk-IJNCYVH4.js → chunk-W2B2G7OE.js} +34 -9
- package/dist/registry/chunk-W2B2G7OE.js.map +7 -0
- package/dist/registry/chunk-WMFF3MPP.js +648 -0
- package/dist/registry/chunk-WMFF3MPP.js.map +7 -0
- package/dist/registry/cli.js +81 -26
- package/dist/registry/cli.js.map +3 -3
- package/dist/registry/find.d.ts +12 -1
- package/dist/registry/find.d.ts.map +1 -1
- package/dist/registry/index.d.ts +5 -2
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +11 -3
- package/dist/registry/schema.d.ts +83 -6
- package/dist/registry/schema.d.ts.map +1 -1
- package/package.json +2 -2
- package/registry.json +233 -88
- package/src/Banner.mdx +17 -4
- package/src/Banner.stories.tsx +22 -0
- package/src/Banner.test.tsx +35 -0
- package/src/Banner.tsx +34 -9
- package/src/ContainerHeader.mdx +56 -0
- package/src/ContainerHeader.stories.tsx +62 -0
- package/src/ContainerHeader.test.tsx +47 -0
- package/src/ContainerHeader.tsx +45 -0
- package/src/EmptyState.mdx +12 -0
- package/src/EmptyState.stories.tsx +6 -1
- package/src/EmptyState.test.tsx +18 -1
- package/src/EmptyState.tsx +14 -1
- package/src/gates/app-checks.ts +23 -1
- package/src/gates/create-app.test.ts +77 -6
- package/src/gates/create-app.ts +288 -15
- package/src/gates/status.ts +16 -0
- package/src/index.ts +1 -0
- package/src/registry/app.test.ts +562 -0
- package/src/registry/app.ts +854 -0
- package/src/registry/build.ts +114 -25
- package/src/registry/cli.ts +105 -42
- package/src/registry/find.ts +45 -8
- package/src/registry/index.ts +8 -1
- package/src/registry/registry.test.ts +5 -5
- package/src/registry/schema.ts +163 -12
- package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
- package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
- package/dist/registry/build-GOVLABI6.js +0 -13
- package/dist/registry/chunk-IJNCYVH4.js.map +0 -7
- package/dist/registry/chunk-MRSBS5OP.js.map +0 -7
- package/dist/registry/chunk-QDYGB3QN.js.map +0 -7
- /package/dist/registry/{build-GOVLABI6.js.map → app-DQI6WLHR.js.map} +0 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* UIG-13: an app's catalogue. Every case below is one a real app has, or one
|
|
4
|
+
* that broke the first draft of the reader against Peek and Ship:
|
|
5
|
+
*
|
|
6
|
+
* - a file's description sits at the very top, above its imports (Ship's style);
|
|
7
|
+
* - a part draws nothing and is only ever used as a tag (a listener, a provider);
|
|
8
|
+
* - `export { A, B } from '@estiva-app/ui'` is one statement handing on two parts;
|
|
9
|
+
* - a card is only drawn by the list in its own file;
|
|
10
|
+
* - a page is loaded with `lazy(() => import('./Page'))`, and the app itself with
|
|
11
|
+
* `const { default: App } = await import('./App.tsx')`;
|
|
12
|
+
* - a part is reached through a barrel file;
|
|
13
|
+
* - a part's type comes from a file that imports an app's data package.
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from 'node:child_process'
|
|
16
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { tmpdir } from 'node:os'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
21
|
+
import { buildAppRegistry } from './app'
|
|
22
|
+
import { findInRegistries, formatFindings } from './find'
|
|
23
|
+
import { validateRegistry, type Registry, type RegistryEntry } from './schema'
|
|
24
|
+
|
|
25
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
26
|
+
const packageRegistry = JSON.parse(readFileSync(join(repoRoot, 'registry.json'), 'utf8')) as Registry
|
|
27
|
+
const made: string[] = []
|
|
28
|
+
afterAll(() => {
|
|
29
|
+
for (const dir of made) rmSync(dir, { recursive: true, force: true })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/** Write an app into a fresh folder and return the folder. */
|
|
33
|
+
function app(files: Record<string, string>, manifest: object = { name: 'fixture-app', version: '1.2.3', scripts: { storybook: 'storybook dev -p 6123' } }): string {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), 'uig13-'))
|
|
35
|
+
made.push(dir)
|
|
36
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
|
37
|
+
for (const [path, text] of Object.entries(files)) {
|
|
38
|
+
mkdirSync(dirname(join(dir, path)), { recursive: true })
|
|
39
|
+
writeFileSync(join(dir, path), text)
|
|
40
|
+
}
|
|
41
|
+
return dir
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const FIXTURE: Record<string, string> = {
|
|
45
|
+
// Comments in a tsconfig, as Peek writes them.
|
|
46
|
+
'tsconfig.app.json': '{\n // the alias\n "compilerOptions": { "paths": { "@/*": ["./src/*"] } }\n}\n',
|
|
47
|
+
'src/main.tsx': "void (async () => {\n const { default: App } = await import('./App.tsx')\n void App\n})()\n",
|
|
48
|
+
'src/App.tsx': [
|
|
49
|
+
"import { Frame } from '@/components/Frame'",
|
|
50
|
+
"import { TopicSync } from '@/components/Sync'",
|
|
51
|
+
"import { CtxProvider } from '@/components/Ctx'",
|
|
52
|
+
"import { HomePage } from './pages/HomePage'",
|
|
53
|
+
'',
|
|
54
|
+
'/** The whole app: its frame and its one page. */',
|
|
55
|
+
'export default function App() {',
|
|
56
|
+
' return <CtxProvider value={1}><TopicSync /><Frame><HomePage /></Frame></CtxProvider>',
|
|
57
|
+
'}',
|
|
58
|
+
'',
|
|
59
|
+
].join('\n'),
|
|
60
|
+
'src/components/ui/Button.tsx': "/** Hands on the package's Button. */\nexport { Button, type ButtonProps } from '@estiva-app/ui'\n",
|
|
61
|
+
'src/components/ui/Menu.tsx': "export { Menu, MenuItem } from '@estiva-app/ui'\n",
|
|
62
|
+
'src/components/Frame.tsx': [
|
|
63
|
+
'/**',
|
|
64
|
+
' * The frame every page sits in, with room for a side panel.',
|
|
65
|
+
' *',
|
|
66
|
+
' * It is written once, at the top, above the imports.',
|
|
67
|
+
' */',
|
|
68
|
+
"import { EmptyState } from '@estiva-app/ui'",
|
|
69
|
+
"import type { ReactNode } from 'react'",
|
|
70
|
+
'',
|
|
71
|
+
'export interface FrameProps {',
|
|
72
|
+
' /** What sits inside. */',
|
|
73
|
+
' children?: ReactNode',
|
|
74
|
+
" side?: 'left' | 'right'",
|
|
75
|
+
'}',
|
|
76
|
+
'',
|
|
77
|
+
'export function Frame({ children }: FrameProps) {',
|
|
78
|
+
' return <div>{children ?? <EmptyState message="x" />}</div>',
|
|
79
|
+
'}',
|
|
80
|
+
'',
|
|
81
|
+
].join('\n'),
|
|
82
|
+
'src/components/Frame.stories.tsx': [
|
|
83
|
+
"import type { Meta, StoryObj } from '@storybook/react-vite'",
|
|
84
|
+
"import { Frame } from './Frame'",
|
|
85
|
+
'const meta = {',
|
|
86
|
+
" title: 'Layout/Frame',",
|
|
87
|
+
' component: Frame,',
|
|
88
|
+
'} satisfies Meta<typeof Frame>',
|
|
89
|
+
'export default meta',
|
|
90
|
+
'export const Default: StoryObj<typeof meta> = {}',
|
|
91
|
+
'export const WithSidebar: StoryObj<typeof meta> = {}',
|
|
92
|
+
'',
|
|
93
|
+
].join('\n'),
|
|
94
|
+
'src/components/Card.tsx': [
|
|
95
|
+
"import { Button } from '@/components/ui/Button'",
|
|
96
|
+
'',
|
|
97
|
+
'interface CardProps {',
|
|
98
|
+
' title: string',
|
|
99
|
+
'}',
|
|
100
|
+
'',
|
|
101
|
+
'/** One row inside a card. */',
|
|
102
|
+
'export function CardRow({ title }: CardProps) {',
|
|
103
|
+
' return <li>{title}</li>',
|
|
104
|
+
'}',
|
|
105
|
+
'',
|
|
106
|
+
'/** A card that lists its rows and a button. */',
|
|
107
|
+
'export function Card({ title }: CardProps) {',
|
|
108
|
+
' return <ul><CardRow title={title} /><Button>Go</Button></ul>',
|
|
109
|
+
'}',
|
|
110
|
+
'',
|
|
111
|
+
].join('\n'),
|
|
112
|
+
'src/components/Card.test.tsx': "import { Card } from './Card'\nvoid Card\n",
|
|
113
|
+
'src/api/types.ts': "import type { Doc } from 'convex/react'\nexport type Item = Doc & { at: number }\n",
|
|
114
|
+
'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",
|
|
115
|
+
'src/components/Timeline.tsx': [
|
|
116
|
+
"import type { TimelineProps } from '@/components/timelineTypes'",
|
|
117
|
+
'',
|
|
118
|
+
'/** What happened to a thing, as a timeline. */',
|
|
119
|
+
'export function Timeline({ items }: TimelineProps) {',
|
|
120
|
+
' return <ol>{items.map((i) => <li key={i.at} />)}</ol>',
|
|
121
|
+
'}',
|
|
122
|
+
'',
|
|
123
|
+
].join('\n'),
|
|
124
|
+
'src/components/index.ts': "export { Timeline } from './Timeline'\n",
|
|
125
|
+
'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",
|
|
126
|
+
'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",
|
|
127
|
+
'src/components/Lonely.tsx': '/** A part nothing uses. */\nexport function Lonely() {\n return <span />\n}\n',
|
|
128
|
+
'src/components/Shown.tsx': '/** A part only its story uses. */\nexport function Shown() {\n return <span />\n}\n',
|
|
129
|
+
'src/components/Shown.stories.tsx': "import { Shown } from './Shown'\nconst meta = { title: 'Shown', component: Shown }\nexport default meta\nexport const Plain = {}\n",
|
|
130
|
+
'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 <div />\n}\n',
|
|
131
|
+
'src/components/EmptyState.tsx': "import { EmptyState as Shared } from '@estiva-app/ui'\n\n/** The package's empty state, with this app's own default words. */\nexport function EmptyState() {\n return <Shared message=\"Nothing here\" />\n}\n",
|
|
132
|
+
'src/pages/Lazy.tsx': '/** A page loaded only when it is opened. */\nexport default function Lazy() {\n return <main />\n}\n',
|
|
133
|
+
'src/pages/HomePage.tsx': [
|
|
134
|
+
"import { lazy } from 'react'",
|
|
135
|
+
"import { Card } from '@/components/Card'",
|
|
136
|
+
"import { Timeline } from '../components/Timeline'",
|
|
137
|
+
"import { Wide } from '@/components/Wide'",
|
|
138
|
+
"import { EmptyState } from '@/components/EmptyState'",
|
|
139
|
+
"const Lazy = lazy(() => import('./Lazy'))",
|
|
140
|
+
'',
|
|
141
|
+
'/** The first page. */',
|
|
142
|
+
'export function HomePage() {',
|
|
143
|
+
' return <><Card title="a" /><Timeline items={[]} /><Wide /><EmptyState /><Lazy /></>',
|
|
144
|
+
'}',
|
|
145
|
+
'',
|
|
146
|
+
].join('\n'),
|
|
147
|
+
'src/pages/Other.tsx': [
|
|
148
|
+
"import { Frame } from '@/components/Frame'",
|
|
149
|
+
"import { Timeline } from '@/components'",
|
|
150
|
+
"import { EmptyState } from '@/components/EmptyState'",
|
|
151
|
+
'',
|
|
152
|
+
'/** A second page. */',
|
|
153
|
+
'export function Other() {',
|
|
154
|
+
' return <Frame><Timeline items={[]} /><EmptyState /></Frame>',
|
|
155
|
+
'}',
|
|
156
|
+
'',
|
|
157
|
+
].join('\n'),
|
|
158
|
+
'src/hooks.tsx': "/** A hook, in a .tsx file. */\nexport function useThing() {\n return 1\n}\n",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const root = app(FIXTURE)
|
|
162
|
+
const registry = buildAppRegistry({ root, repo: 'fixture', packageRegistry })
|
|
163
|
+
const entry = (name: string): RegistryEntry => {
|
|
164
|
+
const found = registry.entries.find((candidate) => candidate.name === name)
|
|
165
|
+
if (!found) throw new Error(`no entry called ${name}`)
|
|
166
|
+
return found
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
describe("an app's catalogue", () => {
|
|
170
|
+
it('validates against the schema it was built for', () => {
|
|
171
|
+
expect(validateRegistry(registry)).toEqual([])
|
|
172
|
+
expect(registry.builtFrom).toMatchObject({ kind: 'app', repo: 'fixture', package: 'fixture-app', packageVersion: '1.2.3', typeExports: 0 })
|
|
173
|
+
expect(registry.storybook.devUrl).toBe('http://localhost:6123')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('counts every .tsx outside stories and tests, and says why a file holds no part', () => {
|
|
177
|
+
// main.tsx, App.tsx, 2 pass-ons, Frame, Card, Timeline, Sync, Ctx, Lonely, Shown, Wide, EmptyState, Lazy, HomePage, Other, hooks.tsx
|
|
178
|
+
expect(registry.builtFrom.files).toBe(17)
|
|
179
|
+
expect(registry.filesWithoutParts).toEqual([
|
|
180
|
+
{ file: 'src/hooks.tsx', reason: 'It exports no part, only useThing.' },
|
|
181
|
+
{ file: 'src/main.tsx', reason: 'It exports nothing.' },
|
|
182
|
+
])
|
|
183
|
+
expect(registry.entries.length).toBe(registry.builtFrom.exports)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('finds parts that draw nothing, by their use as a tag, and leaves a context alone', () => {
|
|
187
|
+
expect(entry('TopicSync').app?.usedIn).toEqual(['src/App.tsx'])
|
|
188
|
+
expect(entry('CtxProvider').purpose).toBe('Gives every part below it the number.')
|
|
189
|
+
expect(registry.entries.some((candidate) => candidate.name === 'ThemeContext')).toBe(false)
|
|
190
|
+
expect(registry.entries.some((candidate) => candidate.name === 'useThing')).toBe(false)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('lists a pass-on once per name, even two in one statement, and says whose it is', () => {
|
|
194
|
+
const menu = entry('Menu')
|
|
195
|
+
expect(menu.app).toMatchObject({ class: 're-export', handsOn: 'Menu', written: false })
|
|
196
|
+
expect(entry('MenuItem').app?.class).toBe('re-export')
|
|
197
|
+
expect(menu.purposeFrom).toBe('package')
|
|
198
|
+
expect(entry('Button').importPath).toBe('@/components/ui/Button')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('reads a description written at the very top of a one-part file, above the imports', () => {
|
|
202
|
+
expect(entry('Frame').purpose).toBe('The frame every page sits in, with room for a side panel.')
|
|
203
|
+
expect(entry('Frame').purposeFrom).toBe('file')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it("counts a part's own file when another part there draws it, and ignores tests", () => {
|
|
207
|
+
expect(entry('CardRow').app).toMatchObject({ class: 'one-off', usedIn: ['src/components/Card.tsx'] })
|
|
208
|
+
expect(entry('Card').app?.usedIn).toEqual(['src/pages/HomePage.tsx'])
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('follows a barrel, a lazy page and a dynamic import of the default', () => {
|
|
212
|
+
expect(entry('Timeline').app?.usedIn).toEqual(['src/pages/HomePage.tsx', 'src/pages/Other.tsx'])
|
|
213
|
+
expect(entry('Lazy').app).toMatchObject({ class: 'one-off', usedIn: ['src/pages/HomePage.tsx'], defaultExport: true })
|
|
214
|
+
expect(entry('App').app).toMatchObject({ class: 'one-off', usedIn: ['src/main.tsx'], defaultExport: true })
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it("ties a part to its app through a type that comes from the app's data", () => {
|
|
218
|
+
const timeline = entry('Timeline')
|
|
219
|
+
expect(timeline.app).toMatchObject({ class: 'reusable', tiedTo: ['src/components/timelineTypes.ts'] })
|
|
220
|
+
expect(timeline.props).toEqual([{ name: 'items', takes: 'Item[]', required: true, note: 'The things that happened, oldest first.' }])
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('offers a reusable part that uses only what the package has as a candidate to move there', () => {
|
|
224
|
+
expect(entry('Frame').app).toMatchObject({ class: 'promote-candidate', tiedTo: [], usedIn: ['src/App.tsx', 'src/pages/Other.tsx'] })
|
|
225
|
+
expect(entry('Frame').variants).toEqual([{ prop: 'side', values: ['left', 'right'] }])
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('says a part is unused, and whether only its stories reach it', () => {
|
|
229
|
+
expect(entry('Lonely').app).toMatchObject({ class: 'unused', reason: 'Nothing uses it.' })
|
|
230
|
+
expect(entry('Shown').app).toMatchObject({ class: 'unused', reason: 'No file of the app uses it; only its stories do.' })
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('takes a kind written beside the part, with its reason', () => {
|
|
234
|
+
expect(entry('Wide').app).toMatchObject({ class: 'reusable', written: true, reason: 'general; one screen uses it so far', usedIn: ['src/pages/HomePage.tsx'] })
|
|
235
|
+
expect(entry('Wide').purpose).toBe('A general strip of things.')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it("names the package's part of the same name", () => {
|
|
239
|
+
expect(entry('EmptyState').app?.packageNamesake).toBe('EmptyState')
|
|
240
|
+
expect(entry('Button').app?.packageNamesake).toBe(null)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it("links the story that shows a part, with Storybook's own ids", () => {
|
|
244
|
+
expect(entry('Frame')).toMatchObject({ storyId: 'layout-frame--default', docsId: 'layout-frame--docs' })
|
|
245
|
+
// Card appears in no story of its own.
|
|
246
|
+
expect(entry('Card')).toMatchObject({ storyId: null, docsId: null })
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// Found by writing Peek's and Ship's first descriptions, 18 September: each of these
|
|
251
|
+
// left a part with a description the builder could not see, or a pass-on counted as a part.
|
|
252
|
+
describe('what the first descriptions found', () => {
|
|
253
|
+
const faults = buildAppRegistry({
|
|
254
|
+
root: app({
|
|
255
|
+
// Peek's Skeleton.tsx: imported from the package, then exported on a line of its own.
|
|
256
|
+
'src/Skeleton.tsx': "import { SkeletonBar, SkeletonRow, SkeletonList as Shared } from '@estiva-app/ui'\n\n/** Grey rows in the shape of a list, while it loads. */\nexport function SkeletonList() {\n return <div><SkeletonBar /><SkeletonRow /></div>\n}\n\n/** Peek's name for the package's list placeholder. */\nexport const SkeletonSidebarList = Shared\n\nexport { SkeletonBar, SkeletonRow }\n",
|
|
257
|
+
// A file with its own description at the very top, and one on its part.
|
|
258
|
+
'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 <li>{String(on)}</li>\n}\n",
|
|
259
|
+
// A class, described above itself, with its props from what it extends.
|
|
260
|
+
'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<Props> {\n render() {\n return <div>{this.props.children}</div>\n }\n}\n",
|
|
261
|
+
// A barrel in two lines: it hands Row on, and does not use it.
|
|
262
|
+
'src/parts.ts': "import { Row } from './Row'\nexport { Row }\n",
|
|
263
|
+
'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 <Boundary label=\"x\"><Row /><SkeletonList /><SkeletonBar /></Boundary>\n}\n",
|
|
264
|
+
// Peek's SearchInput and EmptyState: typed by the package's own props, under another local name.
|
|
265
|
+
'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 <UiSearchInput placeholder={placeholder} {...rest} />\n}\n",
|
|
266
|
+
'src/Empty.tsx': "import { EmptyState as Shared, type EmptyStateProps } from '@estiva-app/ui'\n\ntype Props = Omit<EmptyStateProps, 'message'> & { 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 <Shared message={message} {...rest} />\n}\n",
|
|
267
|
+
// Found by the code review: a default by name, a default written as an expression,
|
|
268
|
+
// and a property that happens to share a part's name.
|
|
269
|
+
'src/AsDefault.tsx': "/** A page handed out as its file's default, by name. */\nfunction Quiet() {\n return <p />\n}\nexport { Quiet as default }\n",
|
|
270
|
+
'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 }) => <main>{title}</main>)\n",
|
|
271
|
+
'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 <><Quiet /><Memo title=\"x\" /><Row /><Hidden /></>\n}\n",
|
|
272
|
+
// Found by the second review: a part named and also its file's memo'd default; a default
|
|
273
|
+
// that only wraps a local part; and the first of two parts, described right above itself.
|
|
274
|
+
'src/TopicRow.tsx': "import { memo } from 'react'\n\n/** One topic as a row of a list. */\nexport function TopicRow() {\n return <li />\n}\n\nexport default memo(TopicRow)\n",
|
|
275
|
+
'src/rows.tsx': "import { memo } from 'react'\n\nfunction HiddenRow() {\n return <li />\n}\n\n/** A row handed out only as its file's default, wrapped in memo. */\nexport default memo(HiddenRow)\n",
|
|
276
|
+
'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 <i>{a}</i>\n}\n\n/** The second of two parts, described right above itself. */\nexport function Second() {\n return <b />\n}\n",
|
|
277
|
+
'src/Slots.tsx': "/** A header nothing draws. */\nexport function Header() {\n return <header />\n}\n\nexport interface Slots {\n Header: string\n}\n\nexport class Frame {\n Header = 1\n}\n",
|
|
278
|
+
// Peek's SlashMenu: naming itself for React's tools is not a use of itself.
|
|
279
|
+
'src/Menu.tsx': "import { forwardRef } from 'react'\n\n/** The menu a slash opens while typing. */\nexport const SlashMenu = forwardRef<HTMLDivElement>((_, ref) => <div ref={ref} />)\nSlashMenu.displayName = 'SlashMenu'\n",
|
|
280
|
+
}),
|
|
281
|
+
repo: 'faults',
|
|
282
|
+
packageRegistry,
|
|
283
|
+
})
|
|
284
|
+
const one = (name: string) => faults.entries.find((e) => e.name === name)
|
|
285
|
+
|
|
286
|
+
it('an import exported on its own line is a pass-on, not a part', () => {
|
|
287
|
+
expect(one('SkeletonBar')?.app).toMatchObject({ class: 're-export', handsOn: 'SkeletonBar' })
|
|
288
|
+
expect(one('SkeletonRow')?.app?.class).toBe('re-export')
|
|
289
|
+
expect(one('SkeletonList')?.purpose).toBe('Grey rows in the shape of a list, while it loads.')
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
// Found making the promote page: Peek's SkeletonSidebarList is the package's SkeletonList.
|
|
293
|
+
it("`export const X = ImportedPart` is the package's part under a second name, handed on", () => {
|
|
294
|
+
expect(one('SkeletonSidebarList')?.app).toMatchObject({ class: 're-export', handsOn: 'SkeletonList' })
|
|
295
|
+
expect(one('SkeletonSidebarList')?.purpose).toBe("The package's SkeletonList, handed on as SkeletonSidebarList so the app can import it from src/Skeleton.")
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it("a part's own comment counts when the file has its own at the top", () => {
|
|
299
|
+
expect(one('Row')).toMatchObject({ purpose: 'One row of a list, with a hover state of its own.', purposeFrom: 'comment' })
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('a class is read, comment and props', () => {
|
|
303
|
+
expect(one('Boundary')?.purpose).toBe('Catches a part that crashes and says so, so the rest keeps working.')
|
|
304
|
+
expect(one('Boundary')?.props.map((prop) => prop.name)).toEqual(['children', 'label'])
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('a file that only hands a part on is not a place that uses it', () => {
|
|
308
|
+
expect(one('Row')?.app?.usedIn).toEqual(['src/Page.tsx'])
|
|
309
|
+
expect(validateRegistry(faults)).toEqual([])
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
// Found by react-docgen, the second props reader.
|
|
313
|
+
it("a wrapper typed by the package's props takes what the package's part takes", () => {
|
|
314
|
+
const shared = (name: string) => packageRegistry.entries.find((entry) => entry.name === name)!
|
|
315
|
+
expect(one('Search')?.props).toEqual(shared('SearchInput').props)
|
|
316
|
+
const empty = one('Empty')!.props
|
|
317
|
+
expect(empty.find((prop) => prop.name === 'message')).toEqual({ name: 'message', takes: 'text', required: false, note: null })
|
|
318
|
+
expect(empty.map((prop) => prop.name).sort()).toEqual(shared('EmptyState').props.map((prop) => prop.name).sort())
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
// Found by the code review.
|
|
322
|
+
it('`export { Quiet as default }` is a part, and a lazy page loading it uses it', () => {
|
|
323
|
+
expect(one('Quiet')).toMatchObject({ purpose: "A page handed out as its file's default, by name." })
|
|
324
|
+
expect(one('Quiet')?.app).toMatchObject({ usedIn: ['src/Loader.tsx'], defaultExport: true })
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('a default written as an expression is a part, named after its file, with its props', () => {
|
|
328
|
+
expect(one('MemoPage')).toMatchObject({ purpose: 'A page written as memo of an arrow, straight into the default.', importPath: 'src/memo-page' })
|
|
329
|
+
expect(one('MemoPage')?.app?.usedIn).toEqual(['src/Loader.tsx'])
|
|
330
|
+
expect(one('MemoPage')?.props.map((prop) => prop.name)).toEqual(['title'])
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it("a property of a type or a class that shares a part's name is not a use of it", () => {
|
|
334
|
+
expect(one('Header')?.app).toMatchObject({ class: 'unused', usedIn: [] })
|
|
335
|
+
expect(faults.entries.some((entry) => entry.name === 'Frame')).toBe(false)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
// Found by the second review.
|
|
339
|
+
it('`export default memo(TopicRow)` beside `export function TopicRow` is one part, and a default import uses it', () => {
|
|
340
|
+
expect(faults.entries.filter((entry) => entry.name === 'TopicRow')).toHaveLength(1)
|
|
341
|
+
expect(one('TopicRow')?.app?.usedIn).toEqual(['src/Loader.tsx'])
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('a default that only wraps a local part is that part, with the comment on the export', () => {
|
|
345
|
+
expect(one('HiddenRow')).toMatchObject({ purpose: "A row handed out only as its file's default, wrapped in memo.", sourceFile: 'src/rows.tsx' })
|
|
346
|
+
expect(one('HiddenRow')?.app?.usedIn).toEqual(['src/Loader.tsx'])
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it("the first of several parts keeps the comment written right above it", () => {
|
|
350
|
+
expect(one('First')).toMatchObject({ purpose: 'The first of two parts, described right above itself.', purposeFrom: 'comment' })
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
// Found by the second counter.
|
|
354
|
+
it('setting displayName on a part is not a use of it', () => {
|
|
355
|
+
expect(one('SlashMenu')?.app).toMatchObject({ class: 'unused', usedIn: [] })
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
// The third /code-review pass, 18 September: ten more ways a real file can mislead the
|
|
360
|
+
// catalogue, each confirmed there on a throwaway app, each one case here.
|
|
361
|
+
describe('what the third review found', () => {
|
|
362
|
+
const third = buildAppRegistry({
|
|
363
|
+
root: app({
|
|
364
|
+
'tsconfig.json': '{ "compilerOptions": { "baseUrl": "." } }\n',
|
|
365
|
+
'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 <span>{String(props.value)}</span>\n}\n",
|
|
366
|
+
'src/Kbd.tsx': '/** One key, drawn as a keycap. */\nexport function Kbd() {\n return <kbd />\n}\n\n/** Keys in a row. */\nexport function KbdRow() {\n return <span><Kbd /></span>\n}\n',
|
|
367
|
+
'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 <i />\n}\n\n/** Catches what crashes below it. */\nexport default class extends Component<Props> {\n render() {\n return <div>{this.props.label}</div>\n }\n}\n",
|
|
368
|
+
'src/List.tsx': "import { memo } from 'react'\n\n/** One row of the list. */\nexport function Row() {\n return <li />\n}\n\n/** The list, drawing its rows. */\nexport default memo(function List() {\n return <ul><Row /></ul>\n})\n",
|
|
369
|
+
'src/parts/Card.tsx': '/** A card. */\nexport function Card() {\n return <div />\n}\n',
|
|
370
|
+
'src/parts/index.ts': "export * as Parts from './Card'\n",
|
|
371
|
+
'src/UsesCard.tsx': "import { Parts } from './parts'\n\n/** Draws a card through a namespace. */\nexport function UsesCard() {\n return <Parts.Card />\n}\n",
|
|
372
|
+
'src/Badge.tsx': '/** A small badge. */\nexport function Badge() {\n return <b />\n}\n',
|
|
373
|
+
'src/UsesBadge.tsx': "import { Badge } from 'src/Badge'\n\n/** Draws a badge, imported through baseUrl. */\nexport function UsesBadge() {\n return <Badge />\n}\n",
|
|
374
|
+
'src/Settings.tsx': '/** The settings page. */\nexport function SettingsPage() {\n return <main />\n}\n\n/** One settings row. */\nexport function SettingsRow() {\n return <li />\n}\n',
|
|
375
|
+
'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 <Page />\n}\n",
|
|
376
|
+
'src/Icon.tsx': '/** An icon nothing uses. */\nexport function Icon() {\n return <svg />\n}\n\n/** An item that draws the icon it is given. */\nexport function Item({ icon: Icon }: { icon: () => JSX.Element }) {\n return <Icon />\n}\n',
|
|
377
|
+
'src/profile/index.tsx': "/** The profile folder's page. */\nexport default function () {\n return <section />\n}\n",
|
|
378
|
+
'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",
|
|
379
|
+
'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<TagProps> = ({ label }) => <em>{label}</em>\n",
|
|
380
|
+
// Found by the narrow pass on those fixes.
|
|
381
|
+
'src/routesTable.ts': 'export const routes: unknown[] = []\n',
|
|
382
|
+
'src/router.tsx': "import { createBrowserRouter } from 'react-router-dom'\nimport { routes } from './routesTable'\n\nexport default createBrowserRouter(routes)\n",
|
|
383
|
+
'src/Chip.tsx': "import { forwardRef, memo, type ComponentType } from 'react'\n\n/** A chip nothing uses. */\nexport function Chip() {\n return <i />\n}\n\nexport default memo(forwardRef(Chip)) as ComponentType\n",
|
|
384
|
+
}),
|
|
385
|
+
repo: 'third',
|
|
386
|
+
packageRegistry,
|
|
387
|
+
})
|
|
388
|
+
const one = (name: string) => third.entries.find((entry) => entry.name === name)
|
|
389
|
+
|
|
390
|
+
it('builds and validates', () => {
|
|
391
|
+
expect(validateRegistry(third)).toEqual([])
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('an overloaded part is one entry, described by its first signature', () => {
|
|
395
|
+
expect(third.entries.filter((entry) => entry.name === 'Choice')).toHaveLength(1)
|
|
396
|
+
expect(one('Choice')).toMatchObject({ purpose: 'A choice, taken as text or as a number.' })
|
|
397
|
+
expect(one('Choice')?.props.map((prop) => prop.name)).toEqual(['value'])
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('in a file with no imports, the first part keeps its own comment', () => {
|
|
401
|
+
expect(one('Kbd')).toMatchObject({ purpose: 'One key, drawn as a keycap.', purposeFrom: 'comment' })
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('an unnamed default class is a part, named after its file, with its comment and props', () => {
|
|
405
|
+
expect(one('Guard')).toMatchObject({ purpose: 'Catches what crashes below it.' })
|
|
406
|
+
expect(one('Guard')?.props.map((prop) => prop.name)).toEqual(['label'])
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('a use inside a default written as an expression counts', () => {
|
|
410
|
+
expect(one('Row')?.app?.usedIn).toEqual(['src/List.tsx'])
|
|
411
|
+
expect(one('List')?.purpose).toBe('The list, drawing its rows.')
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('`export * as Parts` is followed to the parts', () => {
|
|
415
|
+
expect(one('Card')?.app?.usedIn).toEqual(['src/UsesCard.tsx'])
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it("an import through tsconfig's baseUrl is the app's own file, not a package", () => {
|
|
419
|
+
expect(one('Badge')?.app?.usedIn).toEqual(['src/UsesBadge.tsx'])
|
|
420
|
+
expect(one('UsesBadge')?.app?.tiedTo).toEqual([])
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('a lazy page that picks one name uses that part only', () => {
|
|
424
|
+
expect(one('SettingsPage')?.app?.usedIn).toEqual(['src/routes.tsx'])
|
|
425
|
+
expect(one('SettingsRow')?.app?.usedIn).toEqual([])
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it("a local name that shadows a part's is not a use of it", () => {
|
|
429
|
+
expect(one('Icon')?.app).toMatchObject({ class: 'unused', usedIn: [] })
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it("an unnamed default in a folder's index.tsx is named after the folder", () => {
|
|
433
|
+
expect(one('Profile')).toMatchObject({ purpose: "The profile folder's page.", sourceFile: 'src/profile/index.tsx' })
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it("a part typed `FC<Props>` takes the annotation's props", () => {
|
|
437
|
+
expect(one('Tag')?.props).toEqual([{ name: 'label', takes: 'text', required: true, note: 'The words on the tag.' }])
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('a default that wraps something that is not a part adds no part', () => {
|
|
441
|
+
expect(third.entries.some((entry) => entry.sourceFile === 'src/router.tsx')).toBe(false)
|
|
442
|
+
expect(third.filesWithoutParts.map((file) => file.file)).toContain('src/router.tsx')
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('a default that hands a part out, however wrapped, is not a use of it', () => {
|
|
446
|
+
expect(third.entries.filter((entry) => entry.name === 'Chip')).toHaveLength(1)
|
|
447
|
+
expect(one('Chip')?.app).toMatchObject({ class: 'unused', usedIn: [] })
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('memo of a package part is a new part of the app, not a pass-on', () => {
|
|
451
|
+
expect(one('Wrapped')?.app).toMatchObject({ class: 'unused', handsOn: null, defaultExport: true })
|
|
452
|
+
expect(one('Wrapped')?.purpose).toBe("The package's avatar, memoised for long lists.")
|
|
453
|
+
expect(third.entries.some((entry) => entry.name === 'Avatar')).toBe(false)
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
describe('an app the catalogue refuses', () => {
|
|
458
|
+
const refused = (files: Record<string, string>) => {
|
|
459
|
+
try {
|
|
460
|
+
buildAppRegistry({ root: app(files), repo: 'x', packageRegistry })
|
|
461
|
+
return ''
|
|
462
|
+
} catch (error) {
|
|
463
|
+
return (error as Error).message
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
it('names every part with no description, and where to write it', () => {
|
|
468
|
+
const message = refused({
|
|
469
|
+
'src/One.tsx': 'export function One() {\n return <i />\n}\n',
|
|
470
|
+
'src/Two.tsx': 'export function Alpha() {\n return <i />\n}\nexport function Beta() {\n return <Alpha />\n}\n',
|
|
471
|
+
})
|
|
472
|
+
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.')
|
|
473
|
+
// A file of two parts: the top of the file would describe neither.
|
|
474
|
+
expect(message).toContain('Alpha in src/Two.tsx has no one-line description. Write one as a /** … */ comment directly above it.')
|
|
475
|
+
expect(message).toContain('Beta in src/Two.tsx')
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('a kind nobody may write', () => {
|
|
479
|
+
expect(refused({ 'src/One.tsx': '/**\n * One.\n * @registry unused: gone\n */\nexport function One() {\n return <i />\n}\n' })).toMatch(/the kinds a person may write are reusable, one-off, promote-candidate/)
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('export * from a package, which hides what it hands on', () => {
|
|
483
|
+
expect(refused({ 'src/All.tsx': "export * from '@estiva-app/ui'\n" })).toMatch(/hands on everything from '@estiva-app\/ui' with export \*/)
|
|
484
|
+
})
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
describe('validateRegistry, on an app', () => {
|
|
488
|
+
const broken = (change: (copy: Registry) => void) => {
|
|
489
|
+
const copy = JSON.parse(JSON.stringify(registry)) as Registry
|
|
490
|
+
change(copy)
|
|
491
|
+
return validateRegistry(copy).join(' ')
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
it('refuses a kind its evidence does not support', () => {
|
|
495
|
+
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/)
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('refuses a file that is neither a part nor listed', () => {
|
|
499
|
+
expect(broken((copy) => copy.filesWithoutParts.pop())).toMatch(/but the app has 17 files/)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('refuses a pass-on that says nothing of whose it is', () => {
|
|
503
|
+
expect(broken((copy) => (copy.entries.find((e) => e.name === 'Menu')!.app!.handsOn = null))).toMatch(/handsOn is missing on a pass-on/)
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('allows two parts of one name in two files of an app', () => {
|
|
507
|
+
const copy = JSON.parse(JSON.stringify(registry)) as Registry
|
|
508
|
+
const twin = { ...copy.entries.find((e) => e.name === 'Lonely')!, sourceFile: 'src/components/Lonely2.tsx' }
|
|
509
|
+
copy.entries.push(twin)
|
|
510
|
+
copy.builtFrom.exports += 1
|
|
511
|
+
copy.builtFrom.files! += 1
|
|
512
|
+
expect(validateRegistry(copy)).toEqual([])
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
describe('one search over the package and the apps', () => {
|
|
517
|
+
it("answers with the app's part, and never lists a pass-on as a part of its own", () => {
|
|
518
|
+
const found = findInRegistries([packageRegistry, registry], 'timeline')
|
|
519
|
+
expect(found[0].entry).toMatchObject({ name: 'Timeline', repo: 'fixture' })
|
|
520
|
+
expect(findInRegistries([packageRegistry, registry], 'menu', { limit: 50 }).every((f) => f.entry.app?.class !== 're-export')).toBe(true)
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it("puts the package's part first when an app's matches as well", () => {
|
|
524
|
+
const found = findInRegistries([packageRegistry, registry], 'EmptyState', { limit: 10 }).map((f) => `${f.entry.repo}:${f.entry.name}`)
|
|
525
|
+
expect(found).toContain('fixture:EmptyState')
|
|
526
|
+
expect(found.indexOf('estiva-ui:EmptyState')).toBeLessThan(found.indexOf('fixture:EmptyState'))
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('says which apps hand a package part on, and how a default is imported', () => {
|
|
530
|
+
const text = formatFindings([packageRegistry, registry], findInRegistries([packageRegistry, registry], 'Button', { limit: 1 }), 'Button')
|
|
531
|
+
expect(text).toContain('fixture hands it on from @/components/ui/Button')
|
|
532
|
+
const lazy = formatFindings([packageRegistry, registry], findInRegistries([packageRegistry, registry], 'Lazy', { limit: 1 }), 'Lazy')
|
|
533
|
+
expect(lazy).toContain("import Lazy from '@/pages/Lazy'")
|
|
534
|
+
})
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
// The command, as an app runs it: `dist/` is what `npm test` builds first.
|
|
538
|
+
const cli = join(repoRoot, 'dist', 'registry', 'cli.js')
|
|
539
|
+
describe.skipIf(!existsSync(cli))('estiva-ui in an app', () => {
|
|
540
|
+
const run = (args: string[], cwd: string) => execFileSync(process.execPath, [cli, ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
541
|
+
|
|
542
|
+
it('check prints what it found, and fails on a part with no description', () => {
|
|
543
|
+
expect(run(['check', '--repo', 'fixture'], root)).toMatch(/^fixture: 17 parts in 15 files \(2 more hold none\)/)
|
|
544
|
+
const bare = app({ 'src/One.tsx': 'export function One() {\n return <i />\n}\n' })
|
|
545
|
+
expect(() => run(['check'], bare)).toThrow(/One in src\/One\.tsx has no one-line description/)
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
it("find searches the app and the package, and never takes an app's own registry.json for the package's", () => {
|
|
549
|
+
// What `estiva-ui build` leaves behind in an app, and git ignores.
|
|
550
|
+
run(['build'], root)
|
|
551
|
+
expect(existsSync(join(root, 'registry.json'))).toBe(true)
|
|
552
|
+
const out = run(['find', 'timeline'], root)
|
|
553
|
+
expect(out).toContain('Timeline · used in several places · fixture-app')
|
|
554
|
+
expect(run(['find', 'popover'], root)).toContain('Popover · component · estiva-ui')
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('find brings in an app beside it with --also, and says when one is not there', () => {
|
|
558
|
+
const other = app({ 'src/Orbit.tsx': '/** A ring of avatars around a centre. */\nexport function Orbit() {\n return <i />\n}\n' }, { name: 'other-app' })
|
|
559
|
+
const out = execFileSync(process.execPath, [cli, 'find', 'orbit', '--also', `other=${other}`, '--also', 'gone=./nowhere'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
560
|
+
expect(out).toContain('Orbit · used nowhere in the app · other')
|
|
561
|
+
})
|
|
562
|
+
})
|