diff --git a/.circleci/config.yml b/.circleci/config.yml index 52244e08..5448e178 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -84,10 +84,14 @@ commands: command: npm run lint:css test: + parameters: + runInBand: + type: boolean + default: false steps: - run: name: Jest - command: npm run test:coverage -- --ci -i --reporters=default --reporters=jest-junit + command: npm run test:coverage -- --ci --reporters=default --reporters=jest-junit <<# parameters.runInBand >>-i<> environment: JEST_JUNIT_OUTPUT_DIR: tmp/test-results MARP_TEST_CI: 1 @@ -129,7 +133,8 @@ jobs: - prepare: browser: true - lint - - test + - test: + runInBand: true test-node20: executor: @@ -140,7 +145,8 @@ jobs: - prepare: browser: true - lint - - test + - test: + runInBand: true test-node22: executor: diff --git a/.github/workflows/test-win.yml b/.github/workflows/test-win.yml index b0b53ec9..68475e57 100644 --- a/.github/workflows/test-win.yml +++ b/.github/workflows/test-win.yml @@ -24,6 +24,10 @@ jobs: # - name: Output concurrency group # run: echo "${{ github.workflow }}-${{ (github.ref_name == 'main' && github.run_id) || format('{0}-{1}', github.actor, github.head_ref || github.ref_name) }}" + - name: Get number of CPU cores + uses: SimenB/github-actions-cpu-cores@v2 + id: cpu-cores + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} @@ -43,7 +47,8 @@ jobs: env: MARP_TEST_CI: 1 run: >- - npm run test:coverage -- --ci -i --reporters=default --reporters=jest-junit --forceExit || + npm run test:coverage -- --ci --max-workers ${{ steps.cpu-cores.outputs.count }} --reporters=default --reporters=jest-junit || + npm run test:coverage -- --ci -i --reporters=default --reporters=jest-junit --forceExit --no-cache || npm run test:coverage -- --ci -i --reporters=default --reporters=jest-junit --forceExit --no-cache || npm run test:coverage -- --ci -i --reporters=default --reporters=jest-junit --forceExit --no-cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 4718a002..2e9b37a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Introduce parallelism for batch conversion: `--parallel` / `-P` ([#509](https://github.com/marp-team/marp-cli/issues/509), [#628](https://github.com/marp-team/marp-cli/pull/628)) + ### Fixed - Make the preview option stable against occasional invalid URL errors ([#627](https://github.com/marp-team/marp-cli/pull/627)) diff --git a/README.md b/README.md index b865de4c..2bb3b9af 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,10 @@ marp --pdf --allow-local-files slide-deck.md ## Conversion modes +### Parallelism + +When converting multiple files, Marp CLI will process them in parallel with 5 concurrency by default. You can set the number of concurrency by `--parallel` (`-P`) option, or disable parallelism by `--no-parallel`. + ### Watch mode (`--watch` / `-w`) Marp CLI will observe a change of Markdown and using theme CSS when passed with `--watch` (`-w`) option. The conversion will be triggered whenever the content of file is updated. diff --git a/src/browser/browser.ts b/src/browser/browser.ts index 251660cc..f222707f 100644 --- a/src/browser/browser.ts +++ b/src/browser/browser.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' +import { isPromise } from 'node:util/types' import { nanoid } from 'nanoid' import type { Browser as PuppeteerBrowser, @@ -11,6 +12,7 @@ import type { } from 'puppeteer-core' import type TypedEventEmitter from 'typed-emitter' import { debugBrowser } from '../utils/debug' +import { createMemoizedPromiseContext } from '../utils/memoized-promise' import { getWindowsEnv, isWSL, @@ -46,11 +48,11 @@ export abstract class Browser path: string protocolTimeout: number - puppeteer: PuppeteerBrowser | undefined timeout: number #dataDirName: string - private _puppeteerDataDir?: string + private _puppeteerDataDir = createMemoizedPromiseContext() + private _puppeteer = createMemoizedPromiseContext() constructor(opts: BrowserOptions) { super() @@ -70,29 +72,32 @@ export abstract class Browser return (this.constructor as typeof Browser).protocol } - async launch(opts: LaunchOptions = {}): Promise { - if (!this.puppeteer) { + async launch(opts: LaunchOptions = {}) { + return this._puppeteer.init(async () => { + debugBrowser('Launching browser via Puppeteer...') + const puppeteer = await this.launchPuppeteer(opts) puppeteer.once('disconnected', () => { this.emit('disconnect', puppeteer) - this.puppeteer = undefined + this._puppeteer.value = undefined debugBrowser('Browser disconnected (Cleaned up puppeteer instance)') }) - this.puppeteer = puppeteer this.emit('launch', puppeteer) return puppeteer - } - return this.puppeteer + }) } async withPage(fn: (page: Page) => T) { + const debugPageId = nanoid(8) const puppeteer = await this.launch() const page = await puppeteer.newPage() + debugBrowser('Created a new page [%s]', debugPageId) + page.setDefaultTimeout(this.timeout) page.setDefaultNavigationTimeout(this.timeout) @@ -100,19 +105,20 @@ export abstract class Browser return await fn(page) } finally { await page.close() + debugBrowser('Page closed [%s]', debugPageId) } } async close() { - if (this.puppeteer) { - const { puppeteer } = this + const pptr = await this._puppeteer.value - if (puppeteer.connected) { - await puppeteer.close() - this.emit('close', puppeteer) + if (pptr) { + if (pptr.connected) { + await pptr.close() + this.emit('close', pptr) } - this.puppeteer = undefined + this._puppeteer.value = undefined } } @@ -123,7 +129,15 @@ export abstract class Browser async browserInWSLHost(): Promise { return ( !!(await isWSL()) && - wslHostMatcher.test(this.puppeteer?.process()?.spawnfile ?? this.path) + wslHostMatcher.test( + // This function may be called while launching Puppeteer. If the browser + // value awaited, Marp CLI will bring deadlock. So we should check the + // value is already resolved (non-Promise truthy value) or not (Promise + // or undefined). + (this._puppeteer.value && !isPromise(this._puppeteer.value) + ? this._puppeteer.value.process()?.spawnfile + : null) ?? this.path + ) ) } @@ -160,10 +174,10 @@ export abstract class Browser /** @internal */ protected async puppeteerDataDir() { - if (this._puppeteerDataDir === undefined) { + return this._puppeteerDataDir.init(async () => { let needToTranslateWindowsPathToWSL = false - this._puppeteerDataDir = await (async () => { + const dir = await (async () => { // In WSL environment, Marp CLI may use Chrome on Windows. If Chrome has // located in host OS (Windows), we have to specify Windows path. if (await this.browserInWSLHost()) { @@ -176,16 +190,15 @@ export abstract class Browser return path.resolve(os.tmpdir(), this.#dataDirName) })() - debugBrowser(`Chrome data directory: %s`, this._puppeteerDataDir) - // Ensure the data directory is created const mkdirPath = needToTranslateWindowsPathToWSL - ? await translateWindowsPathToWSL(this._puppeteerDataDir) - : this._puppeteerDataDir + ? await translateWindowsPathToWSL(dir) + : dir await fs.promises.mkdir(mkdirPath, { recursive: true }) debugBrowser(`Created data directory: %s`, mkdirPath) - } - return this._puppeteerDataDir + + return dir + }) } } diff --git a/src/browser/manager.ts b/src/browser/manager.ts index da806691..aa94d0de 100644 --- a/src/browser/manager.ts +++ b/src/browser/manager.ts @@ -1,5 +1,6 @@ import { error } from '../error' import { debugBrowser } from '../utils/debug' +import { createMemoizedPromiseContext } from '../utils/memoized-promise' import type { Browser, BrowserProtocol } from './browser' import { ChromeCdpBrowser } from './browsers/chrome-cdp' import { defaultFinders, findBrowser } from './finder' @@ -23,12 +24,12 @@ export class BrowserManager implements AsyncDisposable { // Finder private _finders: readonly FinderName[] = defaultFinders private _finderPreferredPath?: string - private _finderResult?: BrowserFinderResult + private _finderResult = createMemoizedPromiseContext() // Browser - private _conversionBrowser?: Browser + private _conversionBrowser = createMemoizedPromiseContext() private _preferredProtocol: BrowserProtocol = 'webDriverBiDi' - private _previewBrowser?: ChromeCdpBrowser + private _previewBrowser = createMemoizedPromiseContext() private _timeout?: number constructor(config: BrowserManagerConfig = {}) { @@ -42,11 +43,11 @@ export class BrowserManager implements AsyncDisposable { configure(config: BrowserManagerConfig) { if (config.finders) { this._finders = ([] as FinderName[]).concat(config.finders) - this._finderResult = undefined // Reset finder result cache + this._finderResult.value = undefined // Reset finder result cache } if (config.path !== undefined) { this._finderPreferredPath = config.path - this._finderResult = undefined // Reset finder result cache + this._finderResult.value = undefined // Reset finder result cache } if (config.protocol) { if (this._conversionBrowser) @@ -62,14 +63,14 @@ export class BrowserManager implements AsyncDisposable { } async findBrowser() { - return (this._finderResult ??= await findBrowser(this._finders, { - preferredPath: this._finderPreferredPath, - })) + return this._finderResult.init(() => + findBrowser(this._finders, { preferredPath: this._finderPreferredPath }) + ) } // Browser for converter async browserForConversion(): Promise { - if (!this._conversionBrowser) { + return this._conversionBrowser.init(async () => { const { acceptedBrowsers, path } = await this.findBrowser() const browser = @@ -90,14 +91,13 @@ export class BrowserManager implements AsyncDisposable { debugBrowser('Use browser class for conversion: %o', browser) // @ts-expect-error ts2511: TS cannot create an instance of an abstract class - this._conversionBrowser = new browser({ path, timeout: this.timeout }) - } - return this._conversionBrowser! + return new browser({ path, timeout: this.timeout }) + }) } // Browser for preview window async browserForPreview(): Promise { - if (!this._previewBrowser) { + return this._previewBrowser.init(async () => { const { acceptedBrowsers, path } = await this.findBrowser() if (!acceptedBrowsers.some((browser) => browser === ChromeCdpBrowser)) { @@ -105,18 +105,20 @@ export class BrowserManager implements AsyncDisposable { } debugBrowser('Use browser class for preview: %o', ChromeCdpBrowser) - this._previewBrowser = new ChromeCdpBrowser({ - path, - timeout: this.timeout, - }) - } - return this._previewBrowser + return new ChromeCdpBrowser({ path, timeout: this.timeout }) + }) } async dispose() { await Promise.all([ - this._conversionBrowser?.close(), - this._previewBrowser?.close(), + (async () => { + await (await this._conversionBrowser.value)?.close() + this._conversionBrowser.value = undefined + })(), + (async () => { + await (await this._previewBrowser.value)?.close() + this._previewBrowser.value = undefined + })(), ]) } diff --git a/src/config.ts b/src/config.ts index 09acb3b5..13978ab6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -43,6 +43,7 @@ interface IMarpCLIArguments { notes?: boolean ogImage?: string output?: string | false + parallel?: number | boolean pdf?: boolean pdfNotes?: boolean pdfOutlines?: boolean @@ -94,6 +95,8 @@ export type IMarpCLIConfig = Overwrite< } > +export const DEFAULT_PARALLEL = 5 + export class MarpCLIConfig { args: IMarpCLIArguments = {} conf: IMarpCLIConfig = {} @@ -332,10 +335,27 @@ export class MarpCLIConfig { return scale })() + const parallel = (() => { + const parseParallel = (value: boolean | number | undefined) => { + if (value === true) return DEFAULT_PARALLEL + if (value === false) return 1 + if (typeof value === 'number') return Math.max(1, value) + + return undefined + } + + return ( + parseParallel(this.args.parallel) ?? + parseParallel(this.conf.parallel) ?? + DEFAULT_PARALLEL + ) + })() + return { imageScale, inputDir, output, + parallel, pdfNotes, pdfOutlines, preview, diff --git a/src/converter.ts b/src/converter.ts index c7b78069..8436692b 100644 --- a/src/converter.ts +++ b/src/converter.ts @@ -74,6 +74,7 @@ export interface ConverterOption { options: MarpitOptions output?: string | false pages?: boolean | number[] + parallel?: number pdfNotes?: boolean pdfOutlines?: | false @@ -211,6 +212,8 @@ export class Converter { } if (!opts.onlyScanning) { + debug('Converting %s ...', file.relativePath()) + const files: File[] = [] switch (this.options.type) { case ConvertType.pdf: @@ -267,7 +270,24 @@ export class Converter { if (!inputDir && output && output !== '-' && files.length > 1) error('Output path cannot specify with processing multiple files.') - for (const file of files) await this.convertFile(file, opts) + const parallel = Math.max(1, this.options.parallel ?? 1) + const queue = [...files] + + const workers = Array.from({ length: parallel }, async (_, i) => { + debug(`[Worker ${i + 1}] Start processing ...`) + + let file: File | undefined + + while ((file = queue.shift())) { + debug(`[Worker ${i + 1}] Processing ${file.absolutePath} ...`) + await this.convertFile(file, opts) + } + + debug(`[Worker ${i + 1}] Finish processing.`) + }) + + await Promise.all(workers) + debug(`Batch processing has been completed.`) } private convertFileToHTML(tpl: TemplateResult, file: File): File { diff --git a/src/marp-cli.ts b/src/marp-cli.ts index 61da1c92..1ef4b1c4 100644 --- a/src/marp-cli.ts +++ b/src/marp-cli.ts @@ -2,7 +2,7 @@ import chalk from 'chalk' import { availableFinders } from './browser/finder' import { BrowserManager } from './browser/manager' import * as cli from './cli' -import { fromArguments } from './config' +import { DEFAULT_PARALLEL, fromArguments } from './config' import { Converter, ConvertedCallback, ConvertType } from './converter' import { CLIError, CLIErrorCode, error, isError } from './error' import { File, FileType } from './file' @@ -172,6 +172,18 @@ export const marpCli = async ( describe: 'Prevent looking up for a configuration file', group: OptionGroup.Basic, }, + parallel: { + alias: ['P'], + defaultDescription: DEFAULT_PARALLEL.toString(), + describe: 'Number of max parallel processes for multiple conversions', + group: OptionGroup.Basic, + type: 'number', + }, + 'no-parallel': { + describe: 'Disable parallel processing', + group: OptionGroup.Basic, + type: 'boolean', + }, watch: { alias: 'w', describe: 'Watch input markdowns for changes', @@ -472,7 +484,12 @@ export const marpCli = async ( if (cvtOpts.server) { await converter.convertFiles(foundFiles, { onlyScanning: true }) } else { - cli.info(`Converting ${length} markdown${length > 1 ? 's' : ''}...`) + const isParallel = Math.min(converter.options.parallel ?? 1, length) > 1 + + cli.info( + `Converting ${length} markdown${length > 1 ? 's' : ''}...${isParallel ? ` (Parallelism: up to ${converter.options.parallel} workers)` : ''}` + ) + await converter.convertFiles(foundFiles, { onConverted }) } } catch (e: unknown) { @@ -551,7 +568,6 @@ export const marpCli = async ( })().catch(rej) ) } - return 0 } catch (e: unknown) { if (throwErrorAlways || !(e instanceof CLIError)) throw e diff --git a/src/utils/memoized-promise.ts b/src/utils/memoized-promise.ts new file mode 100644 index 00000000..ee82a585 --- /dev/null +++ b/src/utils/memoized-promise.ts @@ -0,0 +1,20 @@ +type MemoizedPromiseAllowedValue = NonNullable | null + +export interface MemoizedPromiseContext { + value: Promise | T | undefined + init: (initializer: () => T | Promise) => Promise +} + +export const createMemoizedPromiseContext = < + T extends MemoizedPromiseAllowedValue, +>(): MemoizedPromiseContext => { + const ctx: MemoizedPromiseContext = { + value: undefined, + init: async (initializer) => + await (ctx.value ??= Promise.resolve(initializer()).then( + (v) => (ctx.value = v) + )), + } + + return ctx +} diff --git a/src/utils/wsl.ts b/src/utils/wsl.ts index 676b3268..10f046fa 100644 --- a/src/utils/wsl.ts +++ b/src/utils/wsl.ts @@ -2,6 +2,7 @@ import { execFile as cpExecFile } from 'node:child_process' import fs from 'node:fs' import { promisify } from 'node:util' import { debug } from './debug' +import { createMemoizedPromiseContext } from './memoized-promise' const execFile = promisify(cpExecFile) const resolveStdout = ({ stdout }: { stdout: string }) => stdout.trim() @@ -27,67 +28,52 @@ export const getWindowsEnv = async (envName: string) => { type WSL2NetworkingMode = 'nat' | 'mirrored' -let wslNetworkingMode: - | WSL2NetworkingMode - | null - | Promise - | undefined +const wslNetworkingMode = + createMemoizedPromiseContext() -export const getWSL2NetworkingMode = async () => { - if (wslNetworkingMode === undefined) { - wslNetworkingMode = (async () => { - if ((await isWSL()) !== 2) return null - try { - return ( - await execFile('wslinfo', ['--networking-mode']).then(resolveStdout) - ).toLowerCase() as WSL2NetworkingMode - } catch (e) { - debug('Error while detecting WSL networking mode: %o', e) - return 'nat' // Default - } - })().then( - (correctedWSLNetworkingMode) => - (wslNetworkingMode = correctedWSLNetworkingMode) - ) - } - return await wslNetworkingMode -} +export const getWSL2NetworkingMode = () => + wslNetworkingMode.init(async () => { + if ((await isWSL()) !== 2) return null + + try { + return ( + await execFile('wslinfo', ['--networking-mode']).then(resolveStdout) + ).toLowerCase() as WSL2NetworkingMode + } catch (e) { + debug('Error while detecting WSL networking mode: %o', e) + return 'nat' // Default + } + }) -let isWsl: number | Promise | undefined +const isWsl = createMemoizedPromiseContext() const wsl2VerMatcher = /microsoft-standard-wsl2/i -export const isWSL = async (): Promise => { - if (isWsl === undefined) { - isWsl = (async () => { - if ((await import('is-wsl')).default) { - // Detect whether WSL version is 2 - // https://github.com/microsoft/WSL/issues/4555#issuecomment-700213318 - const isWSL2 = await (async () => { - if (process.env.WSL_DISTRO_NAME && process.env.WSL_INTEROP) - return true +export const isWSL = () => + isWsl.init(async () => { + if ((await import('is-wsl')).default) { + // Detect whether WSL version is 2 + // https://github.com/microsoft/WSL/issues/4555#issuecomment-700213318 + const isWSL2 = await (async () => { + if (process.env.WSL_DISTRO_NAME && process.env.WSL_INTEROP) return true - try { - const verStr = await fs.promises.readFile('/proc/version', 'utf8') - if (wsl2VerMatcher.test(verStr)) return true + try { + const verStr = await fs.promises.readFile('/proc/version', 'utf8') + if (wsl2VerMatcher.test(verStr)) return true - const gccMatched = verStr.match(/gcc[^,]+?(\d+)\.\d+\.\d+/) - if (gccMatched && Number.parseInt(gccMatched[1], 10) >= 8) - return true - } catch (e) { - debug('Error while detecting WSL version: %o', e) - debug('Assuming current WSL version is the primary version 2') - return true - } - })() + const gccMatched = verStr.match(/gcc[^,]+?(\d+)\.\d+\.\d+/) + if (gccMatched && Number.parseInt(gccMatched[1], 10) >= 8) return true + } catch (e) { + debug('Error while detecting WSL version: %o', e) + debug('Assuming current WSL version is the primary version 2') + return true + } + })() - const wslVersion = isWSL2 ? 2 : 1 - debug('Detected WSL version: %s', wslVersion) + const wslVersion = isWSL2 ? 2 : 1 + debug('Detected WSL version: %s', wslVersion) - return wslVersion - } else { - return 0 - } - })().then((correctedIsWsl) => (isWsl = correctedIsWsl)) - } - return await isWsl -} + return wslVersion + } else { + return 0 + } + }) diff --git a/test/converter.ts b/test/converter.ts index e2cd3301..ef909cff 100644 --- a/test/converter.ts +++ b/test/converter.ts @@ -85,12 +85,13 @@ describe('Converter', () => { imageScale: 2, lang: 'fr', options: { html: true } as Options, + parallel: 3, server: false, template: 'test-template', templateOption: {}, type: ConvertType.html, watch: false, - } + } as const satisfies ConverterOption expect(new Converter(options).options).toMatchObject(options) }) @@ -1386,17 +1387,50 @@ describe('Converter', () => { }) describe('#convertFiles', () => { + const originalConvertFile = Converter.prototype.convertFile + const convertFileCallTimes: number[] = [] + + let convertFileSpy: jest.SpiedFunction + + beforeEach(() => { + convertFileCallTimes.splice(0, convertFileCallTimes.length) + + convertFileSpy = jest + .spyOn(Converter.prototype, 'convertFile') + .mockImplementation(function (this: Converter, ...args) { + convertFileCallTimes.push(Date.now()) + return originalConvertFile.apply(this, args) + }) + }) + describe('with multiple files', () => { - it('converts passed files', async () => { - await instance().convertFiles([new File(onePath), new File(twoPath)]) - expect(fs.promises.writeFile).toHaveBeenCalledTimes(2) - expect(writeFileSpy.mock.calls[0][0]).toBe( - `${onePath.slice(0, -3)}.html` - ) - expect(writeFileSpy.mock.calls[1][0]).toBe( - `${twoPath.slice(0, -6)}.html` - ) - }) + it( + 'converts passed files', + async () => { + await instance({ type: ConvertType.pdf }).convertFiles([ + new File(onePath), + new File(twoPath), + ]) + expect(fs.promises.writeFile).toHaveBeenCalledTimes(2) + + // Check sequential conversion + expect(convertFileSpy).toHaveBeenCalledTimes(2) + expect(convertFileCallTimes).toHaveLength(2) + expect( + Math.abs(convertFileCallTimes[1] - convertFileCallTimes[0]) + ).toBeGreaterThanOrEqual(300) + + const files = writeFileSpy.mock.calls.map(([fn]) => fn) + expect(files).toHaveLength(2) + expect(files).toStrictEqual( + expect.arrayContaining([ + `${onePath.slice(0, -3)}.pdf`, + `${twoPath.slice(0, -6)}.pdf`, + ]) + ) + }, + timeout + ) it('throws CLIError when output is defined', () => expect( @@ -1417,6 +1451,34 @@ describe('Converter', () => { expect(fs.promises.writeFile).not.toHaveBeenCalled() expect(stdout).toHaveBeenCalledTimes(2) }) + + describe('with parallel option', () => { + it( + 'converts passed files in parallel with the number of specified workers', + async () => { + await instance({ parallel: 2, type: ConvertType.pdf }).convertFiles( + [new File(onePath), new File(twoPath)] + ) + + // Check parallelism + expect(convertFileSpy).toHaveBeenCalledTimes(2) + expect(convertFileCallTimes).toHaveLength(2) + expect( + Math.abs(convertFileCallTimes[1] - convertFileCallTimes[0]) + ).toBeLessThan(300) + + const files = writeFileSpy.mock.calls.map(([fn]) => fn) + expect(files).toHaveLength(2) + expect(files).toStrictEqual( + expect.arrayContaining([ + `${onePath.slice(0, -3)}.pdf`, + `${twoPath.slice(0, -6)}.pdf`, + ]) + ) + }, + timeout + ) + }) }) }) }) diff --git a/test/marp-cli.ts b/test/marp-cli.ts index 94f7ec97..cde7b4da 100644 --- a/test/marp-cli.ts +++ b/test/marp-cli.ts @@ -1141,6 +1141,42 @@ describe('Marp CLI', () => { }) }) + for (const opt of ['--parallel', '-P']) { + describe(`with ${opt} option`, () => { + it('converts files in parallel with specified concurrency', async () => { + expect((await conversion(opt, '2', onePath)).options.parallel).toBe(2) + }) + + it('converts files in parallel with 5 concurrency if set as true', async () => { + expect((await conversion(onePath, opt)).options.parallel).toBe(5) + }) + + it('converts files in serial if set as 1', async () => { + expect((await conversion(opt, '1', onePath)).options.parallel).toBe(1) + }) + + it('converts files in serial if set invalid value', async () => { + expect((await conversion(opt, '-1', onePath)).options.parallel).toBe( + 1 + ) + }) + }) + } + + describe('without parallel option', () => { + it('converts files in parallel with 5 concurrency', async () => { + expect((await conversion(onePath)).options.parallel).toBe(5) + }) + }) + + describe('with --no-parallel option', () => { + it('converts files in serial', async () => { + expect( + (await conversion('--no-parallel', onePath)).options.parallel + ).toBe(1) + }) + }) + describe('with -o option', () => { it('converts file and output to stdout when -o is "-"', async () => { const stdout = jest.spyOn(process.stdout, 'write').mockImplementation()