openpencil/tests/engine/cli/export/storybook.test.ts
Marc Went 8c72b62da0
feat(cli): export Storybook stories beside many documents (#761)
* feat(cli): export Storybook stories beside many documents

Accept several documents, or a quoted glob such as 'src/**/*.pen', and add --beside to write each document's stories, design images, and manifest into the document's own folder, next to the component's code. Documents export one after another, since documents in one folder share its manifest; a failed document is reported and the rest still export. --watch covers every matched document through one queue. Several documents need --beside or --output, and --page takes a single document.

Refs #727

* fix(cli): resolve Storybook export documents by existence, not glob syntax

Deciding between a path and a pattern by looking for glob characters missed
extglobs, so 'src/+(a|b).pen' was opened as a literal filename, and it flagged
an escaped star, so a file genuinely named that way went to the matcher. The
character list also could not agree with Node's matcher: is-glob rejects a
bare '?', picomatch accepts a parenthesised directory name.

An existing path is now that file, and everything else goes to glob(), which
matches a plain path to itself and expands every pattern it supports.

---------

Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-09-30 22:05:51 +04:00

512 lines
19 KiB
TypeScript

import { expect, setDefaultTimeout, test } from 'bun:test'
import { mkdir, mkdtemp, readdir, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
import { runOpenPencilCLI } from '#tests/helpers/cli'
import { cliSourcePath } from '#tests/helpers/paths'
import { makeSceneGraph } from '#tests/helpers/scene'
setDefaultTimeout(30_000)
const io = new IORegistry(BUILTIN_IO_FORMATS)
const MANIFEST = '.openpencil-stories.json'
/** Entries a user sees in the output folder; the manifest is a dot file. */
async function outputEntries(output: string): Promise<string[]> {
return (await readdir(output)).filter((entry) => !entry.startsWith('.')).sort()
}
async function writeComponentFixture(dir: string, names: string[], file = 'library.fig') {
const figPath = join(dir, file)
const graph = makeSceneGraph()
const page = graph.addPage('Library')
for (const name of names) graph.createNode('COMPONENT', page.id, { name, width: 32, height: 16 })
const result = await io.writeDocument('fig', graph)
await Bun.write(figPath, result.data as Uint8Array)
return figPath
}
test('export CLI writes Storybook stories with design images', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const figPath = await writeComponentFixture(dir, ['Badge'])
const output = join(dir, 'stories')
const { stdout, stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'storybook',
'--framework',
'vue',
'--font-policy',
'allow',
'--output',
output
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
expect(stdout).toContain('Exported 1 story files')
const story = await Bun.file(join(output, 'Badge.stories.ts')).text()
expect(story).toContain("from '@storybook/vue3-vite'")
expect(story).toContain("title: 'Library/Badge'")
const manifest: unknown = JSON.parse(await Bun.file(join(output, MANIFEST)).text())
expect(manifest).toEqual({
version: 1,
files: {
'Badge.design/Default.png': { source: '../library.fig', page: 'Library' },
'Badge.stories.ts': { source: '../library.fig', page: 'Library' }
}
})
const png = new Uint8Array(await Bun.file(join(output, 'Badge.design/Default.png')).arrayBuffer())
expect(new TextDecoder().decode(png.slice(1, 4))).toBe('PNG')
})
test('export CLI replaces stale generated stories and keeps hand-written ones', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const output = join(dir, 'stories')
const args = ['--format', 'storybook', '--no-design-images', '--output', output]
const setup = await runOpenPencilCLI([
'export',
await writeComponentFixture(dir, ['Old']),
...args
])
expect(setup.exitCode).toBe(0)
await Bun.write(join(output, 'Mine.stories.ts'), 'export default {}\n')
const { exitCode } = await runOpenPencilCLI([
'export',
await writeComponentFixture(dir, ['New']),
...args
])
expect(exitCode).toBe(0)
expect(await Bun.file(join(output, 'Old.stories.ts')).exists()).toBe(false)
expect(await Bun.file(join(output, 'New.stories.ts')).exists()).toBe(true)
expect((await outputEntries(output)).some((entry) => entry.endsWith('.design'))).toBe(false)
expect(await Bun.file(join(output, 'Mine.stories.ts')).text()).toBe('export default {}\n')
})
test('export CLI keeps stories of other documents and refuses to overwrite them', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const output = join(dir, 'stories')
const args = ['--format', 'storybook', '--no-design-images', '--output', output]
for (const [names, file] of [
[['Card'], 'library.fig'],
[['Chip'], 'b.fig']
] as const) {
const setup = await runOpenPencilCLI([
'export',
await writeComponentFixture(dir, [...names], file),
...args
])
expect(setup.exitCode).toBe(0)
}
expect(await Bun.file(join(output, 'Card.stories.ts')).exists()).toBe(true)
expect(await Bun.file(join(output, 'Chip.stories.ts')).exists()).toBe(true)
await Bun.write(join(output, 'Mine.stories.ts'), 'export default {}\n')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
await writeComponentFixture(dir, ['Card', 'Mine']),
...args
])
expect(exitCode).toBe(1)
expect(stderr).toContain('Mine.stories.ts was not generated by this export')
expect(await Bun.file(join(output, 'Mine.stories.ts')).text()).toBe('export default {}\n')
expect(await Bun.file(join(output, 'Card.stories.ts')).exists()).toBe(true)
})
test('export CLI --page replaces that page and keeps the stories of other pages', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const figPath = join(dir, 'library.fig')
const writeDocument = async (second: string) => {
const graph = makeSceneGraph()
graph.createNode('COMPONENT', graph.addPage('One').id, { name: 'Kept', width: 8, height: 8 })
graph.createNode('COMPONENT', graph.addPage('Two').id, { name: second, width: 8, height: 8 })
const result = await io.writeDocument('fig', graph)
await Bun.write(figPath, result.data as Uint8Array)
}
const output = join(dir, 'stories')
const args = ['--format', 'storybook', '--no-design-images', '--output', output]
await writeDocument('Old')
expect((await runOpenPencilCLI(['export', figPath, ...args])).exitCode).toBe(0)
await writeDocument('New')
const { exitCode } = await runOpenPencilCLI(['export', figPath, '--page', 'Two', ...args])
expect(exitCode).toBe(0)
expect(await Bun.file(join(output, 'Kept.stories.ts')).exists()).toBe(true)
expect(await Bun.file(join(output, 'New.stories.ts')).exists()).toBe(true)
})
test('export CLI tells apart documents that share a file name', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const output = join(dir, 'stories')
const args = ['--format', 'storybook', '--no-design-images', '--output', output]
for (const [folder, name] of [
['a', 'Card'],
['b', 'Chip']
]) {
await mkdir(join(dir, folder))
const figPath = await writeComponentFixture(join(dir, folder), [name])
expect((await runOpenPencilCLI(['export', figPath, ...args])).exitCode).toBe(0)
}
expect(await Bun.file(join(output, 'Card.stories.ts')).exists()).toBe(true)
expect(await Bun.file(join(output, 'Chip.stories.ts')).exists()).toBe(true)
})
test('export CLI --page refuses to renumber stories of other pages', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const figPath = join(dir, 'library.fig')
const writeDocument = async (first: string) => {
const graph = makeSceneGraph()
graph.createNode('COMPONENT', graph.addPage('One').id, { name: first, width: 8, height: 8 })
graph.createNode('COMPONENT', graph.addPage('Two').id, { name: 'Card', width: 8, height: 8 })
const result = await io.writeDocument('fig', graph)
await Bun.write(figPath, result.data as Uint8Array)
}
const output = join(dir, 'stories')
const args = ['--format', 'storybook', '--no-design-images', '--output', output]
await writeDocument('Card')
expect((await runOpenPencilCLI(['export', figPath, ...args])).exitCode).toBe(0)
expect(await outputEntries(output)).toEqual(['Card.stories.ts', 'Card2.stories.ts'])
// Renaming page One's Card renumbers page Two's story onto page One's Card.stories.ts.
await writeDocument('Badge')
const pageExport = await runOpenPencilCLI(['export', figPath, '--page', 'Two', ...args])
expect(pageExport.exitCode).toBe(1)
expect(pageExport.stderr).toContain('holds page One of')
expect(await outputEntries(output)).toEqual(['Card.stories.ts', 'Card2.stories.ts'])
expect((await runOpenPencilCLI(['export', figPath, ...args])).exitCode).toBe(0)
expect(await outputEntries(output)).toEqual(['Badge.stories.ts', 'Card.stories.ts'])
})
test('export CLI refuses to overwrite a design image it did not generate', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const figPath = await writeComponentFixture(dir, ['Badge'])
const output = join(dir, 'stories')
await mkdir(join(output, 'Badge.design'), { recursive: true })
await Bun.write(join(output, 'Badge.design/Default.png'), 'mine')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'storybook',
'--font-policy',
'allow',
'--output',
output
])
expect(exitCode).toBe(1)
expect(stderr).toContain('Default.png was not generated by this export')
expect(await Bun.file(join(output, 'Badge.design/Default.png')).text()).toBe('mine')
})
test('export CLI keeps files it did not generate in a design folder', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const figPath = await writeComponentFixture(dir, ['Badge'])
const output = join(dir, 'stories')
const args = ['--format', 'storybook', '--font-policy', 'allow', '--output', output]
expect((await runOpenPencilCLI(['export', figPath, ...args])).exitCode).toBe(0)
await Bun.write(join(output, 'Badge.design/notes.txt'), 'mine')
expect((await runOpenPencilCLI(['export', figPath, ...args])).exitCode).toBe(0)
expect((await readdir(join(output, 'Badge.design'))).sort()).toEqual(['Default.png', 'notes.txt'])
})
test('export CLI removes the design folder of a deleted component', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const output = join(dir, 'stories')
const args = ['--format', 'storybook', '--font-policy', 'allow', '--output', output]
const badge = await writeComponentFixture(dir, ['Badge'])
expect((await runOpenPencilCLI(['export', badge, ...args])).exitCode).toBe(0)
const chip = await writeComponentFixture(dir, ['Chip'])
expect((await runOpenPencilCLI(['export', chip, ...args])).exitCode).toBe(0)
expect(await outputEntries(output)).toEqual(['Chip.design', 'Chip.stories.ts'])
})
test('export CLI refuses a design folder that links outside the output', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const figPath = await writeComponentFixture(dir, ['Badge'])
const output = join(dir, 'stories')
const args = ['--format', 'storybook', '--font-policy', 'allow', '--output', output]
expect((await runOpenPencilCLI(['export', figPath, ...args])).exitCode).toBe(0)
const outside = join(dir, 'outside')
await mkdir(outside)
await Bun.write(join(outside, 'Default.png'), 'keep')
await rm(join(output, 'Badge.design'), { recursive: true })
await symlink(outside, join(output, 'Badge.design'))
const { stderr, exitCode } = await runOpenPencilCLI(['export', figPath, ...args])
expect(exitCode).toBe(1)
expect(stderr).toContain('Badge.design is not a folder')
expect(await Bun.file(join(outside, 'Default.png')).text()).toBe('keep')
expect(await Bun.file(join(output, 'Badge.stories.ts')).exists()).toBe(true)
})
test('export CLI --beside writes the stories of every matched document next to it', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
await mkdir(join(dir, 'button'))
await mkdir(join(dir, 'forms/input'), { recursive: true })
await writeComponentFixture(join(dir, 'button'), ['Button'], 'button.fig')
await writeComponentFixture(join(dir, 'forms/input'), ['Input'], 'input.fig')
const { stdout, stderr, exitCode } = await runOpenPencilCLI([
'export',
join(dir, '**/*.fig'),
'--format',
'storybook',
'--no-design-images',
'--beside'
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
expect(stdout).toContain('Exported 1 story files from')
expect(await outputEntries(join(dir, 'button'))).toEqual(['Button.stories.ts', 'button.fig'])
expect(await outputEntries(join(dir, 'forms/input'))).toEqual(['Input.stories.ts', 'input.fig'])
const manifest: unknown = JSON.parse(await Bun.file(join(dir, 'button', MANIFEST)).text())
expect(manifest).toEqual({
version: 1,
files: { 'Button.stories.ts': { source: 'button.fig', page: 'Library' } }
})
})
test('export CLI expands an extglob pattern', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
await writeComponentFixture(dir, ['Card'], 'card.fig')
await writeComponentFixture(dir, ['Chip'], 'chip.fig')
await writeComponentFixture(dir, ['Draft'], 'draft.fig')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
join(dir, '+(card|chip).fig'),
'--format',
'storybook',
'--no-design-images',
'--beside'
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
const manifest: unknown = JSON.parse(await Bun.file(join(dir, MANIFEST)).text())
expect(manifest).toEqual({
version: 1,
files: {
'Card.stories.ts': { source: 'card.fig', page: 'Library' },
'Chip.stories.ts': { source: 'chip.fig', page: 'Library' }
}
})
})
test('export CLI opens a document whose name contains glob syntax', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const file = await writeComponentFixture(dir, ['Card'], 'card[1].fig')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
file,
'--format',
'storybook',
'--no-design-images',
'--beside'
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
const manifest: unknown = JSON.parse(await Bun.file(join(dir, MANIFEST)).text())
expect(manifest).toEqual({
version: 1,
files: { 'Card.stories.ts': { source: 'card[1].fig', page: 'Library' } }
})
})
test('export CLI --beside records documents that share a folder in one manifest', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const card = await writeComponentFixture(dir, ['Card'], 'card.fig')
const chip = await writeComponentFixture(dir, ['Chip'], 'chip.fig')
// A shell expands an unquoted glob into several arguments.
const { exitCode } = await runOpenPencilCLI([
'export',
card,
chip,
'--format',
'storybook',
'--no-design-images',
'--beside'
])
expect(exitCode).toBe(0)
const manifest: unknown = JSON.parse(await Bun.file(join(dir, MANIFEST)).text())
expect(manifest).toEqual({
version: 1,
files: {
'Card.stories.ts': { source: 'card.fig', page: 'Library' },
'Chip.stories.ts': { source: 'chip.fig', page: 'Library' }
}
})
})
test('export CLI needs an output choice and one document for --page', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
await writeComponentFixture(dir, ['Card'], 'card.fig')
await writeComponentFixture(dir, ['Chip'], 'chip.fig')
const pattern = join(dir, '*.fig')
const storybook = ['--format', 'storybook', '--no-design-images']
const noOutput = await runOpenPencilCLI(['export', pattern, ...storybook])
expect(noOutput.exitCode).toBe(1)
expect(noOutput.stderr).toContain('2 documents need --beside')
const page = await runOpenPencilCLI([
'export',
pattern,
...storybook,
'--beside',
'--page',
'Library'
])
expect(page.exitCode).toBe(1)
expect(page.stderr).toContain('--page names a page of one document')
const none = await runOpenPencilCLI(['export', join(dir, '*.pen'), ...storybook, '--beside'])
expect(none.exitCode).toBe(1)
expect(none.stderr).toContain('No documents match')
expect(await outputEntries(dir)).toEqual(['card.fig', 'chip.fig'])
})
test('export CLI exports the other documents when one fails, then exits 1', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
await writeComponentFixture(dir, ['Card'], 'a.fig')
await writeComponentFixture(dir, [], 'b.fig')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
join(dir, '*.fig'),
'--format',
'storybook',
'--no-design-images',
'--beside'
])
expect(exitCode).toBe(1)
expect(stderr).toContain('b.fig: No components found')
expect(await Bun.file(join(dir, 'Card.stories.ts')).exists()).toBe(true)
})
test('export CLI --watch re-exports stories when the document changes', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const figPath = await writeComponentFixture(dir, ['First'])
const output = join(dir, 'stories')
const proc = Bun.spawn(
[
process.execPath,
cliSourcePath('index.ts'),
'export',
figPath,
'--format',
'storybook',
'--no-design-images',
'--watch',
'--output',
output
],
{ stdout: 'pipe', stderr: 'pipe' }
)
try {
const reader = proc.stdout.getReader()
let seen = ''
const waitFor = async (text: string) => {
while (!seen.includes(text)) {
const { value, done } = await reader.read()
if (done) throw new Error(`CLI exited before printing "${text}": ${seen}`)
seen += new TextDecoder().decode(value)
}
}
await waitFor('Watching')
await writeComponentFixture(dir, ['Second'])
const until = Date.now() + 10_000
while (!(await Bun.file(join(output, 'Second.stories.ts')).exists())) {
if (Date.now() > until) throw new Error(`No re-export after the change: ${seen}`)
await Bun.sleep(50)
}
expect(await Bun.file(join(output, 'First.stories.ts')).exists()).toBe(false)
} finally {
proc.kill()
}
})
test('export CLI rejects Storybook export of a document without components', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const figPath = await writeComponentFixture(dir, [])
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'storybook',
'--output',
join(dir, 'stories')
])
expect(exitCode).toBe(1)
expect(stderr).toContain('No components found in the document.')
})
test('export CLI refuses a manifest that lists files outside the output', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const figPath = await writeComponentFixture(dir, ['Badge'])
const output = join(dir, 'stories')
await Bun.write(join(dir, 'outside.png'), 'keep')
await Bun.write(
join(output, MANIFEST),
JSON.stringify({
version: 1,
files: { '../outside.png': { source: '../library.fig', page: 'Library' } }
})
)
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'storybook',
'--no-design-images',
'--output',
output
])
expect(exitCode).toBe(1)
expect(stderr).toContain('is not a valid OpenPencil stories manifest')
expect(await Bun.file(join(dir, 'outside.png')).text()).toBe('keep')
})
test('export CLI treats stories as foreign once the manifest is gone', async () => {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-'))
const figPath = await writeComponentFixture(dir, ['Badge'])
const output = join(dir, 'stories')
const args = ['--format', 'storybook', '--no-design-images', '--output', output]
expect((await runOpenPencilCLI(['export', figPath, ...args])).exitCode).toBe(0)
await rm(join(output, MANIFEST))
const { stderr, exitCode } = await runOpenPencilCLI(['export', figPath, ...args])
expect(exitCode).toBe(1)
expect(stderr).toContain('Badge.stories.ts was not generated by this export')
})