nx/packages/tao/src/shared/params.spec.ts

1324 lines
31 KiB
TypeScript

import * as yargsParser from 'yargs-parser';
import { logger } from './logger';
import {
applyVerbosity,
coerceTypesInOptions,
combineOptionsForExecutor,
convertAliases,
convertSmartDefaultsIntoNamedParams,
convertToCamelCase,
lookupUnmatched,
Schema,
setDefaults,
validateOptsAgainstSchema,
warnDeprecations,
} from './params';
import { TargetConfiguration } from './workspace';
describe('params', () => {
describe('combineOptionsForExecutor', () => {
let schema: Schema;
beforeEach(() => {
schema = {
properties: {
overriddenOpt: {
type: 'string',
alias: 'overriddenOptAlias',
},
},
};
});
it('should use target options', () => {
const commandLineOpts = {};
const target: TargetConfiguration = {
executor: '@nrwl/do:stuff',
options: {
overriddenOpt: 'target value',
},
configurations: {
production: {},
},
};
const options = combineOptionsForExecutor(
commandLineOpts,
'production',
target,
schema,
'proj',
process.cwd()
);
expect(options).toEqual({
overriddenOpt: 'target value',
});
});
it('should combine target, configuration', () => {
const commandLineOpts = {};
const target: TargetConfiguration = {
executor: '@nrwl/do:stuff',
options: {
overriddenOpt: 'target value',
},
configurations: {
production: {
overriddenOpt: 'config value',
},
},
};
const options = combineOptionsForExecutor(
commandLineOpts,
'production',
target,
schema,
'proj',
process.cwd()
);
expect(options).toEqual({
overriddenOpt: 'config value',
});
});
it('should combine target, configuration, and passed options', () => {
const commandLineOpts = {
overriddenOpt: 'command value',
};
const target: TargetConfiguration = {
executor: '@nrwl/do:stuff',
options: {
overriddenOpt: 'target value',
},
configurations: {
production: {
overriddenOpt: 'config value',
},
},
};
const options = combineOptionsForExecutor(
commandLineOpts,
'production',
target,
schema,
'proj',
process.cwd()
);
expect(options).toEqual({
overriddenOpt: 'command value',
});
});
it('should convert aliases in target configuration', () => {
const commandLineOpts = {
overriddenOpt: 'command value',
};
const target: TargetConfiguration = {
executor: '@nrwl/do:stuff',
options: {
overriddenOptAlias: 'target value',
},
configurations: {
production: {
overriddenOptAlias: 'config value',
},
},
};
const options = combineOptionsForExecutor(
commandLineOpts,
'production',
target,
schema,
'proj',
process.cwd()
);
expect(options).toEqual({
overriddenOpt: 'command value',
});
});
it('should convert aliases in command line arguments', () => {
const commandLineOpts = {
overriddenOptAlias: 'command value',
};
const target: TargetConfiguration = {
executor: '@nrwl/do:stuff',
options: {
overriddenOpt: 'target value',
},
configurations: {
production: {
overriddenOpt: 'config value',
},
},
};
const options = combineOptionsForExecutor(
commandLineOpts,
'production',
target,
schema,
'proj',
process.cwd()
);
expect(options).toEqual({
overriddenOpt: 'command value',
});
});
it('should handle targets without options', () => {
const commandLineOpts = {};
const target: TargetConfiguration = {
executor: '@nrwl/do:stuff',
};
const options = combineOptionsForExecutor(
commandLineOpts,
'production',
target,
schema,
'proj',
process.cwd()
);
expect(options).toEqual({});
});
});
describe('coerceTypes', () => {
it('should handle booleans', () => {
const opts = coerceTypesInOptions(
{ a: true, b: 'true', c: false, d: 'true' },
{
properties: {
a: { type: 'boolean' },
b: { type: 'boolean' },
c: { type: 'boolean' },
d: { type: 'string' },
},
} as Schema
);
expect(opts).toEqual({
a: true,
b: true,
c: false,
d: 'true',
});
});
it('should handle numbers', () => {
const opts = coerceTypesInOptions({ a: 1, b: '2', c: '3' }, {
properties: {
a: { type: 'number' },
b: { type: 'number' },
c: { type: 'string' },
},
} as Schema);
expect(opts).toEqual({
a: 1,
b: 2,
c: '3',
});
});
it('should handle arrays', () => {
const opts = coerceTypesInOptions({ a: 'one,two', b: 'three,four' }, {
properties: {
a: { type: 'array' },
b: { type: 'string' },
},
} as Schema);
expect(opts).toEqual({
a: ['one', 'two'],
b: 'three,four',
});
const opts2 = coerceTypesInOptions({ a: '1,2', b: 'true,false' }, {
properties: {
a: { type: 'array', items: { type: 'number' } },
b: { type: 'array', items: { type: 'boolean' } },
},
} as Schema);
expect(opts2).toEqual({
a: [1, 2],
b: [true, false],
});
});
it('should handle options with aliases', () => {
const schema: Schema = {
properties: {
name: { type: 'array', alias: 'n' },
},
};
const opts = coerceTypesInOptions({ n: 'one,two' }, schema);
expect(opts).toEqual({
n: ['one', 'two'],
});
});
it('should handle oneOf', () => {
const opts = coerceTypesInOptions(
{ a: 'false' } as any,
{
properties: {
a: { oneOf: [{ type: 'object' }, { type: 'boolean' }] },
},
} as Schema
);
expect(opts).toEqual({
a: false,
});
});
it('should handle oneOf with enums inside', () => {
const opts = coerceTypesInOptions(
{ inspect: 'inspect' } as any,
{
properties: {
inspect: {
oneOf: [
{
type: 'string',
enum: ['inspect', 'inspect-brk'],
},
{
type: 'number',
},
{
type: 'boolean',
},
],
},
},
} as Schema
);
expect(opts).toEqual({
inspect: 'inspect',
});
});
it('should only coerce string values', () => {
const opts = coerceTypesInOptions(
{ a: true } as any,
{
properties: {
a: { oneOf: [{ type: 'boolean' }, { type: 'number' }] },
},
} as Schema
);
expect(opts).toEqual({
a: true,
});
});
});
describe('convertToCamelCase', () => {
it('should convert dash case to camel case', () => {
expect(
convertToCamelCase(
yargsParser(['--one-two', '1'], {
number: ['oneTwo'],
})
)
).toEqual({
_: [],
oneTwo: 1,
});
});
it('should not convert camel case', () => {
expect(
convertToCamelCase(
yargsParser(['--oneTwo', '1'], {
number: ['oneTwo'],
})
)
).toEqual({
_: [],
oneTwo: 1,
});
});
it('should handle mixed case', () => {
expect(
convertToCamelCase(
yargsParser(['--one-Two', '1'], {
number: ['oneTwo'],
})
)
).toEqual({
_: [],
oneTwo: 1,
});
});
});
describe('convertAliases', () => {
it('should replace aliases with actual keys', () => {
expect(
convertAliases(
{ d: 'test' },
{
properties: { directory: { type: 'string', alias: 'd' } },
required: [],
description: '',
},
true
)
).toEqual({ directory: 'test' });
});
it('should replace aliases defined in aliases with actual keys', () => {
expect(
convertAliases(
{ d: 'test' },
{
properties: { directory: { type: 'string', aliases: ['d'] } },
required: [],
description: '',
},
true
)
).toEqual({ directory: 'test' });
});
it('should filter unknown keys into the leftovers field when excludeUnmatched is true', () => {
expect(
convertAliases(
{ d: 'test' },
{
properties: { directory: { type: 'string' } },
required: [],
description: '',
},
true
)
).toEqual({
'--': [
{
name: 'd',
possible: [],
},
],
});
});
it('should not filter unknown keys into the leftovers field when excludeUnmatched is false', () => {
expect(
convertAliases(
{ d: 'test' },
{
properties: { directory: { type: 'string' } },
required: [],
description: '',
},
false
)
).toEqual({
d: 'test',
});
});
});
describe('lookupUnmatched', () => {
it('should populate the possible array with near matches', () => {
expect(
lookupUnmatched(
{
'--': [
{
name: 'directoy',
possible: [],
},
],
},
{
properties: { directory: { type: 'string' } },
required: [],
description: '',
}
)
).toEqual({
'--': [
{
name: 'directoy',
possible: ['directory'],
},
],
});
});
it('should NOT populate the possible array with far matches', () => {
expect(
lookupUnmatched(
{
'--': [
{
name: 'directoy',
possible: [],
},
],
},
{
properties: { faraway: { type: 'string' } },
required: [],
description: '',
}
)
).toEqual({
'--': [
{
name: 'directoy',
possible: [],
},
],
});
});
});
describe('setDefault', () => {
it('should set default values', () => {
const opts = setDefaults(
{ c: false },
{
properties: {
a: {
type: 'boolean',
},
b: {
type: 'boolean',
default: true,
},
c: {
type: 'boolean',
default: true,
},
},
}
);
expect(opts).toEqual({ b: true, c: false });
});
it('should set defaults in complex cases', () => {
const opts = setDefaults(
{ a: [{}, {}] },
{
properties: {
a: {
type: 'array',
items: {
type: 'object',
properties: {
key: {
type: 'string',
default: 'inner',
},
},
},
},
},
}
);
expect(opts).toEqual({ a: [{ key: 'inner' }, { key: 'inner' }] });
});
it('should set the default array value', () => {
const opts = setDefaults(
{},
{
properties: {
a: {
type: 'array',
items: {
type: 'object',
properties: {
key: {
type: 'string',
default: 'inner',
},
},
},
default: [],
},
},
}
);
expect(opts).toEqual({ a: [] });
});
it('should set the default object value', () => {
const opts = setDefaults(
{
a: {
key: 'value',
},
},
{
properties: {
a: {
type: 'object',
properties: {
key: {
type: 'string',
},
key2: {
type: 'string',
default: 'value2',
},
},
},
},
}
);
expect(opts).toEqual({ a: { key: 'value', key2: 'value2' } });
});
it('should not default object properties to {}', () => {
const opts = setDefaults(
{},
{
properties: {
a: {
type: 'object',
properties: {
key: {
type: 'string',
},
},
},
},
}
);
expect(opts).toEqual({});
});
it('should be able to set defaults for underlying properties', () => {
const opts = setDefaults(
{},
{
properties: {
a: {
type: 'object',
properties: {
minify: {
type: 'boolean',
default: true,
},
inlineCritical: {
type: 'boolean',
default: true,
},
},
additionalProperties: false,
},
},
}
);
expect(opts).toEqual({
a: {
minify: true,
inlineCritical: true,
},
});
});
it('should resolve types using refs', () => {
const opts = setDefaults(
{},
{
properties: {
a: {
$ref: '#/definitions/a',
},
},
definitions: {
a: {
type: 'boolean',
default: true,
},
},
}
);
expect(opts).toEqual({ a: true });
});
});
describe('convertSmartDefaultsIntoNamedParams', () => {
it('should use argv', () => {
const params = {};
convertSmartDefaultsIntoNamedParams(
params,
{
properties: {
a: {
type: 'string',
$default: {
$source: 'argv',
index: 0,
},
},
},
},
['argv-value'],
null,
null
);
expect(params).toEqual({ a: 'argv-value' });
});
it('should use projectName', () => {
const params = {};
convertSmartDefaultsIntoNamedParams(
params,
{
properties: {
a: {
type: 'string',
$default: {
$source: 'projectName',
},
},
},
},
[],
'myProject',
null
);
expect(params).toEqual({ a: 'myProject' });
});
it('should use relativeCwd to set path', () => {
const params = {};
convertSmartDefaultsIntoNamedParams(
params,
{
properties: {
a: {
type: 'string',
format: 'path',
visible: false,
},
},
},
[],
null,
'./somepath'
);
expect(params).toEqual({ a: './somepath' });
});
});
describe('validateOptsAgainstSchema', () => {
it('should throw if missing the required property', () => {
expect(() =>
validateOptsAgainstSchema(
{},
{
properties: {
a: {
type: 'boolean',
},
},
required: ['a'],
}
)
).toThrow("Required property 'a' is missing");
});
it('should throw if found an unknown property', () => {
expect(() =>
validateOptsAgainstSchema(
{
a: true,
b: false,
},
{
properties: {
a: {
type: 'boolean',
},
},
additionalProperties: false,
}
)
).toThrow("'b' is not found in schema");
});
describe('primitive types', () => {
it("should throw if the type doesn't match", () => {
expect(() =>
validateOptsAgainstSchema(
{ a: 'string' },
{
properties: {
a: {
type: 'boolean',
},
},
}
)
).toThrow(
"Property 'a' does not match the schema. 'string' should be a 'boolean'."
);
});
it('should not throw if the schema type is absent', () => {
expect(() =>
validateOptsAgainstSchema(
{ a: 'string' },
{
properties: {
a: {
default: false,
},
},
}
)
).not.toThrow();
});
describe('string', () => {
it('should handle validating patterns', () => {
const schema = {
properties: {
a: {
type: 'string',
pattern: '^a',
},
},
};
expect(() =>
validateOptsAgainstSchema({ a: 'abc' }, schema)
).not.toThrow();
expect(() =>
validateOptsAgainstSchema({ a: 'xyz' }, schema)
).toThrowErrorMatchingInlineSnapshot(
`"Property 'a' does not match the schema. 'xyz' should match the pattern '^a'."`
);
});
it('should handle validating minLength', () => {
const schema = {
properties: {
a: {
type: 'string',
minLength: 2,
},
},
};
expect(() =>
validateOptsAgainstSchema({ a: 'a' }, schema)
).toThrowErrorMatchingInlineSnapshot(
`"Property 'a' does not match the schema. 'a' (1 character(s)) should have at least 2 character(s)."`
);
expect(() =>
validateOptsAgainstSchema({ a: 'abc' }, schema)
).not.toThrow();
});
it('should handle validating maxLength', () => {
const schema = {
properties: {
a: {
type: 'string',
pattern: '^a',
},
},
};
expect(() =>
validateOptsAgainstSchema({ a: 'abc' }, schema)
).not.toThrow();
expect(() =>
validateOptsAgainstSchema({ a: 'xyz' }, schema)
).toThrowErrorMatchingInlineSnapshot(
`"Property 'a' does not match the schema. 'xyz' should match the pattern '^a'."`
);
});
});
describe('number', () => {
it('should handle validating multiples of', () => {
const schema = {
properties: {
a: {
type: 'number',
multipleOf: 3,
},
},
};
expect(() =>
validateOptsAgainstSchema({ a: 6 }, schema)
).not.toThrow();
expect(() =>
validateOptsAgainstSchema({ a: 5 }, schema)
).toThrowErrorMatchingInlineSnapshot(
`"Property 'a' does not match the schema. 5 should be a multiple of 3."`
);
});
it('should handle validating minimum', () => {
const schema = {
properties: {
a: {
type: 'number',
minimum: 3,
},
},
};
expect(() =>
validateOptsAgainstSchema({ a: 2 }, schema)
).toThrowErrorMatchingInlineSnapshot(
`"Property 'a' does not match the schema. 2 should be at least 3"`
);
expect(() =>
validateOptsAgainstSchema({ a: 3 }, schema)
).not.toThrow();
expect(() =>
validateOptsAgainstSchema({ a: 4 }, schema)
).not.toThrow();
});
it('should handle validating exclusive minimum', () => {
const schema = {
properties: {
a: {
type: 'number',
exclusiveMinimum: 3,
},
},
};
expect(() =>
validateOptsAgainstSchema({ a: 2 }, schema)
).toThrowErrorMatchingInlineSnapshot(
`"Property 'a' does not match the schema. 2 should be greater than 3"`
);
expect(() =>
validateOptsAgainstSchema({ a: 3 }, schema)
).toThrowErrorMatchingInlineSnapshot(
`"Property 'a' does not match the schema. 3 should be greater than 3"`
);
expect(() =>
validateOptsAgainstSchema({ a: 4 }, schema)
).not.toThrow();
});
it('should handle validating maximum', () => {
const schema = {
properties: {
a: {
type: 'number',
maximum: 3,
},
},
};
expect(() =>
validateOptsAgainstSchema({ a: 2 }, schema)
).not.toThrow();
expect(() =>
validateOptsAgainstSchema({ a: 3 }, schema)
).not.toThrow();
expect(() =>
validateOptsAgainstSchema({ a: 4 }, schema)
).toThrowErrorMatchingInlineSnapshot(
`"Property 'a' does not match the schema. 4 should be at most 3"`
);
});
it('should handle vavlidating exclusive maximum', () => {
const schema = {
properties: {
a: {
type: 'number',
exclusiveMaximum: 3,
},
},
};
expect(() =>
validateOptsAgainstSchema({ a: 2 }, schema)
).not.toThrow();
expect(() =>
validateOptsAgainstSchema({ a: 3 }, schema)
).toThrowErrorMatchingInlineSnapshot(
`"Property 'a' does not match the schema. 3 should be less than 3"`
);
expect(() =>
validateOptsAgainstSchema({ a: 4 }, schema)
).toThrowErrorMatchingInlineSnapshot(
`"Property 'a' does not match the schema. 4 should be less than 3"`
);
});
});
});
it('should handle one of', () => {
expect(() =>
validateOptsAgainstSchema(
{ a: 'string' },
{
properties: {
a: {
oneOf: [
{
type: 'string',
},
{
type: 'boolean',
},
],
},
},
}
)
).not.toThrow();
});
it('should handle oneOf with factorized type', () => {
const schema = {
properties: {
a: {
type: 'number',
oneOf: [{ multipleOf: 5 }, { multipleOf: 3 }],
},
},
};
expect(() => validateOptsAgainstSchema({ a: 10 }, schema)).not.toThrow();
expect(() => validateOptsAgainstSchema({ a: 15 }, schema)).toThrow();
});
it('should handle anyOf', () => {
const schema = {
properties: {
a: {
type: 'number',
anyOf: [{ multipleOf: 5 }, { multipleOf: 3 }],
},
},
};
expect(() => validateOptsAgainstSchema({ a: 4 }, schema)).toThrow();
expect(() => validateOptsAgainstSchema({ a: 10 }, schema)).not.toThrow();
expect(() => validateOptsAgainstSchema({ a: 15 }, schema)).not.toThrow();
});
it('should handle all of', () => {
const schema = {
properties: {
a: {
type: 'number',
allOf: [{ multipleOf: 5 }, { multipleOf: 3 }],
},
},
};
expect(() => validateOptsAgainstSchema({ a: 4 }, schema)).toThrow();
expect(() => validateOptsAgainstSchema({ a: 10 }, schema)).toThrow();
expect(() => validateOptsAgainstSchema({ a: 15 }, schema)).not.toThrow();
});
it('should handle one of with string lengths', () => {
expect(() =>
validateOptsAgainstSchema(
{ a: 'nrwl' },
{
properties: {
a: {
type: 'string',
oneOf: [
{
maxLength: 0,
},
{
minLength: 1,
},
],
},
},
}
)
).not.toThrow();
});
it('should handle oneOf properties explicit types', () => {
expect(() =>
validateOptsAgainstSchema(
{ a: true },
{
properties: {
a: {
type: 'number',
oneOf: [{ type: 'string' }, { type: 'boolean' }],
},
},
}
)
).not.toThrow();
});
it('should handle oneOf properties with enums', () => {
// matching enum value
expect(() =>
validateOptsAgainstSchema(
{ a: 'inspect' },
{
properties: {
a: {
oneOf: [
{
type: 'string',
enum: ['inspect', 'inspect-brk'],
},
{
type: 'boolean',
},
],
},
},
}
)
).not.toThrow();
// matching oneOf value
expect(() =>
validateOptsAgainstSchema(
{ a: true },
{
properties: {
a: {
oneOf: [
{
type: 'string',
enum: ['inspect', 'inspect-brk'],
},
{
type: 'boolean',
},
],
},
},
}
)
).not.toThrow();
// non-matching enum value
expect(() =>
validateOptsAgainstSchema(
{ a: 'abc' },
{
properties: {
a: {
oneOf: [
{
type: 'string',
enum: ['inspect', 'inspect-brk'],
},
{
type: 'boolean',
},
],
},
},
}
)
).toThrow();
});
it("should throw if the type doesn't match (arrays)", () => {
expect(() =>
validateOptsAgainstSchema(
{ a: ['string', 123] },
{
properties: {
a: {
type: 'array',
items: {
type: 'string',
},
},
},
}
)
).toThrow(
"Property 'a' does not match the schema. '123' should be a 'string'."
);
});
it("should throw if the type doesn't match (objects)", () => {
expect(() =>
validateOptsAgainstSchema(
{ a: { key: 'string' } },
{
properties: {
a: {
type: 'object',
properties: {
key: {
type: 'boolean',
},
},
},
},
}
)
).toThrow(
"Property 'key' does not match the schema. 'string' should be a 'boolean'."
);
});
it('should resolve types using refs', () => {
expect(() =>
validateOptsAgainstSchema(
{ key: 'string' },
{
properties: {
key: {
$ref: '#/definitions/key',
},
},
definitions: {
key: {
type: 'boolean',
},
},
}
)
).toThrow(
"Property 'key' does not match the schema. 'string' should be a 'boolean'."
);
});
});
describe('warnDeprecations', () => {
beforeEach(() => {
jest.spyOn(logger, 'warn').mockImplementation(() => {});
});
it('should not log a warning when an option marked as deprecated is not specified', () => {
warnDeprecations(
{ b: true },
{
properties: {
a: {
type: 'boolean',
'x-deprecated': true,
},
b: {
type: 'boolean',
},
},
}
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('should log a warning when an option marked as deprecated is specified', () => {
warnDeprecations(
{ a: true },
{
properties: {
a: {
type: 'boolean',
'x-deprecated': true,
},
},
}
);
expect(logger.warn).toHaveBeenCalledWith('Option "a" is deprecated.');
});
it('should log a warning with the deprecation notice when x-deprecated is a string', () => {
warnDeprecations(
{ a: true },
{
properties: {
a: {
type: 'boolean',
'x-deprecated':
'Deprecated since version x.x.x. Use "b" instead.',
},
},
}
);
expect(logger.warn).toHaveBeenCalledWith(
'Option "a" is deprecated: Deprecated since version x.x.x. Use "b" instead.'
);
});
});
describe('applyVerbosity', () => {
const isVerbose = true;
it('should not apply verbose if additionalProperties is false and verbose is not in schema', () => {
const options = {};
applyVerbosity(
options,
{ additionalProperties: false, properties: {} },
isVerbose
);
expect(options).toEqual({});
});
it('should apply verbose if additionalProperties is true and isVerbose is truthy', () => {
const options = {};
applyVerbosity(
options,
{ additionalProperties: true, properties: {} },
isVerbose
);
expect(options).toEqual({ verbose: isVerbose });
});
it('should apply verbose if additionalProperties is false but verbose is in schema and isVerbose is truthy', () => {
const options = {};
applyVerbosity(
options,
{
additionalProperties: false,
properties: { verbose: {} },
},
isVerbose
);
expect(options).toEqual({ verbose: isVerbose });
});
it('should not apply verbose if isVerbose is falsy', () => {
const options = {};
applyVerbosity(
options,
{
additionalProperties: false,
properties: { verbose: {} },
},
false
);
expect(options).toEqual({});
});
});
});