@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.
- package/README.md +21 -0
- package/dist/Avatar.d.ts +3 -0
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/Banner.d.ts +20 -2
- package/dist/Banner.d.ts.map +1 -1
- package/dist/ChipInput.d.ts +3 -0
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts +6 -0
- package/dist/CommandPalette.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/IdentityMenu.d.ts +3 -0
- package/dist/IdentityMenu.d.ts.map +1 -1
- package/dist/Menu.d.ts +7 -0
- package/dist/Menu.d.ts.map +1 -1
- package/dist/Skeleton.d.ts +2 -0
- package/dist/Skeleton.d.ts.map +1 -1
- package/dist/Toast.d.ts +5 -0
- package/dist/Toast.d.ts.map +1 -1
- package/dist/Toolbar.d.ts +4 -0
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/Tooltip.d.ts +4 -0
- package/dist/Tooltip.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-DQI6WLHR.js.map +7 -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 +95 -0
- package/dist/registry/build.d.ts.map +1 -0
- package/dist/registry/chunk-E4JNV7PC.js +155 -0
- package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
- package/dist/registry/chunk-NJN4MQAM.js +397 -0
- package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
- package/dist/registry/chunk-W2B2G7OE.js +188 -0
- 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.d.ts +2 -0
- package/dist/registry/cli.d.ts.map +1 -0
- package/dist/registry/cli.js +176 -0
- package/dist/registry/cli.js.map +7 -0
- package/dist/registry/find.d.ts +54 -0
- package/dist/registry/find.d.ts.map +1 -0
- package/dist/registry/index.d.ts +17 -0
- package/dist/registry/index.d.ts.map +1 -0
- package/dist/registry/index.js +34 -0
- package/dist/registry/index.js.map +7 -0
- package/dist/registry/schema.d.ts +224 -0
- package/dist/registry/schema.d.ts.map +1 -0
- package/package.json +15 -2
- package/registry.json +4615 -0
- package/src/Avatar.tsx +3 -0
- 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/ChipInput.tsx +3 -0
- package/src/CommandPalette.tsx +6 -0
- 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/IdentityMenu.tsx +3 -0
- package/src/Menu.tsx +7 -0
- package/src/Skeleton.tsx +2 -0
- package/src/Toast.tsx +5 -0
- package/src/Toolbar.tsx +4 -0
- package/src/Tooltip.tsx +4 -0
- 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 +679 -0
- package/src/registry/cli.ts +204 -0
- package/src/registry/find.ts +232 -0
- package/src/registry/index.ts +31 -0
- package/src/registry/registry.test.ts +448 -0
- package/src/registry/schema.ts +419 -0
- package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
- 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
|
+
})
|