diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a023591..a768beb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,9 +10,9 @@ jobs: fail-fast: false matrix: node-version: + - 21 - 20 - 18 - - 16 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/estest/index.js b/estest/index.js deleted file mode 100644 index 3f62756..0000000 --- a/estest/index.js +++ /dev/null @@ -1,24 +0,0 @@ -import meow from '../source/index.js'; - -meow( - ` - Usage - $ estest - - Options - --rainbow, -r Include a rainbow - - Examples - $ estest unicorns --rainbow - 🌈 unicorns 🌈 - `, - { - importMeta: import.meta, - flags: { - rainbow: { - type: 'boolean', - shortFlag: 'r', - }, - }, - }, -); diff --git a/estest/package.json b/estest/package.json deleted file mode 100644 index eedaaed..0000000 --- a/estest/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "estest", - "type": "module", - "version": "1.2.3" -} diff --git a/package.json b/package.json index f1a83ae..f000643 100644 --- a/package.json +++ b/package.json @@ -64,28 +64,29 @@ "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.2.3", "@types/minimist": "^1.2.5", - "ava": "^6.0.1", - "camelcase-keys": "^9.1.2", + "ava": "^6.1.1", + "camelcase-keys": "^9.1.3", "common-tags": "^2.0.0-alpha.1", "decamelize": "^6.0.0", "decamelize-keys": "^2.0.1", "delete_comments": "^0.0.2", "execa": "^8.0.1", - "globby": "^14.0.0", + "globby": "^14.0.1", "indent-string": "^5.0.0", "minimist-options": "4.1.0", "normalize-package-data": "^6.0.0", "read-package-up": "^11.0.0", "read-pkg": "^9.0.1", "redent": "^4.0.0", - "rollup": "^4.9.2", + "rollup": "^4.12.0", "rollup-plugin-dts": "^6.1.0", "rollup-plugin-license": "^3.2.0", + "stack-utils": "^2.0.6", "trim-newlines": "^5.0.0", - "tsd": "^0.30.3", - "type-fest": "^4.9.0", - "typescript": "^5.3.3", - "xo": "^0.56.0", + "tsd": "^0.30.7", + "type-fest": "^4.10.3", + "typescript": "~5.3.3", + "xo": "^0.57.0", "yargs-parser": "^21.1.1" }, "xo": { @@ -96,10 +97,5 @@ "ignores": [ "build" ] - }, - "ava": { - "files": [ - "test/*" - ] } } diff --git a/source/index.js b/source/index.js index bd048c3..7881e24 100644 --- a/source/index.js +++ b/source/index.js @@ -25,10 +25,7 @@ const buildResult = (options, parserOptions) => { normalizePackageData(package_); - let {description} = options; - if (!description && description !== false) { - ({description} = package_); - } + let description = options.description ?? package_.description; description &&= help ? redent(`\n${description}\n`, options.helpIndent) : `\n${description}`; help = `${description || ''}${help}\n`; diff --git a/source/parser.js b/source/parser.js index c025581..6685c3e 100644 --- a/source/parser.js +++ b/source/parser.js @@ -23,7 +23,7 @@ const buildParserFlags = ({flags, booleanDefault}) => { if (flag.isMultiple) { flag.type = flag.type ? `${flag.type}-array` : 'array'; - flag.default = flag.default ?? []; + flag.default ??= []; delete flag.isMultiple; } diff --git a/test/_utils.js b/test/_utils.js index f09a405..31cec2b 100644 --- a/test/_utils.js +++ b/test/_utils.js @@ -1,24 +1,70 @@ -import path from 'node:path'; +/* eslint-disable ava/no-ignored-test-files */ import {fileURLToPath} from 'node:url'; +import test from 'ava'; import {execa} from 'execa'; +import {readPackage} from 'read-pkg'; import {createTag, stripIndentTransformer, trimResultTransformer} from 'common-tags'; +import StackUtils from 'stack-utils'; -export const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const defaultFixture = 'fixture.js'; -const getFixture = fixture => path.join(__dirname, 'fixtures', fixture); +const getFixture = fixture => fileURLToPath(new URL(`fixtures/${fixture}`, import.meta.url)); -export const spawnFixture = async (fixture = 'fixture.js', args = []) => { - // Allow calling with args first +export const spawnFixture = async (fixture = defaultFixture, arguments_ = [], options = {}) => { + // Allow calling with arguments first if (Array.isArray(fixture)) { - args = fixture; - fixture = 'fixture.js'; + arguments_ = fixture; + fixture = defaultFixture; } - return execa(getFixture(fixture), args); + return execa(getFixture(fixture), arguments_, options); }; +export {stripIndent} from 'common-tags'; + // Use old behavior prior to zspecza/common-tags#165 -export const stripIndent = createTag( +export const stripIndentTrim = createTag( stripIndentTransformer(), trimResultTransformer(), ); + +export const meowPackage = await readPackage(); +export const meowVersion = meowPackage.version; + +const stackUtils = new StackUtils(); + +export const stackToErrorMessage = stack => stackUtils.clean(stack).split('\n').at(0); + +export const _verifyCli = (baseFixture = defaultFixture) => test.macro( + async (t, {fixture = baseFixture, args, execaOptions, expected, error}) => { + const assertions = await t.try(async tt => { + const arguments_ = args ? args.split(' ') : []; + const {all: output, exitCode} = await spawnFixture(fixture, arguments_, {reject: false, all: true, ...execaOptions}); + tt.log('args:', arguments_); + + if (error) { + tt.log(`error (code ${exitCode}):\n`, output); + + if (typeof error === 'string') { + tt.is(output, error); + tt.is(exitCode, 2); + } else { + const error_ = error.clean ? stackToErrorMessage(output) : output; + + tt.is(error_, error.message); + tt.is(exitCode, error.code); + } + } else { + tt.log('output:\n', output); + + if (expected) { + tt.is(output, expected); + } else { + tt.pass(); + } + } + }); + + assertions.commit({retainLogs: !assertions.passed}); + }, +); diff --git a/test/allow-unknown-flags.js b/test/allow-unknown-flags.js deleted file mode 100644 index f4449bc..0000000 --- a/test/allow-unknown-flags.js +++ /dev/null @@ -1,68 +0,0 @@ -import test from 'ava'; -import indentString from 'indent-string'; -import {readPackage} from 'read-pkg'; -import {spawnFixture} from './_utils.js'; - -const fixtureFolder = 'allow-unknown-flags'; - -const allowUnknownFlags = `${fixtureFolder}/fixture.js`; -const allowUnknownFlagsWithHelp = `${fixtureFolder}/fixture-with-help.js`; - -test('spawn CLI and test specifying unknown flags', async t => { - const {stderr} = await t.throwsAsync( - spawnFixture(allowUnknownFlags, ['--foo', 'bar', '--unspecified-a', '--unspecified-b', 'input-is-allowed']), - {message: /^Command failed with exit code 2/}, - ); - - t.regex(stderr, /Unknown flags/); - t.regex(stderr, /--unspecified-a/); - t.regex(stderr, /--unspecified-b/); - t.notRegex(stderr, /input-is-allowed/); -}); - -test('spawn CLI and test specifying known flags', async t => { - const {stdout} = await spawnFixture(allowUnknownFlags, ['--foo', 'bar']); - t.is(stdout, 'bar'); -}); - -test('spawn CLI and test help as a known flag', async t => { - const {stdout} = await spawnFixture(allowUnknownFlags, ['--help']); - t.is(stdout, indentString('\nCustom description\n\nUsage\n foo \n\n', 2)); -}); - -test('spawn CLI and test version as a known flag', async t => { - const pkg = await readPackage(); - const {stdout} = await spawnFixture(allowUnknownFlags, ['--version']); - t.is(stdout, pkg.version); -}); - -test('spawn CLI and test help as an unknown flag', async t => { - const {stderr} = await t.throwsAsync( - spawnFixture(allowUnknownFlags, ['--help', '--no-auto-help']), - {message: /^Command failed with exit code 2/}, - ); - - t.regex(stderr, /Unknown flag/); - t.regex(stderr, /--help/); -}); - -test('spawn CLI and test version as an unknown flag', async t => { - const {stderr} = await t.throwsAsync( - spawnFixture(allowUnknownFlags, ['--version', '--no-auto-version']), - {message: /^Command failed with exit code 2/}, - ); - - t.regex(stderr, /Unknown flag/); - t.regex(stderr, /--version/); -}); - -test('spawn CLI and test help with custom config', async t => { - const {stdout} = await spawnFixture(allowUnknownFlagsWithHelp, ['-h']); - t.is(stdout, indentString('\nCustom description\n\nUsage\n foo \n\n', 2)); -}); - -test('spawn CLI and test version with custom config', async t => { - const pkg = await readPackage(); - const {stdout} = await spawnFixture(allowUnknownFlagsWithHelp, ['-v']); - t.is(stdout, pkg.version); -}); diff --git a/test/build.js b/test/build.js index 5607ef7..28411f8 100644 --- a/test/build.js +++ b/test/build.js @@ -1,7 +1,8 @@ import test from 'ava'; -import {readPackage} from 'read-pkg'; import meow from '../build/index.js'; -import {spawnFixture} from './_utils.js'; +import {_verifyCli, meowVersion} from './_utils.js'; + +const verifyCli = _verifyCli(); test('main', t => { const cli = meow(` @@ -39,8 +40,7 @@ test('main', t => { }); }); -test('spawn cli and show version', async t => { - const pkg = await readPackage(); - const {stdout} = await spawnFixture(['--version']); - t.is(stdout, pkg.version); +test('spawn cli and show version', verifyCli, { + args: '--version', + expected: meowVersion, }); diff --git a/test/choices.js b/test/choices.js deleted file mode 100644 index 7fcf78b..0000000 --- a/test/choices.js +++ /dev/null @@ -1,230 +0,0 @@ -import test from 'ava'; -import meow from '../source/index.js'; -import {stripIndent} from './_utils.js'; - -const importMeta = import.meta; - -test('choices - success case', t => { - const cli = meow({ - importMeta, - argv: ['--animal', 'cat', '--number=2.2'], - flags: { - animal: { - choices: ['dog', 'cat', 'unicorn'], - }, - number: { - type: 'number', - choices: [1.1, 2.2, 3.3], - }, - }, - }); - - t.is(cli.flags.animal, 'cat'); - t.is(cli.flags.number, 2.2); -}); - -test('choices - throws if input does not match choices', t => { - t.throws(() => { - meow({ - importMeta, - argv: ['--animal', 'rainbow', '--number', 5], - flags: { - animal: { - choices: ['dog', 'cat', 'unicorn'], - }, - number: { - choices: [1, 2, 3], - }, - }, - }); - }, { - message: stripIndent` - Unknown value for flag \`--animal\`: \`rainbow\`. Value must be one of: [\`dog\`, \`cat\`, \`unicorn\`] - Unknown value for flag \`--number\`: \`5\`. Value must be one of: [\`1\`, \`2\`, \`3\`] - `, - }); -}); - -test('choices - throws if choices is not array', t => { - t.throws(() => { - meow({ - importMeta, - argv: ['--animal', 'cat'], - flags: { - animal: { - choices: 'cat', - }, - }, - }); - }, {message: 'The option `choices` must be an array. Invalid flags: `--animal`'}); -}); - -test('choices - does not throw error when isRequired is false', t => { - t.notThrows(() => { - meow({ - importMeta, - argv: [], - flags: { - animal: { - isRequired: false, - choices: ['dog', 'cat', 'unicorn'], - }, - }, - }); - }); -}); - -test('choices - throw error when isRequired is true', t => { - t.throws(() => { - meow({ - importMeta, - argv: [], - flags: { - animal: { - isRequired: true, - choices: ['dog', 'cat', 'unicorn'], - }, - }, - }); - }, {message: 'Flag `--animal` has no value. Value must be one of: [`dog`, `cat`, `unicorn`]'}); -}); - -test('choices - success with isMultiple', t => { - const cli = meow({ - importMeta, - argv: ['--animal=dog', '--animal=unicorn'], - flags: { - animal: { - type: 'string', - isMultiple: true, - choices: ['dog', 'cat', 'unicorn'], - }, - }, - }); - - t.deepEqual(cli.flags.animal, ['dog', 'unicorn']); -}); - -test('choices - throws with isMultiple, one unknown value', t => { - t.throws(() => { - meow({ - importMeta, - argv: ['--animal=dog', '--animal=rabbit'], - flags: { - animal: { - type: 'string', - isMultiple: true, - choices: ['dog', 'cat', 'unicorn'], - }, - }, - }); - }, {message: 'Unknown value for flag `--animal`: `rabbit`. Value must be one of: [`dog`, `cat`, `unicorn`]'}); -}); - -test('choices - throws with isMultiple, multiple unknown value', t => { - t.throws(() => { - meow({ - importMeta, - argv: ['--animal=dog', '--animal=rabbit'], - flags: { - animal: { - type: 'string', - isMultiple: true, - choices: ['cat', 'unicorn'], - }, - }, - }); - }, {message: 'Unknown values for flag `--animal`: `dog`, `rabbit`. Value must be one of: [`cat`, `unicorn`]'}); -}); - -test('choices - throws with multiple flags', t => { - t.throws(() => { - meow({ - importMeta, - argv: ['--animal=dog', '--plant=succulent'], - flags: { - animal: { - type: 'string', - choices: ['cat', 'unicorn'], - }, - plant: { - type: 'string', - choices: ['tree', 'flower'], - }, - }, - }); - }, {message: stripIndent` - Unknown value for flag \`--animal\`: \`dog\`. Value must be one of: [\`cat\`, \`unicorn\`] - Unknown value for flag \`--plant\`: \`succulent\`. Value must be one of: [\`tree\`, \`flower\`] - `}); -}); - -test('choices - choices must be of the same type', t => { - t.throws(() => { - meow({ - importMeta, - flags: { - number: { - type: 'number', - choices: [1, '2'], - }, - boolean: { - type: 'boolean', - choices: [true, 'false'], - }, - }, - }); - }, {message: 'Each value of the option `choices` must be of the same type as its flag. Invalid flags: (`--number`, type: \'number\'), (`--boolean`, type: \'boolean\')'}); -}); - -test('choices - success when each value of default exist within the option choices', t => { - t.notThrows(() => { - meow({ - importMeta, - flags: { - number: { - type: 'number', - choices: [1, 2, 3], - default: 1, - }, - string: { - type: 'string', - choices: ['dog', 'cat', 'unicorn'], - default: 'dog', - }, - multiString: { - type: 'string', - choices: ['dog', 'cat', 'unicorn'], - default: ['dog', 'cat'], - isMultiple: true, - }, - }, - }); - }); -}); - -test('choices - throws when default does not only include valid choices', t => { - t.throws(() => { - meow({ - importMeta, - flags: { - number: { - type: 'number', - choices: [1, 2, 3], - default: 8, - }, - string: { - type: 'string', - choices: ['dog', 'cat'], - default: 'unicorn', - }, - multiString: { - type: 'string', - choices: ['dog', 'cat'], - default: ['dog', 'unicorn'], - isMultiple: true, - }, - }, - }); - }, {message: 'Each value of the option `default` must exist within the option `choices`. Invalid flags: `--number`, `--string`, `--multiString`'}); -}); diff --git a/test/errors.js b/test/errors.js deleted file mode 100644 index dd28aac..0000000 --- a/test/errors.js +++ /dev/null @@ -1,86 +0,0 @@ -import test from 'ava'; -import meow from '../source/index.js'; -import {stripIndent} from './_utils.js'; - -const importMeta = import.meta; - -/** -A convenience-wrapper around `t.throws` with `meow`. - -@param {import('../index').Options} options `meow` options with `importMeta` set -@param {string} message The thrown error message. Strips indentation, so template literals can be used. -*/ -const meowThrows = test.macro((t, options, {message}) => { - options = { - importMeta, - ...options, - }; - - message = stripIndent(message); - - t.throws(() => meow(options), {message}); -}); - -test('invalid package url', meowThrows, { - importMeta: '/path/to/package', -}, {message: 'The `importMeta` option is required. Its value must be `import.meta`.'}); - -test('supports `number` flag type - throws on incorrect default value', meowThrows, { - argv: [], - flags: { - foo: { - type: 'number', - default: 'x', - }, - }, -}, {message: 'Expected "foo" default value to be of type "number", got "string"'}); - -test('flag declared in kebab-case is an error', meowThrows, { - flags: {'kebab-case': 'boolean', test: 'boolean', 'another-one': 'boolean'}, -}, {message: 'Flag keys may not contain \'-\'. Invalid flags: `kebab-case`, `another-one`'}); - -test('single flag set more than once is an error', meowThrows, { - argv: ['--foo=bar', '--foo=baz'], - flags: { - foo: { - type: 'string', - }, - }, -}, {message: 'The flag --foo can only be set once.'}); - -test('suggests renaming alias to shortFlag', meowThrows, { - flags: { - foo: { - type: 'string', - alias: 'f', - }, - bar: { - type: 'string', - alias: 'b', - }, - baz: { - type: 'string', - shortFlag: 'z', - }, - }, -}, {message: 'The option `alias` has been renamed to `shortFlag`. The following flags need to be updated: `--foo`, `--bar`'}); - -test('options - multiple validation errors', meowThrows, { - flags: { - animal: { - type: 'string', - choices: 'cat', - }, - plant: { - type: 'string', - alias: 'p', - }, - 'some-thing': { - type: 'string', - }, - }, -}, {message: ` - Flag keys may not contain '-'. Invalid flags: \`some-thing\` - The option \`alias\` has been renamed to \`shortFlag\`. The following flags need to be updated: \`--plant\` - The option \`choices\` must be an array. Invalid flags: \`--animal\` -`}); diff --git a/test/fixtures/help/fixture.js b/test/fixtures/help/fixture.js new file mode 100755 index 0000000..54dbf9a --- /dev/null +++ b/test/fixtures/help/fixture.js @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import meow from '../../../source/index.js'; + +const cli = meow({ + importMeta: import.meta, + help: 'foo', + flags: { + showHelp: {type: 'boolean'}, + code: {type: 'number'}, + }, +}); + +const {code} = cli.flags; + +if (cli.flags.showHelp) { + if (code !== undefined) { + cli.showHelp(code); + } + + cli.showHelp(); +} diff --git a/test/fixtures/help/package.json b/test/fixtures/help/package.json new file mode 100644 index 0000000..7f75ec9 --- /dev/null +++ b/test/fixtures/help/package.json @@ -0,0 +1,6 @@ +{ + "name": "foo", + "version": "1.0.0", + "bin": "./fixture.js", + "type": "module" +} diff --git a/test/fixtures/version/fixture.js b/test/fixtures/version/fixture.js new file mode 100755 index 0000000..b28b332 --- /dev/null +++ b/test/fixtures/version/fixture.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +import process from 'node:process'; +import meow from '../../../source/index.js'; + +const version = process.env.VERSION === 'false' ? false : process.env.VERSION; + +const options = { + importMeta: import.meta, + version, + autoVersion: !process.argv.includes('--no-auto-version'), + flags: { + showVersion: {type: 'boolean'}, + }, +}; + +if (options.version === undefined) { + delete options.version; +} + +const cli = meow(options); + +if (cli.flags.showVersion) { + cli.showVersion(); +} diff --git a/test/fixtures/version/package.json b/test/fixtures/version/package.json new file mode 100644 index 0000000..7f75ec9 --- /dev/null +++ b/test/fixtures/version/package.json @@ -0,0 +1,6 @@ +{ + "name": "foo", + "version": "1.0.0", + "bin": "./fixture.js", + "type": "module" +} diff --git a/test/fixtures/with-package-json/fixture.js b/test/fixtures/with-package-json/custom-bin/fixture.js similarity index 50% rename from test/fixtures/with-package-json/fixture.js rename to test/fixtures/with-package-json/custom-bin/fixture.js index b68255c..a2fa84f 100755 --- a/test/fixtures/with-package-json/fixture.js +++ b/test/fixtures/with-package-json/custom-bin/fixture.js @@ -1,9 +1,6 @@ #!/usr/bin/env node import process from 'node:process'; -import meow from '../../../source/index.js'; - -meow({ - importMeta: import.meta, -}); +import meow from '../../../../source/index.js'; +meow({importMeta: import.meta}); console.log(process.title); diff --git a/test/fixtures/with-package-json/custom-bin/package.json b/test/fixtures/with-package-json/custom-bin/package.json new file mode 100644 index 0000000..7a04580 --- /dev/null +++ b/test/fixtures/with-package-json/custom-bin/package.json @@ -0,0 +1,7 @@ +{ + "name": "foo", + "bin": { + "bar": "./fixture.js" + }, + "type": "module" +} diff --git a/test/fixtures/with-package-json/default/fixture.js b/test/fixtures/with-package-json/default/fixture.js new file mode 100755 index 0000000..a2fa84f --- /dev/null +++ b/test/fixtures/with-package-json/default/fixture.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import process from 'node:process'; +import meow from '../../../../source/index.js'; + +meow({importMeta: import.meta}); +console.log(process.title); diff --git a/test/fixtures/with-package-json/package.json b/test/fixtures/with-package-json/default/package.json similarity index 100% rename from test/fixtures/with-package-json/package.json rename to test/fixtures/with-package-json/default/package.json diff --git a/test/fixtures/with-package-json/no-bin/fixture.js b/test/fixtures/with-package-json/no-bin/fixture.js new file mode 100755 index 0000000..a2fa84f --- /dev/null +++ b/test/fixtures/with-package-json/no-bin/fixture.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import process from 'node:process'; +import meow from '../../../../source/index.js'; + +meow({importMeta: import.meta}); +console.log(process.title); diff --git a/test/fixtures/with-package-json/no-bin/package.json b/test/fixtures/with-package-json/no-bin/package.json new file mode 100644 index 0000000..b55415b --- /dev/null +++ b/test/fixtures/with-package-json/no-bin/package.json @@ -0,0 +1,4 @@ +{ + "name": "foo", + "type": "module" +} diff --git a/test/flags/_utils.js b/test/flags/_utils.js new file mode 100644 index 0000000..dde13de --- /dev/null +++ b/test/flags/_utils.js @@ -0,0 +1,31 @@ +/* eslint-disable ava/no-ignored-test-files */ +import test from 'ava'; +import meow from '../../source/index.js'; + +export const _verifyFlags = importMeta => test.macro(async (t, {flags = {}, args, expected, error}) => { + const assertions = await t.try(async tt => { + const arguments_ = args?.split(' ') ?? []; + const meowOptions = {importMeta, argv: arguments_, flags}; + + tt.log('arguments:', arguments_); + + if (error) { + tt.throws(() => meow(meowOptions), { + message(message) { + tt.log('error:\n', message); + return tt.is(message, error); + }, + }); + } else { + const cli = meow(meowOptions); + + if (expected) { + tt.like(cli.flags, expected); + } else { + tt.pass(); + } + } + }); + + assertions.commit({retainLogs: !assertions.passed}); +}); diff --git a/test/aliases.js b/test/flags/aliases.js similarity index 67% rename from test/aliases.js rename to test/flags/aliases.js index 39972ad..3e362e6 100644 --- a/test/aliases.js +++ b/test/flags/aliases.js @@ -1,10 +1,10 @@ import test from 'ava'; -import meow from '../source/index.js'; +import meow from '../../source/index.js'; const importMeta = import.meta; -test('aliases - accepts one', t => { - t.deepEqual(meow({ +test('accepts one', t => { + const cli = meow({ importMeta, argv: ['--foo=baz'], flags: { @@ -13,13 +13,15 @@ test('aliases - accepts one', t => { aliases: ['foo'], }, }, - }).flags, { + }); + + t.like(cli.flags, { fooBar: 'baz', }); }); -test('aliases - accepts multiple', t => { - t.deepEqual(meow({ +test('accepts multiple', t => { + const cli = meow({ importMeta, argv: ['--foo=baz1', '--bar=baz2'], flags: { @@ -29,13 +31,15 @@ test('aliases - accepts multiple', t => { isMultiple: true, }, }, - }).flags, { + }); + + t.like(cli.flags, { fooBar: ['baz1', 'baz2'], }); }); -test('aliases - can be a short flag', t => { - t.deepEqual(meow({ +test('can be a short flag', t => { + const cli = meow({ importMeta, argv: ['--f=baz'], flags: { @@ -44,13 +48,15 @@ test('aliases - can be a short flag', t => { aliases: ['f'], }, }, - }).flags, { + }); + + t.like(cli.flags, { fooBar: 'baz', }); }); -test('aliases - works with short flag', t => { - t.deepEqual(meow({ +test('works with short flag', t => { + const cli = meow({ importMeta, argv: ['--foo=baz1', '--bar=baz2', '-f=baz3'], flags: { @@ -61,13 +67,15 @@ test('aliases - works with short flag', t => { isMultiple: true, }, }, - }).flags, { + }); + + t.like(cli.flags, { fooBar: ['baz1', 'baz2', 'baz3'], }); }); -test('aliases - unnormalized flags', t => { - t.deepEqual(meow({ +test('unnormalized flags', t => { + const cli = meow({ importMeta, argv: ['--foo=baz'], flags: { @@ -77,7 +85,9 @@ test('aliases - unnormalized flags', t => { shortFlag: 'f', }, }, - }).unnormalizedFlags, { + }); + + t.like(cli.unnormalizedFlags, { fooBar: 'baz', foo: 'baz', f: 'baz', diff --git a/test/flags/allow-unknown-flags.js b/test/flags/allow-unknown-flags.js new file mode 100644 index 0000000..4a459ba --- /dev/null +++ b/test/flags/allow-unknown-flags.js @@ -0,0 +1,63 @@ +import test from 'ava'; +import indentString from 'indent-string'; +import {_verifyCli, stripIndentTrim, meowVersion} from '../_utils.js'; + +const fixtureFolder = 'allow-unknown-flags'; + +const allowUnknownFlags = `${fixtureFolder}/fixture.js`; +const allowUnknownFlagsWithHelp = `${fixtureFolder}/fixture-with-help.js`; + +const verifyFlags = _verifyCli(allowUnknownFlags); + +test('specifying unknown flags', verifyFlags, { + args: '--foo bar --unspecified-a --unspecified-b input-is-allowed', + error: stripIndentTrim` + Unknown flags + --unspecified-a + --unspecified-b + `, + +}); + +test('specifying known flags', verifyFlags, { + args: '--foo bar', + expected: 'bar', +}); + +test('help as a known flag', verifyFlags, { + args: '--help', + expected: indentString('\nCustom description\n\nUsage\n foo \n\n', 2), +}); + +test('version as a known flag', verifyFlags, { + args: '--version', + expected: meowVersion, +}); + +test('help as an unknown flag', verifyFlags, { + args: '--help --no-auto-help', + error: stripIndentTrim` + Unknown flag + --help + `, +}); + +test('version as an unknown flag', verifyFlags, { + args: '--version --no-auto-version', + error: stripIndentTrim` + Unknown flag + --version + `, +}); + +test('help with custom config', verifyFlags, { + fixture: allowUnknownFlagsWithHelp, + args: '-h', + expected: indentString('\nCustom description\n\nUsage\n foo \n\n', 2), +}); + +test('version with custom config', verifyFlags, { + fixture: allowUnknownFlagsWithHelp, + args: '-v', + expected: meowVersion, +}); diff --git a/test/flags/boolean-default.js b/test/flags/boolean-default.js new file mode 100644 index 0000000..70747b4 --- /dev/null +++ b/test/flags/boolean-default.js @@ -0,0 +1,55 @@ +import test from 'ava'; +import meow from '../../source/index.js'; + +const importMeta = import.meta; + +test('undefined - filter out unset boolean args', t => { + const cli = meow({ + importMeta, + argv: ['--foo'], + booleanDefault: undefined, + flags: { + foo: { + type: 'boolean', + }, + bar: { + type: 'boolean', + }, + baz: { + type: 'boolean', + default: false, + }, + }, + }); + + t.like(cli.flags, { + foo: true, + bar: undefined, + baz: false, + }); +}); + +test('boolean args are false by default', t => { + const cli = meow({ + importMeta, + argv: ['--foo'], + flags: { + foo: { + type: 'boolean', + }, + bar: { + type: 'boolean', + default: true, + }, + baz: { + type: 'boolean', + }, + }, + }); + + t.like(cli.flags, { + foo: true, + bar: true, + baz: false, + }); +}); diff --git a/test/flags/choices.js b/test/flags/choices.js new file mode 100644 index 0000000..f210c2c --- /dev/null +++ b/test/flags/choices.js @@ -0,0 +1,187 @@ +import test from 'ava'; +import {oneLine} from 'common-tags'; +import {stripIndentTrim} from '../_utils.js'; +import {_verifyFlags} from './_utils.js'; + +const verifyChoices = _verifyFlags(import.meta); + +test('success case', verifyChoices, { + flags: { + animal: { + choices: ['dog', 'cat', 'unicorn'], + }, + number: { + type: 'number', + choices: [1.1, 2.2, 3.3], + }, + }, + args: '--animal cat --number=2.2', + expected: { + animal: 'cat', + number: 2.2, + }, +}); + +test('throws if input does not match choices', verifyChoices, { + flags: { + animal: { + choices: ['dog', 'cat', 'unicorn'], + }, + number: { + choices: [1, 2, 3], + }, + }, + args: '--animal rainbow --number 5', + error: stripIndentTrim` + Unknown value for flag \`--animal\`: \`rainbow\`. Value must be one of: [\`dog\`, \`cat\`, \`unicorn\`] + Unknown value for flag \`--number\`: \`5\`. Value must be one of: [\`1\`, \`2\`, \`3\`] + `, +}); + +test('throws if choices is not array', verifyChoices, { + flags: { + animal: { + choices: 'cat', + }, + }, + args: '--animal cat', + error: 'The option `choices` must be an array. Invalid flags: `--animal`', +}); + +test('does not throw error when isRequired is false', verifyChoices, { + flags: { + animal: { + isRequired: false, + choices: ['dog', 'cat', 'unicorn'], + }, + }, +}); + +test('throw error when isRequired is true', verifyChoices, { + flags: { + animal: { + isRequired: true, + choices: ['dog', 'cat', 'unicorn'], + }, + }, + error: 'Flag `--animal` has no value. Value must be one of: [`dog`, `cat`, `unicorn`]', +}); + +test('success with isMultiple', verifyChoices, { + flags: { + animal: { + type: 'string', + isMultiple: true, + choices: ['dog', 'cat', 'unicorn'], + }, + }, + args: '--animal=dog --animal=unicorn', + expected: { + animal: ['dog', 'unicorn'], + }, +}); + +test('throws with isMultiple, one unknown value', verifyChoices, { + flags: { + animal: { + type: 'string', + isMultiple: true, + choices: ['dog', 'cat', 'unicorn'], + }, + }, + args: '--animal=dog --animal=rabbit', + error: 'Unknown value for flag `--animal`: `rabbit`. Value must be one of: [`dog`, `cat`, `unicorn`]', +}); + +test('throws with isMultiple, multiple unknown value', verifyChoices, { + flags: { + animal: { + type: 'string', + isMultiple: true, + choices: ['cat', 'unicorn'], + }, + }, + args: '--animal=dog --animal=rabbit', + error: 'Unknown values for flag `--animal`: `dog`, `rabbit`. Value must be one of: [`cat`, `unicorn`]', +}); + +test('throws with multiple flags', verifyChoices, { + flags: { + animal: { + type: 'string', + choices: ['cat', 'unicorn'], + }, + plant: { + type: 'string', + choices: ['tree', 'flower'], + }, + }, + args: '--animal=dog --plant=succulent', + error: stripIndentTrim` + Unknown value for flag \`--animal\`: \`dog\`. Value must be one of: [\`cat\`, \`unicorn\`] + Unknown value for flag \`--plant\`: \`succulent\`. Value must be one of: [\`tree\`, \`flower\`] + `, +}); + +test('choices must be of the same type', verifyChoices, { + flags: { + number: { + type: 'number', + choices: [1, '2'], + }, + boolean: { + type: 'boolean', + choices: [true, 'false'], + }, + }, + error: oneLine` + Each value of the option \`choices\` must be of the same type as its flag. + Invalid flags: (\`--number\`, type: 'number'), (\`--boolean\`, type: 'boolean') + `, +}); + +test('success when each value of default exist within the option choices', verifyChoices, { + flags: { + number: { + type: 'number', + choices: [1, 2, 3], + default: 1, + }, + string: { + type: 'string', + choices: ['dog', 'cat', 'unicorn'], + default: 'dog', + }, + multiString: { + type: 'string', + choices: ['dog', 'cat', 'unicorn'], + default: ['dog', 'cat'], + isMultiple: true, + }, + }, +}); + +test('throws when default does not only include valid choices', verifyChoices, { + flags: { + number: { + type: 'number', + choices: [1, 2, 3], + default: 8, + }, + string: { + type: 'string', + choices: ['dog', 'cat'], + default: 'unicorn', + }, + multiString: { + type: 'string', + choices: ['dog', 'cat'], + default: ['dog', 'unicorn'], + isMultiple: true, + }, + }, + error: oneLine` + Each value of the option \`default\` must exist within the option \`choices\`. + Invalid flags: \`--number\`, \`--string\`, \`--multiString\` + `, +}); diff --git a/test/flags/is-multiple.js b/test/flags/is-multiple.js new file mode 100644 index 0000000..801affe --- /dev/null +++ b/test/flags/is-multiple.js @@ -0,0 +1,205 @@ +import test from 'ava'; +import meow from '../../source/index.js'; +import {_verifyFlags} from './_utils.js'; + +const importMeta = import.meta; +const verifyFlags = _verifyFlags(importMeta); + +test('unset flag returns empty array', verifyFlags, { + flags: { + foo: { + type: 'string', + isMultiple: true, + }, + }, + expected: { + foo: [], + }, +}); + +test('flag set once returns array', verifyFlags, { + flags: { + foo: { + type: 'string', + isMultiple: true, + }, + }, + args: '--foo=bar', + expected: { + foo: ['bar'], + }, +}); + +test('flag set multiple times', verifyFlags, { + flags: { + foo: { + type: 'string', + isMultiple: true, + }, + }, + args: '--foo=bar --foo=baz', + expected: { + foo: ['bar', 'baz'], + }, +}); + +test('flag with space separated values', t => { + const cli = meow({ + importMeta, + argv: ['--foo', 'bar', 'baz'], + flags: { + foo: { + type: 'string', + isMultiple: true, + }, + }, + }); + + t.like(cli, { + input: ['baz'], + flags: { + foo: ['bar'], + }, + }); +}); + +test('does not support comma separated values', verifyFlags, { + flags: { + foo: { + type: 'string', + isMultiple: true, + }, + }, + args: '--foo bar,baz', + expected: { + foo: ['bar,baz'], + }, +}); + +test('default to type string', verifyFlags, { + flags: { + foo: { + isMultiple: true, + }, + }, + args: '--foo=bar', + expected: { + foo: ['bar'], + }, +}); + +test('boolean flag', verifyFlags, { + flags: { + foo: { + type: 'boolean', + isMultiple: true, + }, + }, + args: '--foo --foo=false', + expected: { + foo: [true, false], + }, +}); + +test('boolean flag is false by default', verifyFlags, { + flags: { + foo: { + type: 'boolean', + isMultiple: true, + }, + }, + expected: { + foo: [false], + }, +}); + +test('number flag', verifyFlags, { + flags: { + foo: { + type: 'number', + isMultiple: true, + }, + }, + args: '--foo=1.3 --foo=-1', + expected: { + foo: [1.3, -1], + }, +}); + +test('flag default values', verifyFlags, { + flags: { + string: { + type: 'string', + isMultiple: true, + default: ['foo'], + }, + boolean: { + type: 'boolean', + isMultiple: true, + default: [true], + }, + number: { + type: 'number', + isMultiple: true, + default: [0.5], + }, + }, + expected: { + string: ['foo'], + boolean: [true], + number: [0.5], + }, +}); + +test('multiple flag default values', verifyFlags, { + flags: { + string: { + type: 'string', + isMultiple: true, + default: ['foo', 'bar'], + }, + boolean: { + type: 'boolean', + isMultiple: true, + default: [true, false], + }, + number: { + type: 'number', + isMultiple: true, + default: [0.5, 1], + }, + }, + expected: { + string: ['foo', 'bar'], + boolean: [true, false], + number: [0.5, 1], + }, +}); + +// Happened in production 2020-05-10: https://github.com/sindresorhus/meow/pull/143#issuecomment-626287226 +test('handles multi-word flag name', verifyFlags, { + flags: { + fooBar: { + type: 'string', + isMultiple: true, + }, + }, + args: '--foo-bar=baz', + expected: { + fooBar: ['baz'], + }, +}); + +test('works with short flags', verifyFlags, { + flags: { + foo: { + type: 'string', + shortFlag: 'f', + isMultiple: true, + }, + }, + args: '-f bar -f baz', + expected: { + foo: ['bar', 'baz'], + }, +}); diff --git a/test/flags/is-required.js b/test/flags/is-required.js new file mode 100644 index 0000000..1810585 --- /dev/null +++ b/test/flags/is-required.js @@ -0,0 +1,108 @@ +import test from 'ava'; +import {_verifyCli, stripIndentTrim} from '../_utils.js'; + +const fixtureFolder = 'required'; + +const required = `${fixtureFolder}/fixture.js`; +const requiredFunction = `${fixtureFolder}/fixture-required-function.js`; +const requiredMultiple = `${fixtureFolder}/fixture-required-multiple.js`; +const conditionalRequiredMultiple = `${fixtureFolder}/fixture-conditional-required-multiple.js`; + +const verifyFlags = _verifyCli(required); + +test('not specifying required flags', verifyFlags, { + error: stripIndentTrim` + Missing required flags + --test, -t + --number + --kebab-case + `, +}); + +test('specifying all required flags', verifyFlags, { + args: '--test test --number 6 --kebab-case test', + expected: 'test,6', +}); + +test('specifying required string flag with an empty string as value', verifyFlags, { + args: '--test ', + error: stripIndentTrim` + Missing required flags + --number + --kebab-case + `, +}); + +test('specifying required number flag without a number', verifyFlags, { + args: '--number', + error: stripIndentTrim` + Missing required flags + --test, -t + --number + --kebab-case + `, +}); + +test('setting isRequired as a function and not specifying any flags', verifyFlags, { + fixture: requiredFunction, + expected: 'false,undefined', +}); + +test('setting isRequired as a function and specifying only the flag that activates the isRequired condition for the other flag', verifyFlags, { + fixture: requiredFunction, + args: '--trigger', + error: stripIndentTrim` + Missing required flag + --with-trigger + `, +}); + +test('setting isRequired as a function and specifying both the flags', verifyFlags, { + fixture: requiredFunction, + args: '--trigger --withTrigger specified', + expected: 'true,specified', +}); + +test('setting isRequired as a function and check if returning a non-boolean value throws an error', verifyFlags, { + fixture: requiredFunction, + args: '--allowError --shouldError specified', + error: { + clean: true, + message: 'TypeError: Return value for isRequired callback should be of type boolean, but string was returned.', + code: 1, + }, +}); + +test('isRequired with isMultiple giving a single value', verifyFlags, { + fixture: requiredMultiple, + args: '--test 1', + expected: '[ 1 ]', +}); + +test('isRequired with isMultiple giving multiple values', verifyFlags, { + fixture: requiredMultiple, + args: '--test 1 --test 2', + expected: '[ 1, 2 ]', +}); + +test('isRequired with isMultiple giving no values, but flag is given', verifyFlags, { + fixture: requiredMultiple, + args: '--test', + error: stripIndentTrim` + Missing required flag + --test, -t + `, +}); + +test('isRequired with isMultiple giving no values, but flag is not given', verifyFlags, { + fixture: requiredMultiple, + error: stripIndentTrim` + Missing required flag + --test, -t + `, +}); + +test('isRequire function that returns false with isMultiple given no values, but flag is not given', verifyFlags, { + fixture: conditionalRequiredMultiple, + expected: '[]', +}); diff --git a/test/flags/short-flag.js b/test/flags/short-flag.js new file mode 100644 index 0000000..311de00 --- /dev/null +++ b/test/flags/short-flag.js @@ -0,0 +1,78 @@ +import test from 'ava'; +import meow from '../../source/index.js'; +import {_verifyFlags} from './_utils.js'; + +const importMeta = import.meta; +const verifyFlags = _verifyFlags(importMeta); + +test('can be used in place of long flags', t => { + const cli = meow({ + importMeta, + argv: ['-f'], + flags: { + foo: { + type: 'boolean', + shortFlag: 'f', + }, + }, + }); + + t.like(cli.flags, { + foo: true, + f: undefined, + }); + + t.like(cli.unnormalizedFlags, { + foo: true, + f: true, + }); +}); + +test('grouped flags work', t => { + const cli = meow({ + importMeta, + argv: ['-cl'], + flags: { + coco: { + type: 'boolean', + shortFlag: 'c', + }, + loco: { + type: 'boolean', + shortFlag: 'l', + }, + }, + }); + + t.like(cli.flags, { + coco: true, + loco: true, + c: undefined, + l: undefined, + }); + + t.like(cli.unnormalizedFlags, { + coco: true, + loco: true, + c: true, + l: true, + }); +}); + +test('suggests renaming alias to shortFlag', verifyFlags, { + flags: { + foo: { + type: 'string', + alias: 'f', + }, + bar: { + type: 'string', + alias: 'b', + }, + baz: { + type: 'string', + shortFlag: 'z', + }, + }, + error: 'The option `alias` has been renamed to `shortFlag`. The following flags need to be updated: `--foo`, `--bar`', +}); diff --git a/test/flags/test.js b/test/flags/test.js new file mode 100644 index 0000000..ae57a1b --- /dev/null +++ b/test/flags/test.js @@ -0,0 +1,125 @@ +import test from 'ava'; +import {stripIndentTrim} from '../_utils.js'; +import {_verifyFlags} from './_utils.js'; + +const verifyFlags = _verifyFlags(import.meta); + +test('flag types', verifyFlags, { + flags: { + foo: {type: 'string'}, + bar: {type: 'number'}, + baz: {type: 'boolean'}, + }, + args: '--foo=bar --bar=1.3 --baz=false', + expected: { + foo: 'bar', + bar: 1.3, + baz: false, + }, +}); + +test('supports negation via --no', verifyFlags, { + flags: { + foo: { + type: 'boolean', + default: true, + }, + }, + args: '--no-foo', + expected: { + foo: false, + }, +}); + +test('throws if default value is not of the correct type', verifyFlags, { + flags: { + foo: { + type: 'number', + default: 'x', + }, + }, + error: 'Expected "foo" default value to be of type "number", got "string"', +}); + +test('flag, no value', verifyFlags, { + flags: { + foo: {type: 'string'}, + bar: {type: 'number'}, + }, + args: '--foo --bar', + expected: { + foo: '', + bar: undefined, + }, +}); + +test('default - flag, no value', verifyFlags, { + flags: { + foo: {type: 'string', default: 'bar'}, + bar: {type: 'number', default: 1.3}, + }, + args: '--foo --bar', + expected: { + foo: 'bar', + bar: 1.3, + }, +}); + +test('default - no flag', verifyFlags, { + flags: { + foo: {type: 'string', default: 'bar'}, + bar: {type: 'number', default: 1.3}, + }, + args: '', + expected: { + foo: 'bar', + bar: 1.3, + }, +}); + +test('single character flag casing should be preserved', verifyFlags, { + args: '-F', + expected: { + F: true, + }, +}); + +test('flag declared in kebab-case is an error', verifyFlags, { + flags: { + 'kebab-case': {type: 'boolean'}, + test: {type: 'boolean'}, + 'another-one': {type: 'boolean'}, + }, + error: 'Flag keys may not contain \'-\'. Invalid flags: `kebab-case`, `another-one`', +}); + +test('single flag set more than once is an error', verifyFlags, { + flags: { + foo: { + type: 'string', + }, + }, + args: '--foo=bar --foo=baz', + error: 'The flag --foo can only be set once.', +}); + +test('options - multiple validation errors', verifyFlags, { + flags: { + animal: { + type: 'string', + choices: 'cat', + }, + plant: { + type: 'string', + alias: 'p', + }, + 'some-thing': { + type: 'string', + }, + }, + error: stripIndentTrim` + Flag keys may not contain '-'. Invalid flags: \`some-thing\` + The option \`alias\` has been renamed to \`shortFlag\`. The following flags need to be updated: \`--plant\` + The option \`choices\` must be an array. Invalid flags: \`--animal\` + `, +}); diff --git a/test/help.js b/test/help.js deleted file mode 100644 index c361c0a..0000000 --- a/test/help.js +++ /dev/null @@ -1,67 +0,0 @@ -import test from 'ava'; -import indentString from 'indent-string'; -import meow from '../source/index.js'; -import {spawnFixture} from './_utils.js'; - -const importMeta = import.meta; - -test('support help shortcut', t => { - t.is(meow(` - unicorn - cat - `, { - importMeta, - }).help, indentString('\nCLI app helper\n\nunicorn\ncat\n', 2)); -}); - -test('spawn cli and show help screen', async t => { - const {stdout} = await spawnFixture(['--help']); - t.is(stdout, indentString('\nCustom description\n\nUsage\n foo \n\n', 2)); -}); - -test('spawn cli and disabled autoHelp', async t => { - const {stdout} = await spawnFixture(['--help', '--no-auto-help']); - t.is(stdout, 'help\nautoHelp\nmeow\ncamelCaseOption'); -}); - -test('spawn cli and not show help', async t => { - const {stdout} = await spawnFixture(['--help=all']); - t.is(stdout, 'help\nmeow\ncamelCaseOption'); -}); - -test('single line help messages are not indented', t => { - t.is(meow({ - importMeta, - description: false, - help: 'single line', - }).help, '\nsingle line\n'); -}); - -test('descriptions with no help are not indented', t => { - t.is(meow({ - importMeta, - help: false, - description: 'single line', - }).help, '\nsingle line\n'); -}); - -test('support help shortcut with no indentation', t => { - t.is(meow(` - unicorn - cat - `, { - helpIndent: 0, - importMeta, - }).help, indentString('\nCLI app helper\n\nunicorn\ncat\n', 0)); -}); - -test('no description and no indentation', t => { - t.is(meow(` - unicorn - cat - `, { - helpIndent: 0, - description: false, - importMeta, - }).help, indentString('\nunicorn\ncat\n', 0)); -}); diff --git a/test/is-multiple.js b/test/is-multiple.js deleted file mode 100644 index 4b5469d..0000000 --- a/test/is-multiple.js +++ /dev/null @@ -1,211 +0,0 @@ -import test from 'ava'; -import meow from '../source/index.js'; - -const importMeta = import.meta; - -test('isMultiple - unset flag returns empty array', t => { - t.deepEqual(meow({ - importMeta, - argv: [], - flags: { - foo: { - type: 'string', - isMultiple: true, - }, - }, - }).flags, { - foo: [], - }); -}); - -test('isMultiple - flag set once returns array', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo=bar'], - flags: { - foo: { - type: 'string', - isMultiple: true, - }, - }, - }).flags, { - foo: ['bar'], - }); -}); - -test('isMultiple - flag set multiple times', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo=bar', '--foo=baz'], - flags: { - foo: { - type: 'string', - isMultiple: true, - }, - }, - }).flags, { - foo: ['bar', 'baz'], - }); -}); - -test('isMultiple - flag with space separated values', t => { - const {input, flags} = meow({ - importMeta, - argv: ['--foo', 'bar', 'baz'], - flags: { - foo: { - type: 'string', - isMultiple: true, - }, - }, - }); - - t.deepEqual(input, ['baz']); - t.deepEqual(flags.foo, ['bar']); -}); - -test('isMultiple - flag with comma separated values', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo', 'bar,baz'], - flags: { - foo: { - type: 'string', - isMultiple: true, - }, - }, - }).flags, { - foo: ['bar,baz'], - }); -}); - -test('isMultiple - default to type string', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo=bar'], - flags: { - foo: { - isMultiple: true, - }, - }, - }).flags, { - foo: ['bar'], - }); -}); - -test('isMultiple - boolean flag', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo', '--foo=false'], - flags: { - foo: { - type: 'boolean', - isMultiple: true, - }, - }, - }).flags, { - foo: [true, false], - }); -}); - -test('isMultiple - boolean flag is false by default', t => { - t.deepEqual(meow({ - importMeta, - argv: [], - flags: { - foo: { - type: 'boolean', - isMultiple: true, - }, - }, - }).flags, { - foo: [false], - }); -}); - -test('isMultiple - number flag', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo=1.3', '--foo=-1'], - flags: { - foo: { - type: 'number', - isMultiple: true, - }, - }, - }).flags, { - foo: [1.3, -1], - }); -}); - -test('isMultiple - flag default values', t => { - t.deepEqual(meow({ - importMeta, - argv: [], - flags: { - string: { - type: 'string', - isMultiple: true, - default: ['foo'], - }, - boolean: { - type: 'boolean', - isMultiple: true, - default: [true], - }, - number: { - type: 'number', - isMultiple: true, - default: [0.5], - }, - }, - }).flags, { - string: ['foo'], - boolean: [true], - number: [0.5], - }); -}); - -test('isMultiple - multiple flag default values', t => { - t.deepEqual(meow({ - importMeta, - argv: [], - flags: { - string: { - type: 'string', - isMultiple: true, - default: ['foo', 'bar'], - }, - boolean: { - type: 'boolean', - isMultiple: true, - default: [true, false], - }, - number: { - type: 'number', - isMultiple: true, - default: [0.5, 1], - }, - }, - }).flags, { - string: ['foo', 'bar'], - boolean: [true, false], - number: [0.5, 1], - }); -}); - -// Happened in production 2020-05-10: https://github.com/sindresorhus/meow/pull/143#issuecomment-626287226 -test('isMultiple - handles multi-word flag name', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo-bar=baz'], - flags: { - fooBar: { - type: 'string', - isMultiple: true, - }, - }, - }).flags, { - fooBar: ['baz'], - }); -}); diff --git a/test/is-required-flag.js b/test/is-required-flag.js deleted file mode 100644 index f83337d..0000000 --- a/test/is-required-flag.js +++ /dev/null @@ -1,111 +0,0 @@ -import test from 'ava'; -import {spawnFixture} from './_utils.js'; - -const fixtureFolder = 'required'; - -const required = `${fixtureFolder}/fixture.js`; -const requiredFunction = `${fixtureFolder}/fixture-required-function.js`; -const requiredMultiple = `${fixtureFolder}/fixture-required-multiple.js`; -const conditionalRequiredMultiple = `${fixtureFolder}/fixture-conditional-required-multiple.js`; - -test('spawn cli and test not specifying required flags', async t => { - const {stderr} = await t.throwsAsync( - spawnFixture(required), - {message: /^Command failed with exit code 2/}, - ); - - t.regex(stderr, /Missing required flag/); - t.regex(stderr, /--test, -t/); - t.regex(stderr, /--number/); - t.regex(stderr, /--kebab-case/); - t.notRegex(stderr, /--not-required/); -}); - -test('spawn cli and test specifying all required flags', async t => { - const {stdout} = await spawnFixture(required, ['-t', 'test', '--number', '6', '--kebab-case', 'test']); - t.is(stdout, 'test,6'); -}); - -test('spawn cli and test specifying required string flag with an empty string as value', async t => { - const {stderr} = await t.throwsAsync( - spawnFixture(required, ['--test', '']), - {message: /^Command failed with exit code 2/}, - ); - - t.regex(stderr, /Missing required flag/); - t.notRegex(stderr, /--test, -t/); -}); - -test('spawn cli and test specifying required number flag without a number', async t => { - const {stderr} = await t.throwsAsync( - spawnFixture(required, ['--number']), - {message: /^Command failed with exit code 2/}, - ); - - t.regex(stderr, /Missing required flag/); - t.regex(stderr, /--number/); -}); - -test('spawn cli and test setting isRequired as a function and not specifying any flags', async t => { - const {stdout} = await spawnFixture(requiredFunction); - t.is(stdout, 'false,undefined'); -}); - -test('spawn cli and test setting isRequired as a function and specifying only the flag that activates the isRequired condition for the other flag', async t => { - const {stderr} = await t.throwsAsync( - spawnFixture(requiredFunction, ['--trigger']), - {message: /^Command failed with exit code 2/}, - ); - - t.regex(stderr, /Missing required flag/); - t.regex(stderr, /--with-trigger/); -}); - -test('spawn cli and test setting isRequired as a function and specifying both the flags', async t => { - const {stdout} = await spawnFixture(requiredFunction, ['--trigger', '--withTrigger', 'specified']); - t.is(stdout, 'true,specified'); -}); - -test('spawn cli and test setting isRequired as a function and check if returning a non-boolean value throws an error', async t => { - const {stderr} = await t.throwsAsync( - spawnFixture(requiredFunction, ['--allowError', '--shouldError', 'specified']), - {message: /^Command failed with exit code 1/}, - ); - - t.regex(stderr, /Return value for isRequired callback should be of type boolean, but string was returned./); -}); - -test('spawn cli and test isRequired with isMultiple giving a single value', async t => { - const {stdout} = await spawnFixture(requiredMultiple, ['--test', '1']); - t.is(stdout, '[ 1 ]'); -}); - -test('spawn cli and test isRequired with isMultiple giving multiple values', async t => { - const {stdout} = await spawnFixture(requiredMultiple, ['--test', '1', '--test', '2']); - t.is(stdout, '[ 1, 2 ]'); -}); - -test('spawn cli and test isRequired with isMultiple giving no values, but flag is given', async t => { - const {stderr} = await t.throwsAsync( - spawnFixture(requiredMultiple, ['--test']), - {message: /^Command failed with exit code 2/}, - ); - - t.regex(stderr, /Missing required flag/); - t.regex(stderr, /--test/); -}); - -test('spawn cli and test isRequired with isMultiple giving no values, but flag is not given', async t => { - const {stderr} = await t.throwsAsync( - spawnFixture(requiredMultiple), - {message: /^Command failed with exit code 2/}, - ); - - t.regex(stderr, /Missing required flag/); - t.regex(stderr, /--test/); -}); - -test('spawn cli and test isRequire function that returns false with isMultiple given no values, but flag is not given', async t => { - const {stdout} = await spawnFixture(conditionalRequiredMultiple); - t.is(stdout, '[]'); -}); diff --git a/test/options/help.js b/test/options/help.js new file mode 100644 index 0000000..1a1ad15 --- /dev/null +++ b/test/options/help.js @@ -0,0 +1,132 @@ +import test from 'ava'; +import indentString from 'indent-string'; +import meow from '../../source/index.js'; +import {_verifyCli, stripIndent, stripIndentTrim} from '../_utils.js'; + +const importMeta = import.meta; + +const verifyCli = _verifyCli(); + +const verifyHelp = test.macro(async (t, {cli: cliArguments, expected}) => { + const assertions = await t.try(async tt => { + const cli = Array.isArray(cliArguments) + ? meow(cliArguments.at(0), {importMeta, ...cliArguments.at(1)}) + : meow({importMeta, ...cliArguments}); + + tt.log('help text:\n', cli.help); + tt.is(cli.help, expected); + }); + + assertions.commit({retainLogs: !assertions.passed}); +}); + +test('support help shortcut', verifyHelp, { + cli: [` + unicorn + cat + `], + expected: indentString('\nCLI app helper\n\nunicorn\ncat\n', 2), +}); + +test('spawn cli and show help screen', verifyCli, { + args: '--help', + expected: indentString('\nCustom description\n\nUsage\n foo \n\n', 2), +}); + +test('spawn cli and disabled autoHelp', verifyCli, { + args: '--help --no-auto-help', + expected: stripIndentTrim` + help + autoHelp + meow + camelCaseOption + `, +}); + +test('spawn cli and not show help', verifyCli, { + args: '--help=all', + expected: stripIndentTrim` + help + meow + camelCaseOption + `, +}); + +test('single line help messages are not indented', verifyHelp, { + cli: { + description: false, + help: 'single line', + }, + expected: stripIndent` + + single line + `, +}); + +test('descriptions with no help are not indented', verifyHelp, { + cli: { + help: false, + description: 'single line', + }, + expected: stripIndent` + + single line + `, + +}); + +test('support help shortcut with no indentation', verifyHelp, { + cli: [` + unicorn + cat + `, { + helpIndent: 0, + }], + expected: stripIndent` + + CLI app helper + + unicorn + cat + `, +}); + +test('no description and no indentation', verifyHelp, { + cli: [` + unicorn + cat + `, { + helpIndent: 0, + description: false, + }], + expected: stripIndent` + + unicorn + cat + `, +}); + +test('exits with code 0 by default', verifyCli, { + args: '--help', +}); + +test('showHelp exits with code 2 by default', verifyCli, { + fixture: 'help/fixture.js', + args: '--show-help', + error: { + message: stripIndent` + + foo + `, + code: 2, + }, +}); + +test('showHelp exits with given code', verifyCli, { + fixture: 'help/fixture.js', + args: '--show-help --code=0', + expected: stripIndent` + + foo + `, +}); diff --git a/test/options/import-meta.js b/test/options/import-meta.js new file mode 100644 index 0000000..421c3c1 --- /dev/null +++ b/test/options/import-meta.js @@ -0,0 +1,24 @@ +import test from 'ava'; +import meow from '../../source/index.js'; + +test('main', t => { + t.notThrows(() => meow({importMeta: import.meta})); +}); + +test('with help shortcut', t => { + t.notThrows(() => ( + meow(` + unicorns + rainbows + `, { + importMeta: import.meta, + }) + )); +}); + +test('invalid package url', t => { + t.throws( + () => meow({importMeta: '/path/to/package'}), + {message: 'The `importMeta` option is required. Its value must be `import.meta`.'}, + ); +}); diff --git a/test/options/infer-type.js b/test/options/infer-type.js new file mode 100644 index 0000000..a96ffe5 --- /dev/null +++ b/test/options/infer-type.js @@ -0,0 +1,39 @@ +import test from 'ava'; +import meow from '../../source/index.js'; + +const verifyTypeInference = test.macro((t, {cli: cliArguments, expected}) => { + const cli = meow({importMeta: import.meta, ...cliArguments}); + t.like(cli.input, [expected]); +}); + +test('no inference by default', verifyTypeInference, { + cli: { + argv: ['5'], + }, + expected: '5', +}); + +test('type inference', verifyTypeInference, { + cli: { + argv: ['5'], + inferType: true, + }, + expected: 5, +}); + +test('with input type', verifyTypeInference, { + cli: { + argv: ['5'], + input: 'number', + }, + expected: 5, +}); + +test('works with flags', verifyTypeInference, { + cli: { + argv: ['5'], + inferType: true, + flags: {foo: 'string'}, + }, + expected: 5, +}); diff --git a/test/options/pkg.js b/test/options/pkg.js new file mode 100644 index 0000000..3259a8f --- /dev/null +++ b/test/options/pkg.js @@ -0,0 +1,57 @@ +import test from 'ava'; +import {_verifyCli, stripIndent} from '../_utils.js'; +import meow from '../../source/index.js'; + +const importMeta = import.meta; + +const verifyPackage = _verifyCli('with-package-json/default/fixture.js'); + +test('description', t => { + const cli = meow({ + importMeta, + pkg: { + description: 'Unicorn and rainbow creator', + }, + }); + + t.is(cli.help, stripIndent` + + Unicorn and rainbow creator + `); +}); + +test.todo('version'); + +test('overriding pkg still normalizes', t => { + const cli = meow({ + importMeta, + pkg: { + name: 'browser-sync', + bin: './bin/browser-sync.js', + }, + }); + + t.like(cli, { + pkg: { + name: 'browser-sync', + version: '', + }, + }); + + // TODO: test that showVersion logs undefined +}); + +test('process title - bin default', verifyPackage, { + expected: 'foo', +}); + +test('process title - bin custom', verifyPackage, { + fixture: 'with-package-json/custom-bin/fixture.js', + expected: 'bar', +}); + +test('process title - name backup', verifyPackage, { + fixture: 'with-package-json/no-bin/fixture.js', + expected: 'foo', +}); + diff --git a/test/options/version.js b/test/options/version.js new file mode 100644 index 0000000..29b5f28 --- /dev/null +++ b/test/options/version.js @@ -0,0 +1,47 @@ +import test from 'ava'; +import {_verifyCli, defaultFixture, stripIndentTrim} from '../_utils.js'; + +const verifyVersion = _verifyCli('version/fixture.js'); + +test('spawn cli and show version', verifyVersion, { + args: '--version', + expected: '1.0.0', +}); + +test('spawn cli and disabled autoVersion', verifyVersion, { + fixture: defaultFixture, + args: '--version --no-auto-version', + expected: stripIndentTrim` + version + autoVersion + meow + camelCaseOption + `, +}); + +test('spawn cli and not show version', verifyVersion, { + fixture: defaultFixture, + args: '--version=beta', + expected: stripIndentTrim` + version + meow + camelCaseOption + `, +}); + +test('custom version', verifyVersion, { + args: '--version', + execaOptions: {env: {VERSION: 'beta'}}, + expected: 'beta', +}); + +test('version = false has no effect', verifyVersion, { + args: '--version', + execaOptions: {env: {VERSION: 'false'}}, + expected: '1.0.0', +}); + +test('manual showVersion', verifyVersion, { + args: '--show-version', + expected: '1.0.0', +}); diff --git a/test/test.js b/test/test.js index 4d8c4ec..c589ad0 100644 --- a/test/test.js +++ b/test/test.js @@ -1,14 +1,10 @@ -import path from 'node:path'; -import process from 'node:process'; import test from 'ava'; import indentString from 'indent-string'; -import {execa} from 'execa'; -import {readPackage} from 'read-pkg'; import meow from '../source/index.js'; -import {spawnFixture, __dirname} from './_utils.js'; +import {_verifyCli, stripIndentTrim} from './_utils.js'; const importMeta = import.meta; -const NODE_MAJOR_VERSION = process.versions.node.split('.').at(0); +const verifyCli = _verifyCli(); test('return object', t => { const cli = meow({ @@ -25,219 +21,43 @@ test('return object', t => { }, }); - t.is(cli.input.at(0), 'foo'); - t.true(cli.flags.fooBar); - t.is(cli.flags.meow, 'dog'); - t.is(cli.flags.unicorn, 'cat'); - t.deepEqual(cli.flags['--'], ['unicorn', 'cake']); - t.is(cli.pkg.name, 'meow'); - t.is(cli.help, indentString('\nCLI app helper\n\nUsage\n foo \n', 2)); -}); - -test('spawn cli and show version', async t => { - const pkg = await readPackage(); - const {stdout} = await spawnFixture(['--version']); - t.is(stdout, pkg.version); -}); - -test('spawn cli and disabled autoVersion and autoHelp', async t => { - const {stdout} = await spawnFixture(['--version', '--help']); - t.is(stdout, 'version\nhelp\nmeow\ncamelCaseOption'); -}); - -test('spawn cli and disabled autoVersion', async t => { - const {stdout} = await spawnFixture(['--version', '--no-auto-version']); - t.is(stdout, 'version\nautoVersion\nmeow\ncamelCaseOption'); -}); - -test('spawn cli and not show version', async t => { - const {stdout} = await spawnFixture(['--version=beta']); - t.is(stdout, 'version\nmeow\ncamelCaseOption'); -}); - -test('spawn cli and test input', async t => { - const {stdout} = await spawnFixture(['-u', 'cat']); - t.is(stdout, 'unicorn\nmeow\ncamelCaseOption'); -}); - -test('spawn cli and test input flag', async t => { - const {stdout} = await spawnFixture(['--camel-case-option', 'bar']); - t.is(stdout, 'bar'); -}); - -test('spawn cli and test process title', async t => { - const {stdout} = await spawnFixture('with-package-json/fixture.js'); - t.is(stdout, 'foo'); -}); - -test('setting pkg.bin should work', t => { - const cli = meow({ - importMeta, - pkg: { - name: 'browser-sync', - bin: './bin/browser-sync.js', - }, - }); - - t.is(cli.pkg.name, 'browser-sync'); - t.is(cli.pkg.version, ''); - t.is(cli.version, undefined); -}); - -test('single character flag casing should be preserved', t => { - t.deepEqual(meow({ - importMeta, - argv: ['-F'], - }).flags, {F: true}); -}); - -test('type inference', t => { - t.is(meow({importMeta, argv: ['5']}).input.at(0), '5'); - t.is(meow({importMeta, argv: ['5']}, {input: 'string'}).input.at(0), '5'); - t.is(meow({ - importMeta, - argv: ['5'], - inferType: true, - }).input.at(0), 5); - t.is(meow({ - importMeta, - argv: ['5'], - inferType: true, - flags: {foo: 'string'}, - }).input.at(0), 5); - t.is(meow({ - importMeta, - argv: ['5'], - inferType: true, - flags: { - foo: 'string', - }, - }).input.at(0), 5); - t.is(meow({ - importMeta, - argv: ['5'], - input: 'number', - }).input.at(0), 5); -}); - -test('booleanDefault: undefined, filter out unset boolean args', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo'], - booleanDefault: undefined, + t.like(cli, { + input: ['foo'], flags: { - foo: { - type: 'boolean', - }, - bar: { - type: 'boolean', - }, - baz: { - type: 'boolean', - default: false, - }, + fooBar: true, + meow: 'dog', + unicorn: 'cat', + '--': ['unicorn', 'cake'], }, - }).flags, { - foo: true, - baz: false, - }); -}); - -test('boolean args are false by default', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo'], - flags: { - foo: { - type: 'boolean', - }, - bar: { - type: 'boolean', - default: true, - }, - baz: { - type: 'boolean', - }, - }, - }).flags, { - foo: true, - bar: true, - baz: false, - }); -}); - -test('enforces boolean flag type', t => { - const cli = meow({ - importMeta, - argv: ['--cursor=false'], - flags: { - cursor: { - type: 'boolean', - }, + pkg: { + name: 'meow', }, + help: indentString('\nCLI app helper\n\nUsage\n foo \n', 2), }); - t.deepEqual(cli.flags, {cursor: false}); }); -test('accept help and options', t => { - t.deepEqual(meow({ - importMeta, - argv: ['-f'], - flags: { - foo: { - type: 'boolean', - shortFlag: 'f', - }, - }, - }).flags, { - foo: true, - }); +test('spawn cli and disabled autoVersion and autoHelp', verifyCli, { + args: '--version --help', + expected: stripIndentTrim` + version + help + meow + camelCaseOption + `, }); -test('grouped short-flags work', t => { - const cli = meow({ - importMeta, - argv: ['-cl'], - flags: { - coco: { - type: 'boolean', - shortFlag: 'c', - }, - loco: { - type: 'boolean', - shortFlag: 'l', - }, - }, - }); - - const {unnormalizedFlags} = cli; - t.true(unnormalizedFlags.coco); - t.true(unnormalizedFlags.loco); - t.true(unnormalizedFlags.c); - t.true(unnormalizedFlags.l); +test('spawn cli and test input', verifyCli, { + args: '-u cat', + expected: stripIndentTrim` + unicorn + meow + camelCaseOption + `, }); -test('grouped flags work', t => { - const cli = meow({ - importMeta, - argv: ['-cl'], - flags: { - coco: { - type: 'boolean', - shortFlag: 'c', - }, - loco: { - type: 'boolean', - shortFlag: 'l', - }, - }, - }); - - const {flags} = cli; - t.true(flags.coco); - t.true(flags.loco); - t.is(flags.c, undefined); - t.is(flags.l, undefined); +test('spawn cli and test input flag', verifyCli, { + args: '--camel-case-option bar', + expected: 'bar', }); test('disable autoVersion/autoHelp if `cli.input.length > 0`', t => { @@ -245,74 +65,3 @@ test('disable autoVersion/autoHelp if `cli.input.length > 0`', t => { t.is(meow({importMeta, argv: ['bar', '--help']}).input.at(0), 'bar'); t.is(meow({importMeta, argv: ['bar', '--version', '--help']}).input.at(0), 'bar'); }); - -test('supports `number` flag type', t => { - const cli = meow({ - importMeta, - argv: ['--foo=1.3'], - flags: { - foo: { - type: 'number', - }, - }, - }).flags.foo; - - t.is(cli, 1.3); -}); - -test('supports `number` flag type - flag but no value', t => { - const cli = meow({ - importMeta, - argv: ['--foo'], - flags: { - foo: { - type: 'number', - }, - }, - }).flags.foo; - - t.is(cli, undefined); -}); - -test('supports `number` flag type - flag but no value but default', t => { - const cli = meow({ - importMeta, - argv: ['--foo'], - flags: { - foo: { - type: 'number', - default: 2, - }, - }, - }).flags.foo; - - t.is(cli, 2); -}); - -test('supports `number` flag type - no flag but default', t => { - const cli = meow({ - importMeta, - argv: [], - flags: { - foo: { - type: 'number', - default: 2, - }, - }, - }).flags.foo; - - t.is(cli, 2); -}); - -if (NODE_MAJOR_VERSION >= 14) { - test('supports es modules', async t => { - try { - const {stdout} = await execa('node', ['estest/index.js', '--version'], { - importMeta: path.join(__dirname, '..'), - }); - t.regex(stdout, /1.2.3/); - } catch (error) { - t.is(error, undefined); - } - }); -}