@estiva-app/ui 0.35.0 → 0.35.2
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/dist/gates/{chunk-JWOU4UQZ.js → chunk-DBMUQAR6.js} +6 -5
- package/dist/gates/chunk-DBMUQAR6.js.map +7 -0
- package/dist/gates/{chunk-D466XXVL.js → chunk-DRIHHXQK.js} +5 -1
- package/dist/gates/{chunk-D466XXVL.js.map → chunk-DRIHHXQK.js.map} +2 -2
- package/dist/gates/cli.js +1 -1
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/gate-config.d.ts.map +1 -1
- package/dist/gates/hook.d.ts.map +1 -1
- package/dist/gates/index.js +6 -4
- package/dist/gates/index.js.map +2 -2
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/registry/{chunk-KMZIMB3G.js → chunk-5K2TRR4X.js} +30 -2
- package/dist/registry/chunk-5K2TRR4X.js.map +7 -0
- package/dist/registry/cli.js +89 -31
- package/dist/registry/cli.js.map +3 -3
- package/dist/registry/contract.d.ts +14 -0
- package/dist/registry/contract.d.ts.map +1 -1
- package/dist/registry/index.js +1 -1
- package/dist/registry/siblings.d.ts +9 -0
- package/dist/registry/siblings.d.ts.map +1 -1
- package/dist/registry/storymap.d.ts +19 -5
- package/dist/registry/storymap.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/gates/create-app.test.ts +8 -0
- package/src/gates/create-app.ts +4 -0
- package/src/gates/gate-config.ts +4 -2
- package/src/gates/gates.test.ts +12 -2
- package/src/gates/hook.ts +6 -2
- package/src/gates/status.ts +4 -2
- package/src/pages.test.ts +11 -0
- package/src/registry/cli.ts +34 -17
- package/src/registry/contract.test.ts +37 -2
- package/src/registry/contract.ts +49 -1
- package/src/registry/siblings.test.ts +24 -1
- package/src/registry/siblings.ts +23 -0
- package/src/registry/storymap.test.ts +52 -31
- package/src/registry/storymap.ts +76 -37
- package/stories/Choosing.mdx +8 -2
- package/dist/gates/chunk-JWOU4UQZ.js.map +0 -7
- package/dist/registry/chunk-KMZIMB3G.js.map +0 -7
|
@@ -60,6 +60,14 @@ describe('create-estiva-app', () => {
|
|
|
60
60
|
expect(files['.storybook/preview.tsx']).toContain('document.documentElement.dataset.theme = "dark"')
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
+
// The re-review after the audit before UIG-26: the catalogue links every story file's part
|
|
64
|
+
// to its Docs page, and without autodocs there is none, so `registry:check` failed the
|
|
65
|
+
// made app's required job on its first commit ("pages-home--docs" leads nowhere). Proved
|
|
66
|
+
// with the real CLI on a made app: with the tag, "all 2 open"; without it, that failure.
|
|
67
|
+
it('gives every story file a Docs page, so the catalogue links it and the gate passes', () => {
|
|
68
|
+
expect(files['.storybook/preview.tsx']).toMatch(/tags: \['autodocs'\]/)
|
|
69
|
+
})
|
|
70
|
+
|
|
63
71
|
it('starts the count at zero and keeps the CI job named gate', () => {
|
|
64
72
|
const count = JSON.parse(files['.gates-count.json'])
|
|
65
73
|
expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
|
package/src/gates/create-app.ts
CHANGED
|
@@ -929,6 +929,10 @@ import '../src/index.css'
|
|
|
929
929
|
document.documentElement.dataset.theme = ${JSON.stringify(theme)}
|
|
930
930
|
|
|
931
931
|
const preview: Preview = {
|
|
932
|
+
// Every story file gets a Docs page, as in Peek and Ship: the catalogue links each
|
|
933
|
+
// part to it, and \`npm run registry:check\` in CI's job \`gate\` fails a link that
|
|
934
|
+
// leads nowhere.
|
|
935
|
+
tags: ['autodocs'],
|
|
932
936
|
parameters: { layout: 'centered' },
|
|
933
937
|
}
|
|
934
938
|
|
package/src/gates/gate-config.ts
CHANGED
|
@@ -44,8 +44,10 @@ export interface GateConfigOptions {
|
|
|
44
44
|
export function gateLint({ audience = 'app' }: Pick<GateConfigOptions, 'audience'> = {}): Linter.Config {
|
|
45
45
|
return {
|
|
46
46
|
...(audience === 'package' ? estiva.configs.package : estiva.configs.recommended),
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// An app's every script type: a raw <button> in a .jsx, or a key listener in a .mts,
|
|
48
|
+
// passed while only .ts and .tsx were read (the re-review after the audit before UIG-26).
|
|
49
|
+
files: audience === 'package' ? ['src/**/*.tsx'] : ['src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'],
|
|
50
|
+
ignores: ['**/*.test.*'],
|
|
49
51
|
languageOptions: {
|
|
50
52
|
parser: typescriptParser,
|
|
51
53
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
package/src/gates/gates.test.ts
CHANGED
|
@@ -182,6 +182,13 @@ describe('runHook', () => {
|
|
|
182
182
|
expect((await runHook({ root: dir, input: fine })).code).toBe(0)
|
|
183
183
|
})
|
|
184
184
|
|
|
185
|
+
// The re-review after the audit before UIG-26: only .ts and .tsx were read, so these passed.
|
|
186
|
+
it('reads every script type under src: a raw button in a .jsx, a page listener in a .mts', async () => {
|
|
187
|
+
expect((await runHook({ root: dir, input: write('src/Probe.jsx', component('<button type="button">x</button>')) })).code).toBe(2)
|
|
188
|
+
expect((await runHook({ root: dir, input: write('src/lib/probe.mts', "export function listen() {\n document.addEventListener('keydown', () => {})\n}\n") })).code).toBe(2)
|
|
189
|
+
expect((await runHook({ root: dir, input: write('src/Probe.test.jsx', component('<button type="button">x</button>')) })).code).toBe(0)
|
|
190
|
+
})
|
|
191
|
+
|
|
185
192
|
it('lets through a test, a declaration file, and anything outside src', async () => {
|
|
186
193
|
for (const path of ['src/Probe.test.tsx', 'src/types.d.ts', 'scripts/probe.tsx']) {
|
|
187
194
|
expect((await runHook({ root: dir, input: write(path, component('<button type="button">x</button>')) })).code).toBe(0)
|
|
@@ -242,14 +249,17 @@ describe('gates:status', () => {
|
|
|
242
249
|
// the write goes through.
|
|
243
250
|
it('runs the committed hook and passes only when it refuses the write', () => {
|
|
244
251
|
const settings = (matcher: string, command: string) => ({ '.claude/settings.json': JSON.stringify({ hooks: { PreToolUse: [{ matcher, hooks: [{ type: 'command', command }] }] } }) })
|
|
245
|
-
const refuses = 'node -e "process.exit(2)" -- gates'
|
|
252
|
+
const refuses = 'node -e "console.error(String.fromCharCode(60) + \'button\' + String.fromCharCode(62)); process.exit(2)" -- gates'
|
|
246
253
|
expect(helpers(app('hook-refuses', settings('Edit|Write', refuses))).hook('.claude/settings.json', 'gates')).toMatchObject({ result: 'pass' })
|
|
247
254
|
const broken = helpers(app('hook-broken', settings('Edit|Write', 'node "$CLAUDE_PROJECT_DIR/node_modules/@estiva-app/ui/dist/gates/cli.js" hook'))).hook('.claude/settings.json', 'gates')
|
|
248
255
|
expect(broken).toMatchObject({ result: 'fail', detail: expect.stringContaining('let a raw <button> through (exit 1)') })
|
|
249
256
|
expect(helpers(app('hook-waves', settings('Edit|Write', 'node -e "process.exit(0)" -- gates'))).hook('.claude/settings.json', 'gates')).toMatchObject({ result: 'fail' })
|
|
250
257
|
expect(helpers(app('hook-never', settings('Read', refuses))).hook('.claude/settings.json', 'gates')).toMatchObject({ result: 'fail', detail: expect.stringContaining('never for a Write') })
|
|
251
258
|
// The hook is handed the real project folder, as Claude Code hands it.
|
|
252
|
-
const seen = 'node -e "process.exit(String(process.env.CLAUDE_PROJECT_DIR).endsWith(process.argv[1]) ? 2 : 1)" hook-dir gates'
|
|
259
|
+
const seen = 'node -e "console.error(String.fromCharCode(60) + \'button\' + String.fromCharCode(62)); process.exit(String(process.env.CLAUDE_PROJECT_DIR).endsWith(process.argv[1]) ? 2 : 1)" hook-dir gates'
|
|
260
|
+
// Exit 2 for another reason is not a refusal of the button (in the package a new file with no page is refused too).
|
|
261
|
+
const other = 'node -e "console.error(String.fromCharCode(110) + \'o page\'); process.exit(2)" -- gates'
|
|
262
|
+
expect(helpers(app('hook-other', settings('Edit|Write', other))).hook('.claude/settings.json', 'gates')).toMatchObject({ result: 'fail' })
|
|
253
263
|
expect(helpers(app('hook-dir', settings('Edit|Write', seen))).hook('.claude/settings.json', 'gates')).toMatchObject({ result: 'pass' })
|
|
254
264
|
})
|
|
255
265
|
|
package/src/gates/hook.ts
CHANGED
|
@@ -69,6 +69,8 @@ interface ToolCall {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
const PASS: HookResult = { code: 0, message: '' }
|
|
72
|
+
/** An app's source the gate reads: every script type, with or without JSX. */
|
|
73
|
+
const APP_SOURCE = /^src\/.+\.[cm]?[jt]sx?$/
|
|
72
74
|
|
|
73
75
|
export async function runHook({ root, app = '.', audience = 'app', input, seen }: HookOptions = {}): Promise<HookResult> {
|
|
74
76
|
let call: ToolCall
|
|
@@ -84,8 +86,10 @@ export async function runHook({ root, app = '.', audience = 'app', input, seen }
|
|
|
84
86
|
const appDir = resolve(top, app)
|
|
85
87
|
const file = isAbsolute(args.file_path) ? args.file_path : resolve(call.cwd ?? top, args.file_path)
|
|
86
88
|
const rel = relative(appDir, file).split(sep).join('/')
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
// Every script under an app's src/, as the gate reads it (a raw <button> in a .jsx passed
|
|
90
|
+
// before, the re-review after the audit before UIG-26); the package's own gate is .tsx only.
|
|
91
|
+
const source = audience === 'package' ? /^src\/.+\.tsx$/ : APP_SOURCE
|
|
92
|
+
if (rel.startsWith('../') || isAbsolute(rel) || !source.test(rel) || /\.test\.[cm]?[jt]sx?$/.test(rel) || /\.d\.[cm]?ts$/.test(rel)) return PASS
|
|
89
93
|
|
|
90
94
|
const text = proposed(call.tool_name, args, file)
|
|
91
95
|
if (text === null) return PASS
|
package/src/gates/status.ts
CHANGED
|
@@ -318,11 +318,13 @@ export function helpers(ROOT: string): GateHelpers {
|
|
|
318
318
|
hook_event_name: 'PreToolUse',
|
|
319
319
|
cwd: top,
|
|
320
320
|
tool_name: 'Write',
|
|
321
|
-
tool_input: { file_path: `${resolve(ROOT, app, 'src', '__gates_hook_probe__.tsx').split('\\').join('/')}`, content: 'export function Probe() {\n return <button type="button">x</button>\n}\n' },
|
|
321
|
+
tool_input: { file_path: `${resolve(ROOT, app, 'src', '__gates_hook_probe__.tsx').split('\\').join('/')}`, content: 'export function Probe() {\n return <div><button type="button">x</button></div>\n}\n' },
|
|
322
322
|
})
|
|
323
323
|
const command = hit.command.replace(/\$\{?CLAUDE_PROJECT_DIR\}?/g, top)
|
|
324
324
|
const run = spawnSync(command, { cwd: ROOT, input, shell: true, encoding: 'utf8', timeout: 120_000, env: { ...process.env, CLAUDE_PROJECT_DIR: top } })
|
|
325
|
-
|
|
325
|
+
// Refused for the button, not for something else about a new file (in the package a new
|
|
326
|
+
// file with no page is refused too): nested, so the package's inward rule reads it as well.
|
|
327
|
+
if (run.status === 2 && /<button>/.test(`${run.stderr ?? ''}${run.stdout ?? ''}`)) return PASS(`${settingsRel}: the hook ran and refused a raw <button> (exit 2)`)
|
|
326
328
|
const why = `${run.stderr ?? ''}${run.stdout ?? ''}`.trim().split(/\r?\n/)[0] || (run.error ? String(run.error.message) : 'no output')
|
|
327
329
|
return FAIL(`${settingsRel}: the hook ran and let a raw <button> through (exit ${run.status ?? 'none'}): ${why}`)
|
|
328
330
|
},
|
package/src/pages.test.ts
CHANGED
|
@@ -140,3 +140,14 @@ describe('the page contract', () => {
|
|
|
140
140
|
})
|
|
141
141
|
}
|
|
142
142
|
})
|
|
143
|
+
|
|
144
|
+
// The re-review after the audit before UIG-26 (R17): "Choosing a component" is written by
|
|
145
|
+
// hand, and six parts with pages of their own had no row — ContainerHeader and ListColumn
|
|
146
|
+
// among them, the parts behind the skill's first two Gotchas.
|
|
147
|
+
describe('Choosing a component', () => {
|
|
148
|
+
it('names every part that has a page of its own', () => {
|
|
149
|
+
const choosing = readFileSync(new URL('../stories/Choosing.mdx', dir), 'utf8')
|
|
150
|
+
const named = new Set([...choosing.matchAll(/\*\*([A-Z][A-Za-z]+)\*\*/g)].map((m) => m[1]))
|
|
151
|
+
expect(pages.filter((page) => !named.has(page))).toEqual([])
|
|
152
|
+
})
|
|
153
|
+
})
|
package/src/registry/cli.ts
CHANGED
|
@@ -36,11 +36,11 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from
|
|
|
36
36
|
import { tmpdir } from 'node:os'
|
|
37
37
|
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
38
38
|
import { fileURLToPath } from 'node:url'
|
|
39
|
-
import { CONTRACT_KINDS, contractProblems, linkProblems } from './contract'
|
|
39
|
+
import { CONTRACT_KINDS, contractProblems, linkProblems, nameProblems } from './contract'
|
|
40
40
|
import { findInRegistries, formatFindings } from './find'
|
|
41
|
-
import { findSiblings, workspaceOf } from './siblings'
|
|
41
|
+
import { behindBy, findSiblings, workspaceOf } from './siblings'
|
|
42
42
|
import { loaderPath, loaderProblem, loaderText, repositoryOf, settingsPath, settingsWithSearch } from './skill'
|
|
43
|
-
import { storyMapProblems } from './storymap'
|
|
43
|
+
import { indexHeadings, storyMapProblems } from './storymap'
|
|
44
44
|
import { CLASSES, validateRegistry, type Registry } from './schema'
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -132,11 +132,11 @@ async function buildApp(folder: string, repo: string | undefined): Promise<Regis
|
|
|
132
132
|
* Reports, and says whether it passed. A folder with no Storybook has nothing
|
|
133
133
|
* to check.
|
|
134
134
|
*/
|
|
135
|
-
function checkLinks(registry: Registry, folder: string): boolean {
|
|
135
|
+
function checkLinks(registry: Registry, folder: string): { ok: boolean; headings?: Set<string>; titles?: Set<string> } {
|
|
136
136
|
const bin = resolve(folder, 'node_modules', '.bin', process.platform === 'win32' ? 'storybook.cmd' : 'storybook')
|
|
137
137
|
if (!existsSync(resolve(folder, '.storybook')) || !existsSync(bin)) {
|
|
138
138
|
process.stdout.write('story links: no Storybook here, nothing to check\n')
|
|
139
|
-
return true
|
|
139
|
+
return { ok: true }
|
|
140
140
|
}
|
|
141
141
|
const out = join(mkdtempSync(join(tmpdir(), 'estiva-ui-index-')), 'index.json')
|
|
142
142
|
// A `.cmd` needs a shell on Windows, and Node wants that as one quoted line
|
|
@@ -147,17 +147,20 @@ function checkLinks(registry: Registry, folder: string): boolean {
|
|
|
147
147
|
: spawnSync(bin, ['index', '-o', out, '--quiet'], { cwd: folder, encoding: 'utf8' })
|
|
148
148
|
if (run.status !== 0 || !existsSync(out)) {
|
|
149
149
|
process.stderr.write(`story links: Storybook could not index its stories, so no link can be trusted:\n${(run.stderr || run.stdout || '').trim()}\n`)
|
|
150
|
-
return false
|
|
150
|
+
return { ok: false }
|
|
151
151
|
}
|
|
152
|
-
const
|
|
152
|
+
const entries = (JSON.parse(readFileSync(out, 'utf8')) as { entries: Record<string, { title?: string }> }).entries
|
|
153
|
+
const ids = new Set(Object.keys(entries))
|
|
154
|
+
const headings = indexHeadings(entries)
|
|
155
|
+
const titles = new Set(Object.values(entries).flatMap((e) => (e.title ? [e.title] : [])))
|
|
153
156
|
const dead = linkProblems(registry, ids)
|
|
154
157
|
const count = registry.entries.reduce((n, entry) => n + (entry.storyId ? 1 : 0) + (entry.docsId ? 1 : 0), 0)
|
|
155
158
|
if (dead.length) {
|
|
156
159
|
process.stderr.write(`story links: ${dead.length} of ${count} lead nowhere:\n ${dead.join('\n ')}\n`)
|
|
157
|
-
return false
|
|
160
|
+
return { ok: false, headings, titles }
|
|
158
161
|
}
|
|
159
162
|
process.stdout.write(`story links: all ${count} open a story or docs page Storybook has\n`)
|
|
160
|
-
return true
|
|
163
|
+
return { ok: true, headings, titles }
|
|
161
164
|
}
|
|
162
165
|
|
|
163
166
|
/**
|
|
@@ -179,12 +182,12 @@ async function checkGated(registry: Registry, folder: string, kinds?: string[]):
|
|
|
179
182
|
}
|
|
180
183
|
|
|
181
184
|
/**
|
|
182
|
-
* The
|
|
183
|
-
* Introduction — against the Storybook
|
|
184
|
-
* Reports, and says whether it passed.
|
|
185
|
+
* The repository's hand-written map of its Storybook — the heading order and the
|
|
186
|
+
* Introduction — against the Storybook (its own index, when there is one) and,
|
|
187
|
+
* in an app, the catalogue (`./storymap`). Reports, and says whether it passed.
|
|
185
188
|
*/
|
|
186
|
-
function checkStoryMap(
|
|
187
|
-
const problems = storyMapProblems(registry,
|
|
189
|
+
function checkStoryMap(folder: string, registry?: Registry, headings?: Set<string>): boolean {
|
|
190
|
+
const problems = storyMapProblems(folder, { ...(registry ? { registry } : {}), ...(headings ? { real: headings } : {}) })
|
|
188
191
|
if (problems.length) {
|
|
189
192
|
process.stderr.write(`storybook map: ${problems.length === 1 ? 'one line no longer matches' : `${problems.length} lines no longer match`}:\n ${problems.join('\n ')}\n`)
|
|
190
193
|
return false
|
|
@@ -212,6 +215,8 @@ async function main(): Promise<number> {
|
|
|
212
215
|
const registries: Registry[] = [readPackageRegistry()]
|
|
213
216
|
// An app that cannot be read is said out loud, and the rest still answer.
|
|
214
217
|
const add = async (folder: string, repo: string | undefined) => {
|
|
218
|
+
const behind = behindBy(folder)
|
|
219
|
+
if (behind) process.stderr.write(`note: ${repo ?? basename(folder)} (${folder}) is ${behind} commit${behind === 1 ? '' : 's'} behind main as last fetched — its answers may name parts that are gone; pull it\n`)
|
|
215
220
|
try {
|
|
216
221
|
registries.push(await buildApp(folder, repo))
|
|
217
222
|
} catch (error) {
|
|
@@ -283,8 +288,18 @@ async function main(): Promise<number> {
|
|
|
283
288
|
const linked = checkLinks(registry, root)
|
|
284
289
|
const shown = await checkGated(registry, root)
|
|
285
290
|
const loaded = checkLoader()
|
|
286
|
-
const mapped = checkStoryMap(registry,
|
|
287
|
-
|
|
291
|
+
const mapped = checkStoryMap(root, registry, linked.headings)
|
|
292
|
+
// What the pages name must still be there (R14): parts under When and When not,
|
|
293
|
+
// and the stories a Seen in line sends a reader to, here or in the package.
|
|
294
|
+
const pkg = readPackageRegistry()
|
|
295
|
+
const stale = nameProblems(registry, root, {
|
|
296
|
+
known: new Set([...pkg.entries, ...registry.entries].map((e) => e.name)),
|
|
297
|
+
...(linked.titles ? { titles: linked.titles } : {}),
|
|
298
|
+
packageIds: pkg.entries.flatMap((e) => [e.storyId, e.docsId].filter((id): id is string => Boolean(id))),
|
|
299
|
+
})
|
|
300
|
+
if (stale.length) process.stderr.write(`usage pages: ${stale.length === 1 ? 'one name leads' : `${stale.length} names lead`} nowhere:\n ${stale.join('\n ')}\n`)
|
|
301
|
+
else process.stdout.write('usage pages: every part and story they name is there\n')
|
|
302
|
+
return broken.length || stale.length || !linked.ok || !shown || !loaded || !mapped ? 1 : 0
|
|
288
303
|
}
|
|
289
304
|
const { buildRegistry, serializeRegistry } = await packageBuilder()
|
|
290
305
|
const path = packageRegistryPath()
|
|
@@ -308,7 +323,9 @@ async function main(): Promise<number> {
|
|
|
308
323
|
const linked = checkLinks(built, root)
|
|
309
324
|
const shown = await checkGated(built, root, ['component'])
|
|
310
325
|
const loaded = checkLoader()
|
|
311
|
-
|
|
326
|
+
// The package keeps a heading order too (R16): it left out Layout, and nothing saw.
|
|
327
|
+
const mapped = checkStoryMap(root, undefined, linked.headings)
|
|
328
|
+
return linked.ok && shown && loaded && mapped ? 0 : 1
|
|
312
329
|
}
|
|
313
330
|
|
|
314
331
|
case 'skill': {
|
|
@@ -18,7 +18,7 @@ import { tmpdir } from 'node:os'
|
|
|
18
18
|
import { dirname, join } from 'node:path'
|
|
19
19
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
20
20
|
import { buildAppRegistry } from './app'
|
|
21
|
-
import { contractProblems, hasSeenIn, linkProblems, pageProblem } from './contract'
|
|
21
|
+
import { contractProblems, hasSeenIn, linkProblems, nameProblems, pageProblem } from './contract'
|
|
22
22
|
|
|
23
23
|
const made: string[] = []
|
|
24
24
|
afterAll(() => {
|
|
@@ -86,8 +86,11 @@ describe('a usage page', () => {
|
|
|
86
86
|
expect(pageProblem(PAGE.replace('A small count beside a name.\n', ''))).toMatch(/^no opening line/)
|
|
87
87
|
})
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
// R18: the code must be under How itself; one in a later section passed.
|
|
90
|
+
it('fails with no code under How, even with code further down', () => {
|
|
90
91
|
expect(pageProblem(PAGE.replace('```tsx\n<Badge count={3} />\n```', 'Pass a count.'))).toBe('no code under "How"')
|
|
92
|
+
const later = PAGE.replace('```tsx\n<Badge count={3} />\n```', 'Pass a count.').replace('Nothing. It only draws.', '```tsx\n<Badge count={3} />\n```')
|
|
93
|
+
expect(pageProblem(later)).toBe('no code under "How"')
|
|
91
94
|
})
|
|
92
95
|
|
|
93
96
|
it('reads a Windows checkout', () => {
|
|
@@ -156,3 +159,35 @@ describe('story links', () => {
|
|
|
156
159
|
])
|
|
157
160
|
})
|
|
158
161
|
})
|
|
162
|
+
|
|
163
|
+
// R14, the re-review after the audit before UIG-26. Every case is Peek's or Ship's own:
|
|
164
|
+
// StartTopicDialog sent readers to CreateTopicDialog and RepliesRow to HuddleCard, both
|
|
165
|
+
// deleted by FOL-23; EmptyState's Seen in named a story on two lines; Ship's Attachment
|
|
166
|
+
// named the package's story by a title it had lost.
|
|
167
|
+
describe('nameProblems', () => {
|
|
168
|
+
const page = (whenNot: string, seenIn = '') =>
|
|
169
|
+
PAGE.replace('A word.', whenNot).replace('A small count beside a name.', `A small count beside a name.${seenIn ? `\n\n${seenIn}` : ''}`)
|
|
170
|
+
const registry = (dir: string) => ({ entries: [{ name: 'Badge', docPage: 'src/Badge.mdx' }] }) as unknown as Parameters<typeof nameProblems>[0]
|
|
171
|
+
const known = new Set(['Badge', 'Chip'])
|
|
172
|
+
|
|
173
|
+
it('names a part under When not that no catalogue has, and passes one that exists', () => {
|
|
174
|
+
const dir = app({ 'src/Badge.mdx': page('A topic someone is invited to: that is **CreateTopicDialog**; a label is `Chip`.') })
|
|
175
|
+
expect(nameProblems(registry(dir), dir, { known })).toEqual(['src/Badge.mdx: "When not" names CreateTopicDialog, which is no part of the package or this app'])
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('reads a Seen in line against the Storybook, a title wrapped across lines too, and the package ids', () => {
|
|
179
|
+
const seen = '**Seen in** — *Parts/Badge*, *To be removed/\nTopicActivity* and\n*Media/AttachmentCard*.'
|
|
180
|
+
const dir = app({ 'src/Badge.mdx': page('A word.', seen) })
|
|
181
|
+
const titles = new Set(['Parts/Badge'])
|
|
182
|
+
expect(nameProblems(registry(dir), dir, { known, titles, packageIds: ['components-attachmentcard--document'] })).toEqual([
|
|
183
|
+
'src/Badge.mdx: "Seen in" names To be removed/TopicActivity, which no Storybook has',
|
|
184
|
+
'src/Badge.mdx: "Seen in" names Media/AttachmentCard, which no Storybook has',
|
|
185
|
+
])
|
|
186
|
+
expect(nameProblems(registry(dir), dir, { known, titles: new Set(['Parts/Badge', 'To be removed/TopicActivity']), packageIds: ['media-attachmentcard--default'] })).toEqual([])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('leaves the Seen in line alone when there is no Storybook index to read', () => {
|
|
190
|
+
const dir = app({ 'src/Badge.mdx': page('A word.', '**Seen in** — *Gone/Story*.') })
|
|
191
|
+
expect(nameProblems(registry(dir), dir, { known })).toEqual([])
|
|
192
|
+
})
|
|
193
|
+
})
|
package/src/registry/contract.ts
CHANGED
|
@@ -51,7 +51,10 @@ export function pageProblem(source: string): string | null {
|
|
|
51
51
|
.map((s) => s.trim())
|
|
52
52
|
.find((s) => s && !s.startsWith('<') && !s.startsWith('import '))
|
|
53
53
|
if (!opening) return 'no opening line under the title saying what the part is'
|
|
54
|
-
|
|
54
|
+
// Only the How section: the code block must sit under How, not in a later section
|
|
55
|
+
// (a page with none under How and one under What it owns passed, R18).
|
|
56
|
+
const how = text.split(/^##\s+How\s*$/m)[1]?.split(/^##\s/m)[0] ?? ''
|
|
57
|
+
if (!/```tsx?\n/.test(how)) return 'no code under "How"'
|
|
55
58
|
return null
|
|
56
59
|
}
|
|
57
60
|
|
|
@@ -106,3 +109,48 @@ export function contractProblems(registry: Registry, root: string): string[] {
|
|
|
106
109
|
}
|
|
107
110
|
return problems
|
|
108
111
|
}
|
|
112
|
+
|
|
113
|
+
/** Storybook's id for a title: lower case, every run of other characters one hyphen. */
|
|
114
|
+
const toStoryId = (title: string) =>
|
|
115
|
+
title
|
|
116
|
+
.toLowerCase()
|
|
117
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
118
|
+
.replace(/^-+|-+$/g, '')
|
|
119
|
+
|
|
120
|
+
/** The names a page's section writes as a part: bold or in backticks, starting with a capital. */
|
|
121
|
+
function namedParts(section: string): string[] {
|
|
122
|
+
return [...section.matchAll(/\*\*([A-Z][A-Za-z0-9]+)\*\*|`([A-Z][A-Za-z0-9]+)`/g)].map((m) => m[1] ?? m[2])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What an app's usage pages name that is not there (the re-review after the audit
|
|
127
|
+
* before UIG-26, R14): a part named under When or When not that no catalogue has —
|
|
128
|
+
* Peek's StartTopicDialog sent readers to CreateTopicDialog, deleted by FOL-23 —
|
|
129
|
+
* and a story a **Seen in** line names that no Storybook has. `known` is every part
|
|
130
|
+
* of the package and the app; `titles` is the app's Storybook titles, when there
|
|
131
|
+
* is an index to read them from; `packageIds` the package's story and docs ids, so
|
|
132
|
+
* a page may send readers to the package's Storybook too.
|
|
133
|
+
*/
|
|
134
|
+
export function nameProblems(registry: Registry, root: string, { known, titles, packageIds = [] }: { known: Set<string>; titles?: Set<string>; packageIds?: string[] }): string[] {
|
|
135
|
+
const problems: string[] = []
|
|
136
|
+
for (const entry of registry.entries) {
|
|
137
|
+
if (!entry.docPage || !existsSync(join(root, entry.docPage))) continue
|
|
138
|
+
const text = readFileSync(join(root, entry.docPage), 'utf8').replace(/\r\n/g, '\n')
|
|
139
|
+
for (const heading of ['When', 'When not']) {
|
|
140
|
+
const section = text.split(new RegExp(`^##\\s+${heading}\\s*$`, 'm'))[1]?.split(/^##\s/m)[0] ?? ''
|
|
141
|
+
for (const name of new Set(namedParts(section).filter((n) => !known.has(n)))) {
|
|
142
|
+
problems.push(`${entry.docPage}: "${heading}" names ${name}, which is no part of the package or this app`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!titles) continue
|
|
146
|
+
const seenIn = text.match(/^\*\*Seen in\*\*[^\n]*(?:\n(?!\n)[^\n]*)*/m)?.[0] ?? ''
|
|
147
|
+
for (const [, raw] of seenIn.matchAll(/\*([A-Z][^*/]*\/[^*]+)\*/g)) {
|
|
148
|
+
// A title may wrap across lines, and a line may break after its slash.
|
|
149
|
+
const title = raw.replace(/\s+/g, ' ').replace(/\s*\/\s*/g, '/').trim()
|
|
150
|
+
const id = toStoryId(title)
|
|
151
|
+
if (titles.has(title) || packageIds.some((p) => p.startsWith(`${id}--`))) continue
|
|
152
|
+
problems.push(`${entry.docPage}: "Seen in" names ${title}, which no Storybook has`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return problems
|
|
156
|
+
}
|
|
@@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'
|
|
|
11
11
|
import { dirname, join } from 'node:path'
|
|
12
12
|
import { fileURLToPath } from 'node:url'
|
|
13
13
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
14
|
-
import { appName, findSiblings, workspaceOf } from './siblings'
|
|
14
|
+
import { appName, behindBy, findSiblings, workspaceOf } from './siblings'
|
|
15
15
|
|
|
16
16
|
const workspace = mkdtempSync(join(tmpdir(), 'uig20-'))
|
|
17
17
|
afterAll(() => rmSync(workspace, { recursive: true, force: true }))
|
|
@@ -83,3 +83,26 @@ describe.skipIf(!existsSync(cli))('estiva-ui find, with nothing else typed', ()
|
|
|
83
83
|
expect(find(join(workspace, 'alpha'), 'workbench', '--here')).not.toContain('Workbench')
|
|
84
84
|
})
|
|
85
85
|
})
|
|
86
|
+
|
|
87
|
+
// The re-review after the audit before UIG-26: a search from one repository read a
|
|
88
|
+
// neighbour's checkout eleven commits behind, and answered with a part it had deleted.
|
|
89
|
+
describe('behindBy', () => {
|
|
90
|
+
it('says how far a checkout is behind its main as last fetched, and null for a folder that is none', () => {
|
|
91
|
+
const base = mkdtempSync(join(tmpdir(), 'behind-'))
|
|
92
|
+
const git = (cwd: string, ...a: string[]) => execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...a], { cwd, stdio: 'pipe' })
|
|
93
|
+
git(base, 'init', '-q', '--bare', '-b', 'main', 'origin.git')
|
|
94
|
+
git(base, 'clone', '-q', 'origin.git', 'writer')
|
|
95
|
+
git(join(base, 'writer'), 'commit', '-q', '--allow-empty', '-m', 'one')
|
|
96
|
+
git(join(base, 'writer'), 'push', '-q', 'origin', 'HEAD:main')
|
|
97
|
+
git(base, 'clone', '-q', 'origin.git', 'reader')
|
|
98
|
+
const reader = join(base, 'reader')
|
|
99
|
+
expect(behindBy(reader)).toBe(0)
|
|
100
|
+
git(join(base, 'writer'), 'commit', '-q', '--allow-empty', '-m', 'two')
|
|
101
|
+
git(join(base, 'writer'), 'push', '-q', 'origin', 'HEAD:main')
|
|
102
|
+
expect(behindBy(reader)).toBe(0)
|
|
103
|
+
git(reader, 'fetch', '-q')
|
|
104
|
+
expect(behindBy(reader)).toBe(1)
|
|
105
|
+
expect(behindBy(tmpdir())).toBeNull()
|
|
106
|
+
rmSync(base, { recursive: true, force: true })
|
|
107
|
+
})
|
|
108
|
+
})
|
package/src/registry/siblings.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* somebody's copy) carries the same package name; the app the search runs in
|
|
17
17
|
* wins, then the one whose top folder has the shortest name.
|
|
18
18
|
*/
|
|
19
|
+
import { execFileSync } from 'node:child_process'
|
|
19
20
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
20
21
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
21
22
|
|
|
@@ -94,3 +95,25 @@ export function findSiblings(workspace: string, { skip = [] }: { skip?: string[]
|
|
|
94
95
|
}
|
|
95
96
|
return [...byPackage.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
96
97
|
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* How many commits the checkout that holds `folder` is behind its remote's main,
|
|
101
|
+
* as last fetched: nothing is fetched, so a search stays quick. Null when it is
|
|
102
|
+
* not a git checkout or has no remote main. A search that reads a stale copy
|
|
103
|
+
* answers with parts that are gone (the re-review after the audit before UIG-26:
|
|
104
|
+
* "files panel" found Peek's deleted FilesPanel in an old checkout), so `find`
|
|
105
|
+
* says so beside its answer.
|
|
106
|
+
*/
|
|
107
|
+
export function behindBy(folder: string): number | null {
|
|
108
|
+
const git = (...a: string[]) => {
|
|
109
|
+
try {
|
|
110
|
+
return execFileSync('git', ['-C', folder, ...a], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 }).toString().trim()
|
|
111
|
+
} catch {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const main = git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD') ?? (git('rev-parse', '--verify', '--quiet', 'origin/main') ? 'origin/main' : null)
|
|
116
|
+
if (!main) return null
|
|
117
|
+
const count = Number(git('rev-list', '--count', `HEAD..${main}`))
|
|
118
|
+
return Number.isFinite(count) ? count : null
|
|
119
|
+
}
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
/**
|
|
3
|
-
* The
|
|
4
|
-
*
|
|
5
|
-
* the stories,
|
|
6
|
-
*
|
|
3
|
+
* The hand-written map of a Storybook (audit A3, widened by R16). Every case is a
|
|
4
|
+
* real one: Peek's Huddles heading, kept in its Introduction and `HEADINGS` after
|
|
5
|
+
* FOL-23 deleted the stories, and its "49 parts worth reusing" when the catalogue
|
|
6
|
+
* built 41; Ship's `storySort.order` naming a Feedback heading it does not have and
|
|
7
|
+
* leaving out Pages, and its Introduction's bullets; the package's order leaving
|
|
8
|
+
* out Layout; and a Storybook with no map at all, which has nothing to rot.
|
|
7
9
|
*/
|
|
8
10
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
11
|
import { tmpdir } from 'node:os'
|
|
10
12
|
import { dirname, join } from 'node:path'
|
|
11
13
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
12
14
|
import type { Registry } from './schema'
|
|
13
|
-
import { introductionCount, introductionHeadings, orderedHeadings, sidebarHeadings, storyMapProblems } from './storymap'
|
|
15
|
+
import { indexHeadings, introductionCount, introductionHeadings, orderedHeadings, sidebarHeadings, storyMapProblems } from './storymap'
|
|
14
16
|
|
|
15
17
|
const made: string[] = []
|
|
16
18
|
afterAll(() => {
|
|
@@ -31,66 +33,85 @@ function app(files: Record<string, string>): string {
|
|
|
31
33
|
const catalogue = (...classes: string[]) => ({ entries: classes.map((cls) => ({ app: { class: cls } })) }) as unknown as Registry
|
|
32
34
|
|
|
33
35
|
const STORY = (title: string) => `import type { Meta } from '@storybook/react-vite'\nconst meta = {\n title: '${title}',\n} satisfies Meta\nexport default meta\n`
|
|
34
|
-
const
|
|
36
|
+
const HEADINGS_PREVIEW = (headings: string[]) =>
|
|
35
37
|
`export default {\n parameters: {\n options: {\n storySort: (a, b) => {\n const HEADINGS = [\n${headings.map((h) => ` '${h}', // someone's heading\n`).join('')} ]\n return 0\n },\n },\n },\n}\n`
|
|
36
|
-
const
|
|
38
|
+
const ORDER_PREVIEW = (headings: string[]) =>
|
|
39
|
+
`export default {\n parameters: {\n options: {\n // Docs first; the rest follows\n storySort: {\n order: [${headings.map((h) => (h === 'Docs' ? "'Docs', ['Introduction']" : `'${h}'`)).join(', ')}],\n },\n },\n },\n}\n`
|
|
40
|
+
const TABLE_INTRO = (count: number, headings: string[]) =>
|
|
37
41
|
`import { Meta } from '@storybook/addon-docs/blocks'\n\n<Meta title="Docs/Introduction" />\n\nOf everything the app owns, **${count} parts worth reusing** carry a written rule.\n\n| Heading | What |\n|---|---|\n${headings.map((h) => `| **${h}** | About ${h}. |`).join('\n')}\n`
|
|
42
|
+
const BULLET_INTRO = (groups: string[][]) =>
|
|
43
|
+
`<Meta title="Docs/Introduction" />\n\n## How the sidebar is organised\n\n${groups.map((g) => `- **${g.join(' · ')}** — about them.`).join('\n')}\n\n## Conventions\n\n- **Stories first.** A component exists in Storybook first.\n`
|
|
38
44
|
|
|
39
45
|
const PEEK_TODAY = {
|
|
40
|
-
'src/stories/Introduction.mdx':
|
|
46
|
+
'src/stories/Introduction.mdx': TABLE_INTRO(2, ['Docs', 'Primitives', 'Huddles']),
|
|
41
47
|
'src/components/ui/Badge.stories.tsx': STORY('Primitives/Badge'),
|
|
42
48
|
'src/components/ui/Chip.mdx': `import { Meta } from '@storybook/addon-docs/blocks'\n\n<Meta title='Primitives/Chip' />\n\n# Chip\n`,
|
|
43
|
-
'.storybook/preview.tsx':
|
|
49
|
+
'.storybook/preview.tsx': HEADINGS_PREVIEW(['Docs', 'Primitives', 'Huddles']),
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
describe('reading the map', () => {
|
|
47
|
-
it('reads the sidebar from story titles and
|
|
48
|
-
expect([...sidebarHeadings(app(PEEK_TODAY))].sort()).toEqual(['Docs', 'Primitives'])
|
|
53
|
+
it('reads the sidebar from story titles and page titles in either quote, in src and stories', () => {
|
|
54
|
+
expect([...sidebarHeadings(app({ ...PEEK_TODAY, 'stories/Tokens.mdx': "<Meta title='Tokens/All' />\n" }))].sort()).toEqual(['Docs', 'Primitives', 'Tokens'])
|
|
49
55
|
})
|
|
50
56
|
|
|
51
|
-
it(
|
|
52
|
-
expect(
|
|
57
|
+
it("reads Storybook's own index, whatever way a title was written", () => {
|
|
58
|
+
expect([...indexHeadings({ a: { title: 'Pages/Desk' }, b: { title: 'Docs/Introduction' }, c: {} })].sort()).toEqual(['Docs', 'Pages'])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('reads a HEADINGS list and a storySort order, skipping comments and nested lists', () => {
|
|
62
|
+
expect(orderedHeadings(HEADINGS_PREVIEW(['Docs', 'Pages']))).toEqual(['Docs', 'Pages'])
|
|
63
|
+
expect(orderedHeadings(ORDER_PREVIEW(['Docs', 'Primitives', 'Feedback']))).toEqual(['Docs', 'Primitives', 'Feedback'])
|
|
53
64
|
expect(orderedHeadings('export default {}')).toBeNull()
|
|
54
65
|
})
|
|
55
66
|
|
|
56
|
-
it(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
expect(
|
|
67
|
+
it("reads the Introduction's table rows and its bullets, and its stated count", () => {
|
|
68
|
+
expect([...introductionHeadings(TABLE_INTRO(49, ['Docs', 'Huddles']))]).toEqual(['Docs', 'Huddles'])
|
|
69
|
+
// A bullet with no dash after its bold words is prose, not a heading.
|
|
70
|
+
expect([...introductionHeadings(BULLET_INTRO([['Docs'], ['Primitives', 'Feedback', 'Inputs']]))]).toEqual(['Docs', 'Primitives', 'Feedback', 'Inputs'])
|
|
71
|
+
expect(introductionCount(TABLE_INTRO(49, ['Docs']))).toBe(49)
|
|
60
72
|
expect(introductionCount('# No number here')).toBeNull()
|
|
61
73
|
})
|
|
62
74
|
})
|
|
63
75
|
|
|
64
76
|
describe('storyMapProblems', () => {
|
|
65
77
|
it("finds Peek's three stale lines: a heading in both maps with no story, and the wrong count", () => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
'.storybook/preview.tsx: named in HEADINGS but no story uses them: Huddles',
|
|
78
|
+
expect(storyMapProblems(app(PEEK_TODAY), { registry: catalogue('reusable', 'reusable', 'reusable', 'local') })).toEqual([
|
|
79
|
+
'.storybook/preview.tsx: in the heading order but no story uses them: Huddles',
|
|
69
80
|
'src/stories/Introduction.mdx: the page names, the sidebar does not have: Huddles',
|
|
70
81
|
'src/stories/Introduction.mdx: says 2 parts worth reusing; the catalogue builds 3',
|
|
71
82
|
])
|
|
72
83
|
})
|
|
73
84
|
|
|
74
|
-
it(
|
|
75
|
-
const dir = app({
|
|
76
|
-
|
|
77
|
-
'
|
|
78
|
-
|
|
85
|
+
it("finds Ship's: an order and bullets naming Feedback, which it has not got, and leaving out Pages", () => {
|
|
86
|
+
const dir = app({
|
|
87
|
+
'.storybook/preview.tsx': ORDER_PREVIEW(['Docs', 'Primitives', 'Feedback']),
|
|
88
|
+
'src/stories/Introduction.mdx': BULLET_INTRO([['Docs'], ['Primitives', 'Feedback']]),
|
|
89
|
+
})
|
|
90
|
+
expect(storyMapProblems(dir, { real: new Set(['Docs', 'Primitives', 'Pages']) })).toEqual([
|
|
91
|
+
'.storybook/preview.tsx: not in the heading order, so they fall to the bottom of the sidebar: Pages',
|
|
92
|
+
'.storybook/preview.tsx: in the heading order but no story uses them: Feedback',
|
|
79
93
|
'src/stories/Introduction.mdx: the sidebar has, the page does not name: Pages',
|
|
80
|
-
'src/stories/Introduction.mdx: the page names, the sidebar does not have:
|
|
94
|
+
'src/stories/Introduction.mdx: the page names, the sidebar does not have: Feedback',
|
|
95
|
+
])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("finds the package's order leaving out Layout, from a Storybook index", () => {
|
|
99
|
+
const dir = app({ '.storybook/preview.tsx': ORDER_PREVIEW(['Docs', 'Primitives']) })
|
|
100
|
+
expect(storyMapProblems(dir, { real: indexHeadings({ a: { title: 'Layout/ListColumn' }, b: { title: 'Primitives/Button' }, c: { title: 'Docs/Introduction' } }) })).toEqual([
|
|
101
|
+
'.storybook/preview.tsx: not in the heading order, so they fall to the bottom of the sidebar: Layout',
|
|
81
102
|
])
|
|
82
103
|
})
|
|
83
104
|
|
|
84
105
|
it('passes when both maps match the Storybook and the count matches the catalogue', () => {
|
|
85
106
|
const dir = app({
|
|
86
107
|
...PEEK_TODAY,
|
|
87
|
-
'src/stories/Introduction.mdx':
|
|
88
|
-
'.storybook/preview.tsx':
|
|
108
|
+
'src/stories/Introduction.mdx': TABLE_INTRO(2, ['Docs', 'Primitives']),
|
|
109
|
+
'.storybook/preview.tsx': HEADINGS_PREVIEW(['Docs', 'Primitives']),
|
|
89
110
|
})
|
|
90
|
-
expect(storyMapProblems(catalogue('reusable', 'promote-candidate', 'local')
|
|
111
|
+
expect(storyMapProblems(dir, { registry: catalogue('reusable', 'promote-candidate', 'local') })).toEqual([])
|
|
91
112
|
})
|
|
92
113
|
|
|
93
|
-
it('has nothing to check in
|
|
94
|
-
expect(storyMapProblems(
|
|
114
|
+
it('has nothing to check in a repository that keeps no map', () => {
|
|
115
|
+
expect(storyMapProblems(app({ 'src/Badge.stories.tsx': STORY('Parts/Badge') }), { registry: catalogue('reusable') })).toEqual([])
|
|
95
116
|
})
|
|
96
117
|
})
|