Skip to content

Commit

Permalink
chore: add test for flag types
Browse files Browse the repository at this point in the history
  • Loading branch information
mdonnalley committed Aug 19, 2022
1 parent ec3f319 commit 68503b7
Show file tree
Hide file tree
Showing 7 changed files with 379 additions and 40 deletions.
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -74,6 +74,7 @@
"shx": "^0.3.4",
"sinon": "^11.1.2",
"ts-node": "^9.1.1",
"tsd": "^0.22.0",
"typescript": "4.5.5"
},
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion src/flags.ts
Expand Up @@ -12,7 +12,7 @@ export function option<T>(options: {parse: OptionFlag<T>['parse']} & Partial<Opt
return build<T>(options)()
}

export function _enum<T = string>(opts: EnumFlagOptions<T> & {multiple: true} & ({required: true} | { default: Default<T> })): OptionFlag<T[]>
export function _enum<T = string>(opts: EnumFlagOptions<T> & {multiple: true} & ({required: true} | { default: Default<T[]> })): OptionFlag<T[]>
export function _enum<T = string>(opts: EnumFlagOptions<T> & {multiple: true}): OptionFlag<T[] | undefined>
export function _enum<T = string>(opts: EnumFlagOptions<T> & ({required: true} | { default: Default<T> })): OptionFlag<T>
export function _enum<T = string>(opts: EnumFlagOptions<T>): OptionFlag<T | undefined>
Expand Down
45 changes: 31 additions & 14 deletions src/interfaces/parser.ts
Expand Up @@ -109,10 +109,30 @@ export type FlagProps = {
* Shows this flag in a separate list in the help.
*/
helpGroup?: string;
/**
* Accept an environment variable as input
*/
env?: string;
/**
* If true, the flag will not be shown in the help.
*/
hidden?: boolean;
/**
* If true, the flag will be required.
*/
required?: boolean;
/**
* List of flags that this flag depends on.
*/
dependsOn?: string[];
/**
* List of flags that cannot be used with this flag.
*/
exclusive?: string[];
/**
* Exactly one of these flags must be provided.
*/
exactlyOne?: string[];
}

export type BooleanFlagProps = FlagProps & {
Expand All @@ -128,36 +148,33 @@ export type OptionFlagProps = FlagProps & {
}

export type FlagBase<T, I> = FlagProps & {
exactlyOne?: string[];
/**
* also accept an environment variable as input
*/
env?: string;
parse(input: I, context: any): Promise<T>;
}

export type BooleanFlag<T> = FlagBase<T, boolean> & BooleanFlagProps & {
/**
* specifying a default of false is the same not specifying a default
* specifying a default of false is the same as not specifying a default
*/
default?: Default<boolean>;
}

export type OptionFlag<T> = FlagBase<T, string> & OptionFlagProps & {
default?: Default<T | undefined>;
defaultHelp?: DefaultHelp<T>;
input: string[];
}
} & ({
default?: Default<T | undefined>;
multiple: false;
} | {
default?: Default<T[] | undefined>;
multiple: true;
})

export type Definition<T> = {
(
options: { multiple: true } & ({ required: true } | { default: Default<T> }) &
Partial<OptionFlag<T>>,
options: { multiple: true } & ({ required: true } | { default: Default<T[]> }) & Partial<Omit<OptionFlag<T>, 'default'>>
): OptionFlag<T[]>;
(options: { multiple: true } & Partial<OptionFlag<T[]>>): OptionFlag<T[] | undefined>;
(
options: ({ required: true } | { default: Default<T> }) &
Partial<OptionFlag<T>>,
): OptionFlag<T>;
(options: ({ required: true } | { default: Default<T> }) & Partial<OptionFlag<T>>): OptionFlag<T>;
(options?: Partial<OptionFlag<T>>): OptionFlag<T | undefined>;
}

