@estiva-app/ui 0.21.1 → 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.
Files changed (108) hide show
  1. package/README.md +21 -0
  2. package/dist/Avatar.d.ts +3 -0
  3. package/dist/Avatar.d.ts.map +1 -1
  4. package/dist/Banner.d.ts +20 -2
  5. package/dist/Banner.d.ts.map +1 -1
  6. package/dist/ChipInput.d.ts +3 -0
  7. package/dist/ChipInput.d.ts.map +1 -1
  8. package/dist/CommandPalette.d.ts +6 -0
  9. package/dist/CommandPalette.d.ts.map +1 -1
  10. package/dist/ContainerHeader.d.ts +25 -0
  11. package/dist/ContainerHeader.d.ts.map +1 -0
  12. package/dist/EmptyState.d.ts +10 -1
  13. package/dist/EmptyState.d.ts.map +1 -1
  14. package/dist/IdentityMenu.d.ts +3 -0
  15. package/dist/IdentityMenu.d.ts.map +1 -1
  16. package/dist/Menu.d.ts +7 -0
  17. package/dist/Menu.d.ts.map +1 -1
  18. package/dist/Skeleton.d.ts +2 -0
  19. package/dist/Skeleton.d.ts.map +1 -1
  20. package/dist/Toast.d.ts +5 -0
  21. package/dist/Toast.d.ts.map +1 -1
  22. package/dist/Toolbar.d.ts +4 -0
  23. package/dist/Toolbar.d.ts.map +1 -1
  24. package/dist/Tooltip.d.ts +4 -0
  25. package/dist/Tooltip.d.ts.map +1 -1
  26. package/dist/gates/app-checks.d.ts.map +1 -1
  27. package/dist/gates/{chunk-ZGJ2J5NU.js → chunk-EA33NP5B.js} +275 -11
  28. package/dist/gates/chunk-EA33NP5B.js.map +7 -0
  29. package/dist/gates/{chunk-AUXD4GCY.js → chunk-GTQZEHPC.js} +15 -1
  30. package/dist/gates/chunk-GTQZEHPC.js.map +7 -0
  31. package/dist/gates/cli.js +1 -1
  32. package/dist/gates/create-app.d.ts +2 -0
  33. package/dist/gates/create-app.d.ts.map +1 -1
  34. package/dist/gates/create-app.js +1 -1
  35. package/dist/gates/index.js +25 -3
  36. package/dist/gates/index.js.map +2 -2
  37. package/dist/gates/status.d.ts +2 -0
  38. package/dist/gates/status.d.ts.map +1 -1
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +69 -48
  42. package/dist/index.js.map +4 -4
  43. package/dist/registry/app-DQI6WLHR.js +10 -0
  44. package/dist/registry/app-DQI6WLHR.js.map +7 -0
  45. package/dist/registry/app.d.ts +21 -0
  46. package/dist/registry/app.d.ts.map +1 -0
  47. package/dist/registry/build-LGFCCOLR.js +27 -0
  48. package/dist/registry/build-LGFCCOLR.js.map +7 -0
  49. package/dist/registry/build.d.ts +95 -0
  50. package/dist/registry/build.d.ts.map +1 -0
  51. package/dist/registry/chunk-E4JNV7PC.js +155 -0
  52. package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
  53. package/dist/registry/chunk-NJN4MQAM.js +397 -0
  54. package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
  55. package/dist/registry/chunk-W2B2G7OE.js +188 -0
  56. package/dist/registry/chunk-W2B2G7OE.js.map +7 -0
  57. package/dist/registry/chunk-WMFF3MPP.js +648 -0
  58. package/dist/registry/chunk-WMFF3MPP.js.map +7 -0
  59. package/dist/registry/cli.d.ts +2 -0
  60. package/dist/registry/cli.d.ts.map +1 -0
  61. package/dist/registry/cli.js +176 -0
  62. package/dist/registry/cli.js.map +7 -0
  63. package/dist/registry/find.d.ts +54 -0
  64. package/dist/registry/find.d.ts.map +1 -0
  65. package/dist/registry/index.d.ts +17 -0
  66. package/dist/registry/index.d.ts.map +1 -0
  67. package/dist/registry/index.js +34 -0
  68. package/dist/registry/index.js.map +7 -0
  69. package/dist/registry/schema.d.ts +224 -0
  70. package/dist/registry/schema.d.ts.map +1 -0
  71. package/package.json +15 -2
  72. package/registry.json +4615 -0
  73. package/src/Avatar.tsx +3 -0
  74. package/src/Banner.mdx +17 -4
  75. package/src/Banner.stories.tsx +22 -0
  76. package/src/Banner.test.tsx +35 -0
  77. package/src/Banner.tsx +34 -9
  78. package/src/ChipInput.tsx +3 -0
  79. package/src/CommandPalette.tsx +6 -0
  80. package/src/ContainerHeader.mdx +56 -0
  81. package/src/ContainerHeader.stories.tsx +62 -0
  82. package/src/ContainerHeader.test.tsx +47 -0
  83. package/src/ContainerHeader.tsx +45 -0
  84. package/src/EmptyState.mdx +12 -0
  85. package/src/EmptyState.stories.tsx +6 -1
  86. package/src/EmptyState.test.tsx +18 -1
  87. package/src/EmptyState.tsx +14 -1
  88. package/src/IdentityMenu.tsx +3 -0
  89. package/src/Menu.tsx +7 -0
  90. package/src/Skeleton.tsx +2 -0
  91. package/src/Toast.tsx +5 -0
  92. package/src/Toolbar.tsx +4 -0
  93. package/src/Tooltip.tsx +4 -0
  94. package/src/gates/app-checks.ts +23 -1
  95. package/src/gates/create-app.test.ts +77 -6
  96. package/src/gates/create-app.ts +288 -15
  97. package/src/gates/status.ts +16 -0
  98. package/src/index.ts +1 -0
  99. package/src/registry/app.test.ts +562 -0
  100. package/src/registry/app.ts +854 -0
  101. package/src/registry/build.ts +679 -0
  102. package/src/registry/cli.ts +204 -0
  103. package/src/registry/find.ts +232 -0
  104. package/src/registry/index.ts +31 -0
  105. package/src/registry/registry.test.ts +448 -0
  106. package/src/registry/schema.ts +419 -0
  107. package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
  108. package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
