From 2fad42a929247a9885e98d67c36fe6d30e36e53b Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Wed, 22 May 2024 11:24:05 -0600 Subject: [PATCH] fix: preserve quotes in inputs to runCommand --- src/index.ts | 18 +++++- test/run-command.test.ts | 115 ++++++++++++++++++++++++++++++++++----- 2 files changed, 118 insertions(+), 15 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9f33fb0..6cbb930 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,22 @@ function makeLoadOptions(loadOpts?: Interfaces.LoadOptions): Interfaces.LoadOpti return loadOpts ?? {root: findRoot()} } +/** + * Split a string into an array of strings, preserving quoted substrings + * + * @example + * splitString('foo bar --name "foo"') // ['foo bar', '--name', 'foo'] + * splitString('foo bar --name "foo bar"') // ['foo bar', '--name', 'foo bar'] + * splitString('foo bar --name="foo bar"') // ['foo bar', '--name=foo bar'] + * splitString('foo bar --name=foo bar') // ['foo bar', '--name=foo', 'bar'] + * + * @param str input string + * @returns array of strings with quotes removed + */ +function splitString(str: string): string[] { + return (str.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []).map((s) => s.replaceAll(/^"|"$|(?<==)"/g, '')) +} + /** * Capture the stderr and stdout output of a function * @param fn async function to run @@ -138,7 +154,7 @@ export async function runCommand( captureOpts?: CaptureOptions, ): Promise> { const loadOptions = makeLoadOptions(loadOpts) - const argsArray = (Array.isArray(args) ? args : [args]).join(' ').split(' ') + const argsArray = splitString((Array.isArray(args) ? args : [args]).join(' ')) const [id, ...rest] = argsArray const finalArgs = id === '.' ? rest : argsArray diff --git a/test/run-command.test.ts b/test/run-command.test.ts index 5040ee7..334884e 100644 --- a/test/run-command.test.ts +++ b/test/run-command.test.ts @@ -26,12 +26,6 @@ describe('runCommand', () => { expect(result?.name).to.equal('foo') }) - it('should handle single string', async () => { - const {result, stdout} = await runCommand<{name: string}>('foo:bar --name=foo', {root}) - expect(stdout).to.equal('hello foo!\n') - expect(result?.name).to.equal('foo') - }) - it('should handle expected exit codes', async () => { const {error, stdout} = await runCommand(['exit', '--code=101'], {root}) expect(stdout).to.equal('exiting with code 101\n') @@ -50,15 +44,108 @@ describe('runCommand', () => { const {stdout} = await runCommand(['--help']) expect(stdout).to.include('$ @oclif/test [COMMAND]') }) -}) -describe('single command cli', () => { - // eslint-disable-next-line unicorn/prefer-module - const root = join(__dirname, 'fixtures/single') + describe('single command cli', () => { + // eslint-disable-next-line unicorn/prefer-module + const root = join(__dirname, 'fixtures/single') - it('should run a single command cli', async () => { - const {result, stdout} = await runCommand<{name: string}>(['.'], {root}) - expect(stdout).to.equal('hello world!\n') - expect(result?.name).to.equal('world') + it('should run a single command cli', async () => { + const {result, stdout} = await runCommand<{name: string}>(['.'], {root}) + expect(stdout).to.equal('hello world!\n') + expect(result?.name).to.equal('world') + }) + }) + + const cases = [ + { + description: 'should handle single string', + expected: 'foo', + input: 'foo%sbar --name foo', + }, + { + description: 'should handle an array of strings', + expected: 'foo', + input: ['foo%sbar', '--name', 'foo'], + }, + { + description: 'should handle a string with =', + expected: 'foo', + input: 'foo%sbar --name=foo', + }, + { + description: 'should handle an array of strings with =', + expected: 'foo', + input: ['foo%sbar', '--name=foo'], + }, + { + description: 'should handle a string with quotes', + expected: 'foo', + input: 'foo%sbar --name "foo"', + }, + { + description: 'should handle an array of strings with quotes', + expected: 'foo', + input: ['foo%sbar', '--name', '"foo"'], + }, + { + description: 'should handle a string with quotes and with =', + expected: 'foo', + input: 'foo%sbar --name="foo"', + }, + { + description: 'should handle an array of strings with quotes and with =', + expected: 'foo', + input: ['foo%sbar', '--name="foo"'], + }, + { + description: 'should handle a string with spaces in quotes', + expected: 'foo bar', + input: 'foo%sbar --name "foo bar"', + }, + { + description: 'should handle an array of strings with spaces in quotes', + expected: 'foo bar', + input: ['foo%sbar', '--name', '"foo bar"'], + }, + { + description: 'should handle a string with spaces in quotes and with =', + expected: 'foo bar', + input: 'foo%sbar --name="foo bar"', + }, + { + description: 'should handle an array of strings with spaces in quotes and with =', + expected: 'foo bar', + input: ['foo%sbar', '--name="foo bar"'], + }, + ] + + const makeTestCases = (separator: string) => + cases.map(({description, expected, input}) => ({ + description: description.replace('%s', separator), + expected, + input: Array.isArray(input) ? input.map((i) => i.replace('%s', separator)) : input.replace('%s', separator), + })) + + describe('arg input (colon separator)', () => { + const testCases = makeTestCases(':') + + for (const {description, expected, input} of testCases) { + it(description, async () => { + const {result, stdout} = await runCommand<{name: string}>(input, {root}) + expect(stdout).to.equal(`hello ${expected}!\n`) + expect(result?.name).to.equal(expected) + }) + } + }) + + describe('arg input (space separator)', () => { + const testCases = makeTestCases(' ') + for (const {description, expected, input} of testCases) { + it(description, async () => { + const {result, stdout} = await runCommand<{name: string}>(input, {root}) + expect(stdout).to.equal(`hello ${expected}!\n`) + expect(result?.name).to.equal(expected) + }) + } }) })