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 { 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') })