diff --git a/packages/slidev/node/commands/export.test.ts b/packages/slidev/node/commands/export.test.ts new file mode 100644 index 0000000000..3b1f96327c --- /dev/null +++ b/packages/slidev/node/commands/export.test.ts @@ -0,0 +1,65 @@ +import fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { exportSlides } from './export' + +/** + * `exportSlides` writes to `--output` through several writers, and they used + * to disagree about whose job it was to create the parent directory: + * `page.pdf({ path })` creates it, `genPagePng` creates it, and everything + * calling `fs.writeFile` directly did not. `dist/slides.pptx` then failed + * with ENOENT while one-piece PDF wrote the same path happily. + * + * A real export needs a browser and a dev server, neither of which belongs in + * a unit test, and it never gets that far here: `importPlaywright` asks + * `getRoots` for the user root, which throws without an entry, so every call + * below rejects before anything renders. That is enough, because the + * directory is created first. The matcher pins each rejection to that known + * failure, so an error from the `mkdir` itself fails the test instead of + * satisfying it. + */ + +const NO_ENTRY = /Cannot find roots/ + +const tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))) +}) + +async function tempRoot() { + const root = await fs.mkdtemp(join(tmpdir(), 'slidev-export-')) + tempRoots.push(root) + return root +} + +function exportInto(output: string, format: 'pdf' | 'pptx' | 'pptx-editable') { + return exportSlides({ output, format, slides: [], total: 0, waitUntil: undefined }) +} + +describe('exportSlides creates the output directory', () => { + it.each([ + ['pptx', 'slides.pptx'], + ['pptx-editable', 'slides.pptx'], + ['pdf', 'slides.pdf'], + ] as const)('creates a missing directory for %s', async (format, filename) => { + const output = join(await tempRoot(), 'dist', filename) + + await expect(exportInto(output, format)).rejects.toThrow(NO_ENTRY) + await expect(fs.stat(dirname(output))).resolves.toBeTruthy() + }) + + it('creates nested directories', async () => { + const output = join(await tempRoot(), 'a', 'b', 'c', 'slides.pptx') + + await expect(exportInto(output, 'pptx')).rejects.toThrow(NO_ENTRY) + await expect(fs.stat(dirname(output))).resolves.toBeTruthy() + }) + + it('accepts an output with no directory part', async () => { + // Resolves to the working directory, which exists, so the rejection has + // to come from the missing entry rather than from the `mkdir`. + await expect(exportInto('slides.pptx', 'pptx')).rejects.toThrow(NO_ENTRY) + }) +}) diff --git a/packages/slidev/node/commands/export.ts b/packages/slidev/node/commands/export.ts index 9c3bf061bb..450d7fffa0 100644 --- a/packages/slidev/node/commands/export.ts +++ b/packages/slidev/node/commands/export.ts @@ -191,6 +191,12 @@ export async function exportSlides({ }: ExportOptions) { const pages: number[] = parseRangeString(total, range) + // Writers disagree about who creates the parent directory of `--output`, so + // the ones calling `fs.writeFile` directly failed with ENOENT on a path like + // `dist/slides.pptx`. Create it here, before the browser starts. `png` gets + // only the parent: its `--output` is a directory that `genPagePng` creates. + await fs.mkdir(dirname(path.resolve(output)), { recursive: true }) + const { chromium } = await importPlaywright() const browser = await chromium.launch({ executablePath,