Expand Down
50 changes: 31 additions & 19 deletions src/parser/flags.ts
Expand Up @@ -8,9 +8,7 @@ import * as fs from 'fs'
export function build<T>(
defaults: {parse: OptionFlag<T>['parse']} & Partial<OptionFlag<T>>,
): Definition<T>
export function build(
defaults: Partial<OptionFlag<string>>,
): Definition<string>
export function build(defaults: Partial<OptionFlag<string>>): Definition<string>
export function build<T>(defaults: Partial<OptionFlag<T>>): Definition<T> {
return (options: any = {}) => {
return {
Expand All @@ -35,13 +33,19 @@ export function boolean<T = boolean>(
} as BooleanFlag<T>
}

export function integer(opts: Partial<OptionFlag<number>> & {min?: number; max?: number } & {multiple: true} & ({required: true} | { default: Default<number> })): OptionFlag<number[]>
export function integer(opts: Partial<OptionFlag<number>> & {min?: number; max?: number } & {multiple: true}): OptionFlag<number[] | undefined>
export function integer(opts: Partial<OptionFlag<number>> & {min?: number; max?: number } & ({required: true} | { default: Default<number> })): OptionFlag<number>
export function integer(opts?: Partial<OptionFlag<number>> & {min?: number; max?: number }): OptionFlag<number | undefined>
export function integer(opts: Partial<OptionFlag<number>> & {min?: number; max?: number } = {}): OptionFlag<number> | OptionFlag<number[]> | OptionFlag<number | undefined> | OptionFlag<number[] | undefined> {
type IntegerFlagOptions = Partial<OptionFlag<number>> & {
min?: number;
max?: number;
}

export function integer(opts: IntegerFlagOptions & {multiple: true} & ({required: true} | { default: Default<number[]> })): OptionFlag<number[]>
export function integer(opts: IntegerFlagOptions & {multiple: true}): OptionFlag<number[] | undefined>
export function integer(opts: IntegerFlagOptions & ({required: true} | { default: Default<number> })): OptionFlag<number>
export function integer(opts?: IntegerFlagOptions): OptionFlag<number | undefined>
export function integer(opts: IntegerFlagOptions = {}): OptionFlag<number> | OptionFlag<number[]> | OptionFlag<number | undefined> | OptionFlag<number[] | undefined> {
return build({
...opts,
// parse thinks it needs an array to be returned, which is not the case
parse: async input => {
if (!/^-?\d+$/.test(input))
throw new Error(`Expected an integer but received: ${input}`)
Expand All @@ -50,16 +54,20 @@ export function integer(opts: Partial<OptionFlag<number>> & {min?: number; max?:
throw new Error(`Expected an integer greater than or equal to ${opts.min} but received: ${input}`)
if (opts.max !== undefined && num > opts.max)
throw new Error(`Expected an integer less than or equal to ${opts.max} but received: ${input}`)
return opts.parse ? opts.parse(input, 1) : num
return (opts.parse ? opts.parse(input, 1) : num)
},
})()
}

export function directory(opts: Partial<OptionFlag<string>> & { exists?: boolean } & {multiple: true} & ({required: true} | { default: Default<string> })): OptionFlag<string[]>
export function directory(opts: Partial<OptionFlag<string>> & { exists?: boolean } & {multiple: true}): OptionFlag<string[] | undefined>
export function directory(opts: { exists?: boolean } & Partial<OptionFlag<string>> & ({required: true} | { default: Default<string> })): OptionFlag<string>
export function directory(opts?: { exists?: boolean } & Partial<OptionFlag<string>>): OptionFlag<string | undefined>
export function directory(opts: { exists?: boolean } & Partial<OptionFlag<string>> = {}): OptionFlag<string> | OptionFlag<string[]> | OptionFlag<string | undefined> | OptionFlag<string[] | undefined> {
type DirectoryFlagOptions = Partial<OptionFlag<string>> & {
exists?: boolean;
}

export function directory(opts: DirectoryFlagOptions & {multiple: true} & ({required: true} | { default: Default<string[]> })): OptionFlag<string[]>
export function directory(opts: DirectoryFlagOptions & {multiple: true}): OptionFlag<string[] | undefined>
export function directory(opts: DirectoryFlagOptions & ({required: true} | { default: Default<string> })): OptionFlag<string>
export function directory(opts?: DirectoryFlagOptions): OptionFlag<string | undefined>
export function directory(opts: DirectoryFlagOptions = {}): OptionFlag<string> | OptionFlag<string[]> | OptionFlag<string | undefined> | OptionFlag<string[] | undefined> {
return build<string>({
...opts,
parse: async (input: string) => {
Expand All @@ -73,11 +81,15 @@ export function directory(opts: { exists?: boolean } & Partial<OptionFlag<string
})()
}

export function file(opts: Partial<OptionFlag<string>> & { exists?: boolean } & {multiple: true} & ({required: true} | { default: Default<string> })): OptionFlag<string[]>
export function file(opts: Partial<OptionFlag<string>> & { exists?: boolean } & {multiple: true}): OptionFlag<string[] | undefined>
export function file(opts: { exists?: boolean } & Partial<OptionFlag<string>> & ({required: true} | { default: Default<string> })): OptionFlag<string>
export function file(opts?: { exists?: boolean } & Partial<OptionFlag<string>>): OptionFlag<string | undefined>
export function file(opts: { exists?: boolean } & Partial<OptionFlag<string>> = {}): OptionFlag<string> | OptionFlag<string[]> | OptionFlag<string | undefined> | OptionFlag<string[] | undefined> {
type FileFlagOptions = Partial<OptionFlag<string>> & {
exists?: boolean;
}

export function file(opts: FileFlagOptions & {multiple: true} & ({required: true} | { default: Default<string[]> })): OptionFlag<string[]>
export function file(opts: FileFlagOptions & {multiple: true}): OptionFlag<string[] | undefined>
export function file(opts: FileFlagOptions & ({required: true} | { default: Default<string> })): OptionFlag<string>
export function file(opts?: FileFlagOptions): OptionFlag<string | undefined>
export function file(opts: FileFlagOptions = {}): OptionFlag<string> | OptionFlag<string[]> | OptionFlag<string | undefined> | OptionFlag<string[] | undefined> {
return build<string>({
...opts,
parse: async (input: string) => {
Expand Down
211 changes: 211 additions & 0 deletions test/interfaces/flags.test.ts
@@ -0,0 +1,211 @@
/**
* This test file contains no unit tests but we use the tsd package to ensure that the types are valid when the tests are compiled
*/

import Command from '../../src/command'
import * as Flags from '../../src/flags'
import * as Interfaces from '../../src/interfaces'
import {expectType, expectNotType} from 'tsd'
import {URL} from 'url'

abstract class BaseCommand extends Command {
static enableJsonFlag = true

static globalFlags = {
config: Flags.string({
description: 'specify config file',
}),
}
}

type MyFlags = Interfaces.InferredFlags<typeof MyCommand.flags & typeof MyCommand.globalFlags>

enum MyEnum {
'A' = 'A',
'B' = 'B',
'C' = 'C',
}

type MyType = {
foo: boolean;
}

class MyCommand extends BaseCommand {
static description = 'describe the command here'

static examples = [
'<%= config.bin %> <%= command.id %>',
]

static flags = {
requiredString: Flags.string({required: true}),
optionalString: Flags.string(),
defaultString: Flags.string({default: 'default'}),

requiredMultiString: Flags.string({required: true, multiple: true}),
optionalMultiString: Flags.string({multiple: true}),
defaultMultiString: Flags.string({multiple: true, default: ['default']}),

requiredBoolean: Flags.boolean({required: true}),
optionalBoolean: Flags.boolean(),
defaultBoolean: Flags.boolean({default: true}),

optionalEnum: Flags.enum({options: ['a', 'b', 'c']}),
requiredEnum: Flags.enum({options: ['a', 'b', 'c'], required: true}),
defaultEnum: Flags.enum({options: ['a', 'b', 'c'], default: 'a'}),

optionalMultiEnum: Flags.enum({multiple: true, options: ['a', 'b', 'c']}),
requiredMultiEnum: Flags.enum({multiple: true, options: ['a', 'b', 'c'], required: true}),
defaultMultiEnum: Flags.enum({multiple: true, options: ['a', 'b', 'c'], default: ['a']}),

optionalTypedEnum: Flags.enum<MyEnum>({options: Object.values(MyEnum)}),
requiredTypedEnum: Flags.enum<MyEnum>({options: Object.values(MyEnum), required: true}),
defaultTypedEnum: Flags.enum<MyEnum>({options: Object.values(MyEnum), default: MyEnum.A}),

optionalInteger: Flags.integer(),
requiredInteger: Flags.integer({required: true}),
defaultInteger: Flags.integer({default: 1}),

optionalMultiInteger: Flags.integer({multiple: true}),
requiredMultiInteger: Flags.integer({multiple: true, required: true}),
defaultMultiInteger: Flags.integer({multiple: true, default: [1]}),

optionalDirectory: Flags.directory(),
requiredDirectory: Flags.directory({required: true}),
defaultDirectory: Flags.directory({default: 'my-dir'}),

optionalMultiDirectory: Flags.directory({multiple: true}),
requiredMultiDirectory: Flags.directory({multiple: true, required: true}),
defaultMultiDirectory: Flags.directory({multiple: true, default: ['my-dir']}),

optionalFile: Flags.file(),
requiredFile: Flags.file({required: true}),
defaultFile: Flags.file({default: 'my-file.json'}),

optionalMultiFile: Flags.file({multiple: true}),
requiredMultiFile: Flags.file({multiple: true, required: true}),
defaultMultiFile: Flags.file({multiple: true, default: ['my-file.json']}),

optionalUrl: Flags.url(),
requiredUrl: Flags.url({required: true}),
defaultUrl: Flags.url({default: new URL('http://example.com')}),

optionalMultiUrl: Flags.url({multiple: true}),
requiredMultiUrl: Flags.url({multiple: true, required: true}),
defaultMultiUrl: Flags.url({multiple: true, default: [new URL('http://example.com')]}),

optionalCustom: Flags.build<MyType>({
parse: async () => ({foo: true}),
})(),
requiredCustom: Flags.build<MyType>({
parse: async () => ({foo: true}),
})({required: true}),
defaultCustom: Flags.build<MyType>({
parse: async () => ({foo: true}),
})({default: {foo: true}}),
}

public flags!: MyFlags

public async run(): Promise<MyFlags> {
const result = await this.parse(MyCommand)
this.flags = result.flags
expectType<MyFlags>(this.flags)

expectType<string>(this.flags.requiredString)
expectNotType<undefined>(this.flags.requiredString)

expectType<string>(this.flags.defaultString)
expectNotType<undefined>(this.flags.defaultString)

expectType<string | undefined>(this.flags.optionalString)

expectType<string[]>(this.flags.requiredMultiString)
expectNotType<undefined>(this.flags.requiredMultiString)

expectType<string[] | undefined>(this.flags.optionalMultiString)
expectType<string[]>(this.flags.defaultMultiString)
expectNotType<undefined>(this.flags.defaultMultiString)

expectType<boolean>(this.flags.requiredBoolean)
expectNotType<undefined>(this.flags.requiredBoolean)
expectType<boolean>(this.flags.defaultBoolean)
expectNotType<undefined>(this.flags.defaultBoolean)
expectType<boolean | undefined>(this.flags.optionalBoolean)

expectType<string>(this.flags.requiredEnum)
expectNotType<undefined>(this.flags.requiredEnum)
expectType<string>(this.flags.defaultEnum)
expectNotType<undefined>(this.flags.defaultEnum)
expectType<string | undefined>(this.flags.optionalEnum)

expectType<string[]>(this.flags.requiredMultiEnum)
expectNotType<undefined>(this.flags.requiredMultiEnum)
expectType<string[]>(this.flags.defaultMultiEnum)
expectNotType<undefined>(this.flags.defaultMultiEnum)
expectType<string[] | undefined>(this.flags.optionalMultiEnum)

expectType<MyEnum>(this.flags.requiredTypedEnum)
expectNotType<undefined>(this.flags.requiredTypedEnum)
expectType<MyEnum>(this.flags.defaultTypedEnum)
expectNotType<undefined>(this.flags.defaultTypedEnum)
expectType<MyEnum | undefined>(this.flags.optionalTypedEnum)

expectType<number>(this.flags.requiredInteger)
expectNotType<undefined>(this.flags.requiredInteger)
expectType<number>(this.flags.defaultInteger)
expectNotType<undefined>(this.flags.defaultInteger)
expectType<number | undefined>(this.flags.optionalInteger)

expectType<number[]>(this.flags.requiredMultiInteger)
expectNotType<undefined>(this.flags.requiredMultiInteger)
expectType<number[]>(this.flags.defaultMultiInteger)
expectNotType<undefined>(this.flags.defaultMultiInteger)
expectType<number[] | undefined>(this.flags.optionalMultiInteger)

expectType<string>(this.flags.requiredDirectory)
expectNotType<undefined>(this.flags.requiredDirectory)
expectType<string>(this.flags.defaultDirectory)
expectNotType<undefined>(this.flags.defaultDirectory)
expectType<string | undefined>(this.flags.optionalDirectory)

expectType<string[]>(this.flags.requiredMultiDirectory)
expectNotType<undefined>(this.flags.requiredMultiDirectory)
expectType<string[]>(this.flags.defaultMultiDirectory)
expectNotType<undefined>(this.flags.defaultMultiDirectory)
expectType<string[] | undefined>(this.flags.optionalMultiDirectory)

expectType<string>(this.flags.requiredFile)
expectNotType<undefined>(this.flags.requiredFile)
expectType<string>(this.flags.defaultFile)
expectNotType<undefined>(this.flags.defaultFile)
expectType<string | undefined>(this.flags.optionalFile)

expectType<string[]>(this.flags.requiredMultiFile)
expectNotType<undefined>(this.flags.requiredMultiFile)
expectType<string[]>(this.flags.defaultMultiFile)
expectNotType<undefined>(this.flags.defaultMultiFile)
expectType<string[] | undefined>(this.flags.optionalMultiFile)

expectType<URL>(this.flags.requiredUrl)
expectNotType<undefined>(this.flags.requiredUrl)
expectType<URL>(this.flags.defaultUrl)
expectNotType<undefined>(this.flags.defaultUrl)
expectType<URL | undefined>(this.flags.optionalUrl)

expectType<URL[]>(this.flags.requiredMultiUrl)
expectNotType<undefined>(this.flags.requiredMultiUrl)
expectType<URL[]>(this.flags.defaultMultiUrl)
expectNotType<undefined>(this.flags.defaultMultiUrl)
expectType<URL[] | undefined>(this.flags.optionalMultiUrl)

expectType<MyType>(this.flags.requiredCustom)
expectNotType<undefined>(this.flags.requiredCustom)
expectType<MyType>(this.flags.defaultCustom)
expectNotType<undefined>(this.flags.defaultCustom)
expectType<MyType | undefined>(this.flags.optionalCustom)

return result.flags
}
}

0 comments on commit 68503b7

Please sign in to comment.