Skip to content

Commit 93b506f

Browse files
committed
fix(@angular/cli): apply default to array types
This commit fixes an issue where the `default` option was not being applied to `array` type options in yargs. This seemingly minor change required refactoring in some tests, which revealed that a `.coerce` validation was incorrectly throwing an error on failure. The validation logic was moved to a `.check` to ensure proper error handling and prevent unexpected failures.
1 parent f8ad0f9 commit 93b506f

File tree

2 files changed

+91
-89
lines changed

2 files changed

+91
-89
lines changed

packages/angular/cli/src/command-builder/utilities/json-schema.ts

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,44 @@ export interface Option extends YargsOptions {
5151
itemValueType?: 'string';
5252
}
5353

54+
function checkStringMap(keyValuePairOptions: Set<string>, args: Arguments): boolean {
55+
for (const key of keyValuePairOptions) {
56+
const value = args[key];
57+
if (!value) {
58+
continue;
59+
}
60+
61+
if (typeof value === 'object' && !Array.isArray(value)) {
62+
// value has been parsed.
63+
continue;
64+
}
65+
66+
// Invalid cases
67+
if (!Array.isArray(value)) {
68+
throw new Error(
69+
`Invalid value for argument: ${key}, ` + `Given: '${value}', Expected key=value pair`,
70+
);
71+
}
72+
73+
for (const pair of value) {
74+
if (pair === undefined) {
75+
continue;
76+
}
77+
78+
if (!pair.includes('=')) {
79+
throw new Error(
80+
`Invalid value for argument: ${key}, Given: '${pair}', Expected key=value pair`,
81+
);
82+
}
83+
}
84+
}
85+
86+
return true;
87+
}
88+
5489
function coerceToStringMap(
55-
dashedName: string,
5690
value: (string | undefined)[],
57-
): Record<string, string> | Promise<never> {
91+
): Record<string, string> | (string | undefined)[] {
5892
const stringMap: Record<string, string> = {};
5993
for (const pair of value) {
6094
// This happens when the flag isn't passed at all.
@@ -64,18 +98,12 @@ function coerceToStringMap(
6498

6599
const eqIdx = pair.indexOf('=');
66100
if (eqIdx === -1) {
67-
// TODO: Remove workaround once yargs properly handles thrown errors from coerce.
68-
// Right now these sometimes end up as uncaught exceptions instead of proper validation
69-
// errors with usage output.
70-
return Promise.reject(
71-
new Error(
72-
`Invalid value for argument: ${dashedName}, Given: '${pair}', Expected key=value pair`,
73-
),
74-
);
101+
// In the case it is not valid skip processing this option and handle the error in `checkStringMap`
102+
return value;
75103
}
104+
76105
const key = pair.slice(0, eqIdx);
77-
const value = pair.slice(eqIdx + 1);
78-
stringMap[key] = value;
106+
stringMap[key] = pair.slice(eqIdx + 1);
79107
}
80108

81109
return stringMap;
@@ -184,6 +212,7 @@ export async function parseJsonSchemaToOptions(
184212
if (current.default !== undefined) {
185213
switch (types[0]) {
186214
case 'string':
215+
case 'array':
187216
if (typeof current.default == 'string') {
188217
defaultValue = current.default;
189218
}
@@ -308,7 +337,7 @@ export function addSchemaOptionsToCommand<T>(
308337
}
309338

310339
if (itemValueType) {
311-
keyValuePairOptions.add(name);
340+
keyValuePairOptions.add(dashedName);
312341
}
313342

314343
const sharedOptions: YargsOptions & PositionalOptions = {
@@ -317,7 +346,7 @@ export function addSchemaOptionsToCommand<T>(
317346
description,
318347
deprecated,
319348
choices,
320-
coerce: itemValueType ? coerceToStringMap.bind(null, dashedName) : undefined,
349+
coerce: itemValueType ? coerceToStringMap : undefined,
321350
// This should only be done when `--help` is used otherwise default will override options set in angular.json.
322351
...(includeDefaultValues ? { default: defaultVal } : {}),
323352
};
@@ -341,6 +370,11 @@ export function addSchemaOptionsToCommand<T>(
341370
}
342371
}
343372

373+
// Valid key/value options
374+
if (keyValuePairOptions.size) {
375+
localYargs.check(checkStringMap.bind(null, keyValuePairOptions), false);
376+
}
377+
344378
// Handle options which have been defined in the schema with `no` prefix.
345379
if (booleanOptionsWithNoPrefix.size) {
346380
localYargs.middleware((options: Arguments) => {

packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts

Lines changed: 43 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -11,90 +11,56 @@ import yargs from 'yargs';
1111

1212
import { addSchemaOptionsToCommand, parseJsonSchemaToOptions } from './json-schema';
1313

14-
const YError = (() => {
15-
try {
16-
const y = yargs().strict().fail(false).exitProcess(false).parse(['--forced-failure']);
17-
} catch (e) {
18-
if (!(e instanceof Error)) {
19-
throw new Error('Unexpected non-Error thrown');
20-
}
21-
22-
return e.constructor as typeof Error;
23-
}
24-
throw new Error('Expected parse to fail');
25-
})();
26-
27-
interface ParseFunction {
28-
(argv: string[]): unknown;
29-
}
30-
31-
function withParseForSchema(
32-
jsonSchema: json.JsonObject,
33-
{
34-
interactive = true,
35-
includeDefaultValues = true,
36-
}: { interactive?: boolean; includeDefaultValues?: boolean } = {},
37-
): ParseFunction {
38-
let actualParse: ParseFunction = () => {
39-
throw new Error('Called before init');
40-
};
41-
const parse: ParseFunction = (args) => {
42-
return actualParse(args);
43-
};
44-
45-
beforeEach(async () => {
46-
const registry = new schema.CoreSchemaRegistry();
47-
const options = await parseJsonSchemaToOptions(registry, jsonSchema, interactive);
48-
49-
actualParse = async (args: string[]) => {
50-
// Create a fresh yargs for each call. The yargs object is stateful and
51-
// calling .parse multiple times on the same instance isn't safe.
52-
const localYargs = yargs().exitProcess(false).strict().fail(false);
53-
addSchemaOptionsToCommand(localYargs, options, includeDefaultValues);
54-
14+
describe('parseJsonSchemaToOptions', () => {
15+
describe('without required fields in schema', () => {
16+
const parse = async (args: string[]) => {
5517
// Yargs only exposes the parse errors as proper errors when using the
5618
// callback syntax. This unwraps that ugly workaround so tests can just
5719
// use simple .toThrow/.toEqual assertions.
5820
return localYargs.parseAsync(args);
5921
};
60-
});
61-
62-
return parse;
63-
}
6422

65-
describe('parseJsonSchemaToOptions', () => {
66-
describe('without required fields in schema', () => {
67-
const parse = withParseForSchema({
68-
'type': 'object',
69-
'properties': {
70-
'maxSize': {
71-
'type': 'number',
72-
},
73-
'ssr': {
74-
'type': 'string',
75-
'enum': ['always', 'surprise-me', 'never'],
76-
},
77-
'arrayWithChoices': {
78-
'type': 'array',
79-
'items': {
80-
'type': 'string',
81-
'enum': ['always', 'never'],
23+
let localYargs: yargs.Argv<unknown>;
24+
beforeEach(async () => {
25+
// Create a fresh yargs for each call. The yargs object is stateful and
26+
// calling .parse multiple times on the same instance isn't safe.
27+
localYargs = yargs().exitProcess(false).strict().fail(false).wrap(1_000);
28+
const jsonSchema = {
29+
'type': 'object',
30+
'properties': {
31+
'maxSize': {
32+
'type': 'number',
8233
},
83-
},
84-
'extendable': {
85-
'type': 'object',
86-
'properties': {},
87-
'additionalProperties': {
34+
'ssr': {
8835
'type': 'string',
36+
'enum': ['always', 'surprise-me', 'never'],
8937
},
90-
},
91-
'someDefine': {
92-
'type': 'object',
93-
'additionalProperties': {
94-
'type': 'string',
38+
'arrayWithChoices': {
39+
'type': 'array',
40+
'default': 'default-array',
41+
'items': {
42+
'type': 'string',
43+
'enum': ['always', 'never', 'default-array'],
44+
},
45+
},
46+
'extendable': {
47+
'type': 'object',
48+
'properties': {},
49+
'additionalProperties': {
50+
'type': 'string',
51+
},
52+
},
53+
'someDefine': {
54+
'type': 'object',
55+
'additionalProperties': {
56+
'type': 'string',
57+
},
9558
},
9659
},
97-
},
60+
};
61+
const registry = new schema.CoreSchemaRegistry();
62+
const options = await parseJsonSchemaToOptions(registry, jsonSchema, false);
63+
addSchemaOptionsToCommand(localYargs, options, true);
9864
});
9965

10066
describe('type=number', () => {
@@ -123,6 +89,10 @@ describe('parseJsonSchemaToOptions', () => {
12389
/Argument: array-with-choices, Given: "yes", Choices:/,
12490
);
12591
});
92+
93+
it('should add default value to help', async () => {
94+
expect(await localYargs.getHelp()).toContain('[default: "default-array"]');
95+
});
12696
});
12797

12898
describe('type=string, enum', () => {
@@ -150,11 +120,9 @@ describe('parseJsonSchemaToOptions', () => {
150120

151121
it('rejects invalid values for string maps', async () => {
152122
await expectAsync(parse(['--some-define', 'foo'])).toBeRejectedWithError(
153-
YError,
154123
/Invalid value for argument: some-define, Given: 'foo', Expected key=value pair/,
155124
);
156125
await expectAsync(parse(['--some-define', '42'])).toBeRejectedWithError(
157-
YError,
158126
/Invalid value for argument: some-define, Given: '42', Expected key=value pair/,
159127
);
160128
});

0 commit comments

Comments
 (0)