@@ -0,0 +1,448 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import ts from 'typescript'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { OWNED_BEHAVIOURS } from '../eslint/index'
6
+ import { buildRegistry, readIndexExports, serializeRegistry } from './build'
7
+ import { findInRegistry } from './find'
8
+ import { SCHEMA_VERSION, validateRegistry, type Registry, type RegistryEntry } from './schema'
9
+
10
+ /**
11
+ * The catalogue (UIG-12).
12
+ *
13
+ * Three things are held here, and each of them has failed once already while
14
+ * this was being written:
15
+ *
16
+ * 1. **The committed file is what the code produces.** A registry that can
17
+ * drift is a registry nobody can trust, and the whole reason for generating
18
+ * it rather than writing it is gone.
19
+ * 2. **The story ids resolve.** They were derived wrongly at first —
20
+ * `--withcounts` for a story Storybook calls `--with-counts` — and 11 of the
21
+ * 81 pointed nowhere. A real `storybook build` was the only thing that said
22
+ * so, so the ids it produced are written down here.
23
+ * 3. **Every export is accounted for.** Entries plus explained exclusions must
24
+ * equal what `index.ts` exports, or the count has been averaged away.
25
+ */
26
+ const root = process.cwd()
27
+ const registry = buildRegistry({ root })
28
+ const entry = (name: string): RegistryEntry => {
29
+ const found = registry.entries.find((candidate) => candidate.name === name)
30
+ if (!found) throw new Error(`no entry for ${name}`)
31
+ return found
32
+ }
33
+
34
+ describe('the registry builds', () => {
35
+ it('matches its own schema', () => {
36
+ expect(validateRegistry(registry)).toEqual([])
37
+ })
38
+
39
+ it('accounts for every value export of index.ts', () => {
40
+ const exported = readIndexExports(readFileSync(join(root, 'src', 'index.ts'), 'utf8'))
41
+ const values = exported.filter((one) => !one.isType)
42
+ expect(registry.builtFrom.exports).toBe(values.length)
43
+ expect(registry.entries.length + registry.excluded.length).toBe(values.length)
44
+ // Nothing silently dropped: the names match, not only the count.
45
+ expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
46
+ })
47
+
48
+ it('counts 82 names over 54 files: 75 components, 6 helpers and 1 hook (ContainerHeader joined in UIG-13)', () => {
49
+ // The reconciliation GATES.md §24 explains. If this changes, that changes.
50
+ const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
51
+ expect(kinds).toEqual({ component: 75, helper: 6, hook: 1 })
52
+ expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(54)
53
+ })
54
+
55
+ it('gives every entry a purpose, from its own page or from the comment above it', () => {
56
+ expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
57
+ expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(54)
58
+ expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(28)
59
+ })
60
+
61
+ it('takes a name with a page of its own from the page, and a name without one from the code', () => {
62
+ expect(entry('Menu').purposeFrom).toBe('page')
63
+ expect(entry('Menu').docPage).toBe('src/Menu.mdx')
64
+ // MenuSub is documented inside Menu's page, so its line is the one above it
65
+ // in Menu.tsx. Its docs id is still Menu's page, which is where it is
66
+ // explained.
67
+ expect(entry('MenuSub').purposeFrom).toBe('comment')
68
+ expect(entry('MenuSub').docPage).toBeNull()
69
+ expect(entry('MenuSub').docsId).toBe('overlays-menu--docs')
70
+ expect(entry('MenuSub').sourceFile).toBe('src/Menu.tsx')
71
+ })
72
+
73
+ it('reads the behaviours from UIG-8 rather than a list of its own', () => {
74
+ const ids = new Set(OWNED_BEHAVIOURS.map((owned) => owned.id))
75
+ for (const one of registry.entries) {
76
+ for (const owned of one.ownsBehaviours) expect(ids).toContain(owned.id)
77
+ }
78
+ expect(entry('ScrollArea').ownsBehaviours.map((owned) => owned.id)).toContain('scroll')
79
+ expect(entry('Popover').ownsBehaviours.map((owned) => owned.id)).toContain('portal')
80
+ expect(entry('Card').ownsBehaviours).toEqual([])
81
+ })
82
+
83
+ it('reads the variants from the props type, following a local alias', () => {
84
+ // ButtonVariant and ButtonSize are aliases; the props type points at them.
85
+ expect(entry('Button').variants).toEqual([
86
+ { prop: 'variant', values: ['primary', 'outlined', 'muted', 'destructive', 'resolve'] },
87
+ { prop: 'size', values: ['default', 'small'] },
88
+ ])
89
+ expect(entry('Menu').variants).toEqual([{ prop: 'align', values: ['left', 'right'] }])
90
+ })
91
+
92
+ it('carries every prop a component declares itself, not only the word-choices', () => {
93
+ // The catalogue's plain-words job is "what it can do". Built first with
94
+ // variants alone, it carried 33 of 336 — a tenth of the answer.
95
+ const all = registry.entries.flatMap((one) => one.props)
96
+ expect(all.length).toBeGreaterThan(330)
97
+ expect(registry.entries.filter((one) => one.props.length).length).toBeGreaterThan(65)
98
+ // The question that started this: does Link already truncate?
99
+ expect(entry('Link').props.find((prop) => prop.name === 'truncate')).toEqual({
100
+ name: 'truncate',
101
+ takes: 'true/false',
102
+ required: false,
103
+ note: 'One line, cut with an ellipsis when it does not fit — a title that is a link.',
104
+ })
105
+ expect(entry('Link').props.find((prop) => prop.name === 'href')?.required).toBe(true)
106
+ })
107
+
108
+ it('says what a prop takes in words a person reads', () => {
109
+ const takes = (name: string, prop: string) => entry(name).props.find((one) => one.name === prop)?.takes
110
+ expect(takes('Link', 'external')).toBe('true/false')
111
+ expect(takes('Link', 'href')).toBe('text')
112
+ expect(takes('Link', 'children')).toBe('anything')
113
+ expect(takes('Button', 'variant')).toBe('primary | outlined | muted | destructive | resolve')
114
+ expect(takes('Popover', 'actionsRef')).toBe('a handler')
115
+ })
116
+
117
+ it('gives every prop a type that is actually a type', () => {
118
+ // The guard for a whole class of fault. A `ts.TypeElement` carries offsets
119
+ // into its *own* file, so reading a sibling's node with this file's text
120
+ // slices the wrong source: `ToolbarButton` came out with `variant: "ats
121
+ // over what it a"` and `children: "omeAndEndK"`. Real names, prose for
122
+ // types, and every name-based check passed.
123
+ const FRIENDLY = new Set(['anything', 'a handler', 'true/false', 'number', 'text'])
124
+ const sources = readdirSync(join(root, 'src'))
125
+ .filter((file) => file.endsWith('.tsx') || file.endsWith('.ts'))
126
+ .map((file) => readFileSync(join(root, 'src', file), 'utf8'))
127
+ .join('\n')
128
+ const bad: string[] = []
129
+ for (const one of registry.entries) {
130
+ for (const prop of one.props) {
131
+ if (FRIENDLY.has(prop.takes)) continue
132
+ const parsed = ts.createSourceFile('t.ts', `type X = ${prop.takes}`, ts.ScriptTarget.Latest, false)
133
+ // @ts-expect-error parseDiagnostics is internal, and is the only thing that says "this is not a type".
134
+ const errors = (parsed.parseDiagnostics ?? []) as unknown[]
135
+ if (errors.length) {
136
+ bad.push(`${one.name}.${prop.name}: ${JSON.stringify(prop.takes)} does not parse as a type`)
137
+ continue
138
+ }
139
+ // A name that parses but names nothing: `omeAndEndK`, a slice of
140
+ // `HomeAndEndKeys`, is a legal type reference and not a real one.
141
+ if (/^[A-Za-z_$][\w$]*$/.test(prop.takes) && !new RegExp(`\\b${prop.takes}\\b`).test(sources)) {
142
+ bad.push(`${one.name}.${prop.name}: ${JSON.stringify(prop.takes)} is not a word anywhere in src/`)
143
+ }
144
+ }
145
+ }
146
+ expect(bad).toEqual([])
147
+ })
148
+
149
+ it('reads an inherited prop exactly as the file that declares it does', () => {
150
+ // Every one of these was wrong at once, and all four had the same cause.
151
+ const prop = (name: string, key: string) => entry(name).props.find((one) => one.name === key)
152
+
153
+ // ToolbarButtonProps extends IconButtonProps, in another file.
154
+ expect(prop('ToolbarButton', 'variant')).toEqual(prop('IconButton', 'variant'))
155
+ expect(entry('ToolbarButton').variants).toContainEqual({ prop: 'variant', values: ['muted', 'outlined', 'primary', 'current', 'resolve'] })
156
+ // type ToolbarInputProps = TextInputProps — an alias to another file, note and all.
157
+ expect(prop('ToolbarInput', 'size')).toEqual(prop('TextInput', 'size'))
158
+ // extends Omit<IdentityMenuProps, 'compact'>: the base was read as the name
159
+ // `Omit`, so everything it wraps was lost.
160
+ expect(entry('IdentityPanel').props.filter((one) => one.required).map((one) => one.name).sort()).toEqual(['me', 'signedIn'])
161
+ // …and `Omit` drops what it names.
162
+ expect(entry('IdentityPanel').props.some((one) => one.name === 'compact')).toBe(false)
163
+ expect(entry('IdentityMenu').props.some((one) => one.name === 'compact')).toBe(true)
164
+ expect(entry('PersonTrigger').props.map((one) => one.name)).toEqual(expect.arrayContaining(['name', 'picture', 'fallback', 'size']))
165
+ })
166
+
167
+ it('keeps variants a view of props, never a second reading', () => {
168
+ for (const one of registry.entries) {
169
+ for (const variant of one.variants) {
170
+ const prop = one.props.find((candidate) => candidate.name === variant.prop)
171
+ expect(prop, `${one.name}.${variant.prop}`).toBeDefined()
172
+ expect(prop?.takes).toBe(variant.values.join(' | '))
173
+ }
174
+ }
175
+ })
176
+
177
+ it('reads them however the component was written', () => {
178
+ // Written out at the parameter, with no named type at all.
179
+ expect(entry('SectionLabel').variants).toEqual([{ prop: 'tone', values: ['primary', 'secondary'] }])
180
+ // forwardRef: the props are its second type argument, and the inner
181
+ // function's parameter is bare.
182
+ expect(entry('TextInput').variants).toEqual([{ prop: 'size', values: ['default', 'small'] }])
183
+ })
184
+
185
+ it('misses no set of words any component file declares', () => {
186
+ // Both of the above were found this way, in the ten-entry spot check and
187
+ // then in a sweep. The sweep stays: it is the thing that notices a way of
188
+ // writing a component the builder has not met yet.
189
+ const declared = new Map<string, Set<string>>()
190
+ for (const one of registry.entries) {
191
+ const props = declared.get(one.sourceFile) ?? new Set<string>()
192
+ for (const variant of one.variants) props.add(variant.prop)
193
+ declared.set(one.sourceFile, props)
194
+ }
195
+ const missed: string[] = []
196
+ for (const [file, read] of declared) {
197
+ const source = readFileSync(join(root, file), 'utf8')
198
+ for (const match of source.matchAll(/(\w+)\??:\s*'[^']+'\s*\|\s*'[^']+'/g)) {
199
+ if (!read.has(match[1])) missed.push(`${file}: ${match[1]}`)
200
+ }
201
+ }
202
+ expect(missed).toEqual([])
203
+ })
204
+
205
+ it('leaves migrationStage null, because no source a build can read holds it', () => {
206
+ expect(registry.entries.every((one) => one.migrationStage === null)).toBe(true)
207
+ })
208
+
209
+ it('never gives an export the file’s own header comment', () => {
210
+ // Skeleton.tsx opens with a paragraph about the whole family and
211
+ // `SkeletonBar` follows it directly. The bar was given the family's
212
+ // description until the builder learnt to refuse a header.
213
+ expect(entry('SkeletonBar').purpose).not.toMatch(/the generic parts only/)
214
+ expect(entry('SkeletonBar').purpose).toMatch(/One pulsing bar/)
215
+ // The opposite case: InlineChip.tsx has a header too, and
216
+ // INLINE_CHIP_CLASSES has its own comment under it. That one is kept.
217
+ expect(entry('INLINE_CHIP_CLASSES').purpose).toMatch(/every tone shares/)
218
+ })
219
+ })
220
+
221
+ describe('the ids point at something', () => {
222
+ /**
223
+ * Taken from a real `npm run build-storybook` on 2026-09-18, which is the
224
+ * only authority for these. Every one of the 81 was checked against that
225
+ * build; these are the shapes that can break — a one-word export, a
226
+ * many-word export, and a name documented on a sibling's page.
227
+ */
228
+ const REAL = [
229
+ ['Button', 'primitives-button--docs', 'primitives-button--primary'],
230
+ ['Tabs', 'navigation-tabs--docs', 'navigation-tabs--with-counts'],
231
+ ['AvatarGroup', 'primitives-avatargroup--docs', 'primitives-avatargroup--three-members'],
232
+ ['Breadcrumb', 'navigation-breadcrumb--docs', 'navigation-breadcrumb--on-an-item'],
233
+ ['CommandPalette', 'overlays-commandpalette--docs', 'overlays-commandpalette--first-level'],
234
+ ['ChipInput', 'inputs-chipinput--docs', 'inputs-chipinput--type-to-search'],
235
+ ['EnterHint', 'overlays-menu--docs', 'overlays-menu--items'],
236
+ ] as const
237
+
238
+ it.each(REAL)('%s', (name, docsId, storyId) => {
239
+ expect(entry(name).docsId).toBe(docsId)
240
+ expect(entry(name).storyId).toBe(storyId)
241
+ })
242
+
243
+ it('gives every component a page and a story, and only a helper none', () => {
244
+ const without = registry.entries.filter((one) => one.docsId === null || one.storyId === null)
245
+ expect(without.map((one) => one.name)).toEqual(['cn'])
246
+ expect(without.every((one) => one.kind !== 'component')).toBe(true)
247
+ })
248
+
249
+ /**
250
+ * Skipped unless a Storybook has been built here. CI does not build one —
251
+ * that is a job of its own and this is not worth it — but when a build is
252
+ * present, every id is checked against it rather than against the seven above.
253
+ */
254
+ const index = join(root, 'storybook-static', 'index.json')
255
+ it.skipIf(!existsSync(index))('every id is in a real Storybook build', () => {
256
+ const real = new Set(Object.keys((JSON.parse(readFileSync(index, 'utf8')) as { entries: Record<string, unknown> }).entries))
257
+ const missing = registry.entries.filter((one) => (one.docsId && !real.has(one.docsId)) || (one.storyId && !real.has(one.storyId)))
258
+ expect(missing.map((one) => one.name)).toEqual([])
259
+ })
260
+ })
261
+
262
+ /**
263
+ * A second parser, which shares none of this one's assumptions.
264
+ *
265
+ * `react-docgen` is what Storybook builds its own Props table with. Comparing
266
+ * against it is the only evidence here that is not this builder checking its own
267
+ * work, and it earned its place immediately: it found **13 props over 10 files**
268
+ * that were silently dropped because their names are quoted (`'aria-label'`),
269
+ * two of them required; and `ToolbarButton` carrying one prop where it should
270
+ * carry nine, because `ToolbarButtonProps extends IconButtonProps` — a type in
271
+ * another file of this package.
272
+ *
273
+ * It arrives with `@storybook/react-vite` rather than as a dependency of its
274
+ * own: adding one would have npm rewrite `package-lock.json`, which on Windows
275
+ * drops the optional Linux entries CI installs. The test skips if it is ever
276
+ * absent rather than pretending to have run.
277
+ */
278
+ describe('a second parser agrees', async () => {
279
+ const docgen = await import('react-docgen').catch(() => null)
280
+
281
+ it.skipIf(!docgen)('finds the same props on all 75 components', async () => {
282
+ if (!docgen) return
283
+ const resolver = new docgen.builtinResolvers.FindExportedDefinitionsResolver({ limit: 0 })
284
+ const byName = new Map(registry.entries.map((one) => [one.name, one]))
285
+ const differences: string[] = []
286
+ let seen = 0
287
+
288
+ for (const file of new Set(registry.entries.map((one) => one.sourceFile))) {
289
+ let docs
290
+ try {
291
+ docs = docgen.parse(readFileSync(join(root, file), 'utf8'), { filename: file, resolver })
292
+ } catch {
293
+ // `cn.ts` holds no component; react-docgen says so and there is nothing to compare.
294
+ continue
295
+ }
296
+ for (const doc of docs) {
297
+ const entry = doc.displayName ? byName.get(doc.displayName) : undefined
298
+ if (!entry) continue
299
+ seen += 1
300
+ const theirs = new Set(Object.keys(doc.props ?? {}))
301
+ const mine = new Set(entry.props.map((prop) => prop.name))
302
+ for (const prop of theirs) if (!mine.has(prop) && !INHERITED_WITH_A_DEFAULT.has(`${entry.name}.${prop}`)) differences.push(`${entry.name}.${prop} is docgen's and not ours`)
303
+ for (const prop of mine) if (!theirs.has(prop) && !DOCGEN_STOPS_AT_OMIT.has(`${entry.name}.${prop}`)) differences.push(`${entry.name}.${prop} is ours and not docgen's`)
304
+ }
305
+ }
306
+
307
+ expect(differences).toEqual([])
308
+ expect(seen).toBe(registry.entries.filter((one) => one.kind === 'component').length)
309
+ })
310
+ })
311
+
312
+ /**
313
+ * The four the two parsers are *meant* to disagree on.
314
+ *
315
+ * Each is a DOM attribute the component does not declare — it inherits it from
316
+ * `ComponentPropsWithRef<'button'>` or `InputHTMLAttributes` — and only gives a
317
+ * default while destructuring. react-docgen reports everything inherited; this
318
+ * registry lists what a component declares itself, because otherwise every entry
319
+ * carries `onCopy` and `spellCheck` and buries its own answer.
320
+ */
321
+ const INHERITED_WITH_A_DEFAULT = new Set(['Button.type', 'IconButton.type', 'TextInput.type', 'SearchInput.placeholder'])
322
+
323
+ /**
324
+ * The twelve where **this registry is right and `react-docgen` is not**.
325
+ *
326
+ * `IdentityPanelProps extends Omit<IdentityMenuProps, 'compact'>` and
327
+ * `PersonTriggerProps extends Omit<PersonProps, 'className'>`: docgen does not
328
+ * follow a heritage clause wrapped in `Omit`, so it reports neither `me` nor
329
+ * `signedIn`, which `IdentityPanel` *requires*. Checked against the source —
330
+ * `IdentityMenuProps` declares both, and `IdentityPanel` spreads `...rest`
331
+ * straight into `IdentityRows`, which destructures them.
332
+ *
333
+ * Listed rather than waved through, so the day docgen learns to follow them
334
+ * this fails and the list goes.
335
+ */
336
+ const DOCGEN_STOPS_AT_OMIT = new Set([
337
+ 'IdentityPanel.me',
338
+ 'IdentityPanel.signedIn',
339
+ 'IdentityPanel.relayUrl',
340
+ 'IdentityPanel.idBase',
341
+ 'IdentityPanel.onCopyKey',
342
+ 'IdentityPanel.onSignOut',
343
+ 'IdentityPanel.className',
344
+ 'IdentityPanel.children',
345
+ 'PersonTrigger.name',
346
+ 'PersonTrigger.picture',
347
+ 'PersonTrigger.fallback',
348
+ 'PersonTrigger.size',
349
+ ])
350
+
351
+ describe('ui:find', () => {
352
+ const names = (query: string) => findInRegistry(registry, query).map((finding) => finding.entry.name)
353
+
354
+ // The ticket's three. Regression tests, not the scope.
355
+ it('finds Popover for "floating panel"', () => expect(names('floating panel')[0]).toBe('Popover'))
356
+ it('finds ScrollArea for "scrolling"', () => expect(names('scrolling')[0]).toBe('ScrollArea'))
357
+ it('finds EmptyState for "empty"', () => expect(names('empty')[0]).toBe('EmptyState'))
358
+
359
+ it('takes a name apart, so "palette" reaches CommandPalette', () => {
360
+ expect(names('palette')).toContain('CommandPalette')
361
+ })
362
+
363
+ it('answers what a component can do, and names the prop that does it', () => {
364
+ const found = findInRegistry(registry, 'truncate a long link')
365
+ expect(found[0].entry.name).toBe('Link')
366
+ expect(found[0].props).toContain('truncate')
367
+ })
368
+
369
+ it('ignores the words that are in every sentence', () => {
370
+ // "a" appears inside nearly every prop note ("in a new tab"), and an exact
371
+ // match skipped the minimum length. It pulled Link's `external` into the
372
+ // answer for a question that never mentioned it.
373
+ expect(findInRegistry(registry, 'truncate a long link')[0].props).not.toContain('external')
374
+ // A question made only of them still answers something rather than nothing.
375
+ expect(findInRegistry(registry, 'the a of')).not.toEqual([])
376
+ })
377
+
378
+ it('shows a prop the question named, not one whose note happens to say the word', () => {
379
+ // Six of Popover's notes say "panel"; showing all six buries the answer.
380
+ const found = findInRegistry(registry, 'floating panel')
381
+ expect(found[0].entry.name).toBe('Popover')
382
+ expect(found[0].props.filter((name) => !['align', 'side'].includes(name))).toEqual([])
383
+ })
384
+
385
+ it('does not match on a one-letter word', () => {
386
+ // `person's` puts "s" in Avatar's purpose; before the minimum length,
387
+ // "scrolling" found it.
388
+ expect(names('scrolling')).not.toContain('Avatar')
389
+ })
390
+
391
+ it('answers nothing rather than anything', () => {
392
+ expect(findInRegistry(registry, 'kubernetes ingress')).toEqual([])
393
+ expect(findInRegistry(registry, '')).toEqual([])
394
+ })
395
+
396
+ it('ranks a row that answers more of the question first', () => {
397
+ const found = findInRegistry(registry, 'floating panel')
398
+ expect(found[0].entry.name).toBe('Popover')
399
+ expect(found[0].matched).toBe(2)
400
+ })
401
+ })
402
+
403
+ describe('the committed registry.json', () => {
404
+ const committed = join(root, 'registry.json')
405
+
406
+ it('is what the code produces', () => {
407
+ // Line endings are the checkout's (core.autocrlf is true here); the bytes
408
+ // that matter are everything else.
409
+ expect(readFileSync(committed, 'utf8').replace(/\r\n/g, '\n')).toBe(serializeRegistry(registry))
410
+ })
411
+
412
+ it('validates on its own, as a reader loads it', () => {
413
+ expect(validateRegistry(JSON.parse(readFileSync(committed, 'utf8')) as Registry)).toEqual([])
414
+ })
415
+ })
416
+
417
+ describe('validateRegistry refuses', () => {
418
+ const broken = (change: (copy: Registry) => void): string[] => {
419
+ const copy = JSON.parse(JSON.stringify(registry)) as Registry
420
+ change(copy)
421
+ return validateRegistry(copy)
422
+ }
423
+
424
+ it('a schema version it does not know', () => {
425
+ expect(broken((copy) => (copy.schemaVersion = SCHEMA_VERSION + 1))[0]).toMatch(/schemaVersion/)
426
+ })
427
+
428
+ it('an entry with no purpose', () => {
429
+ expect(broken((copy) => (copy.entries[0].purpose = ' ')).join(' ')).toMatch(/purpose.*is empty/)
430
+ })
431
+
432
+ it('the same name twice', () => {
433
+ expect(broken((copy) => copy.entries.push({ ...copy.entries[0] })).join(' ')).toMatch(/appears twice/)
434
+ })
435
+
436
+ it('a count that does not reconcile', () => {
437
+ expect(broken((copy) => copy.entries.pop()).join(' ')).toMatch(/but index\.ts exports/)
438
+ })
439
+
440
+ it('a component with nowhere to be looked at', () => {
441
+ expect(
442
+ broken((copy) => {
443
+ const one = copy.entries.find((candidate) => candidate.kind === 'component')
444
+ if (one) one.storyId = null
445
+ }).join(' '),
446
+ ).toMatch(/only a helper or a hook/)
447
+ })
448
+ })