Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions packages/nx/src/utils/params.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1590,6 +1590,132 @@ describe('params', () => {
);
});

it('should validate arrays with tuple items (items as array)', () => {
expect(() =>
validateOptsAgainstSchema(
{ a: ['junit', { suiteName: 'MyApp' }] },
{
properties: {
a: {
type: 'array',
minItems: 1,
maxItems: 2,
items: [{ type: 'string' }, { type: 'object' }],
},
},
}
)
).not.toThrow();
});

it('should throw when tuple item type does not match (items as array)', () => {
expect(() =>
validateOptsAgainstSchema(
{ a: [123, { suiteName: 'MyApp' }] },
{
properties: {
a: {
type: 'array',
minItems: 1,
maxItems: 2,
items: [{ type: 'string' }, { type: 'object' }],
},
},
}
)
).toThrow("Property 'a' does not match the schema.");
});

it('should pass when array length equals minItems', () => {
expect(() =>
validateOptsAgainstSchema(
{ a: ['junit'] },
{
properties: {
a: {
type: 'array',
minItems: 1,
maxItems: 2,
items: [{ type: 'string' }, { type: 'object' }],
},
},
}
)
).not.toThrow();
});

it('should throw when array length is below minItems', () => {
expect(() =>
validateOptsAgainstSchema(
{ a: [] },
{
properties: {
a: {
type: 'array',
minItems: 1,
maxItems: 2,
items: [{ type: 'string' }, { type: 'object' }],
},
},
}
)
).toThrow("Property 'a' does not match the schema.");
});

it('should throw when array length exceeds maxItems', () => {
expect(() =>
validateOptsAgainstSchema(
{ a: ['junit', { suiteName: 'MyApp' }, 'html'] },
{
properties: {
a: {
type: 'array',
minItems: 1,
maxItems: 2,
items: [{ type: 'string' }, { type: 'object' }],
},
},
}
)
).toThrow("Property 'a' does not match the schema.");
});

it('should validate reporters with oneOf including tuple items (issue scenario)', () => {
expect(() =>
validateOptsAgainstSchema(
{ reporters: [['junit', { suiteName: 'MyApp' }]] },
{
properties: {
reporters: {
type: 'array',
items: {
oneOf: [
{
anyOf: [{ type: 'string' }, { enum: ['junit', 'html'] }],
},
{
type: 'array',
minItems: 1,
maxItems: 2,
items: [
{
anyOf: [
{ type: 'string' },
{ enum: ['junit', 'html'] },
],
},
{ type: 'object' },
],
},
],
},
},
},
}
)
).not.toThrow();
});

it("should throw if the type doesn't match (objects)", () => {
expect(() =>
validateOptsAgainstSchema(
Expand Down
60 changes: 54 additions & 6 deletions packages/nx/src/utils/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ type PropertyDescription = {
oneOf?: PropertyDescription[];
anyOf?: PropertyDescription[];
allOf?: PropertyDescription[];
items?: any;
items?: PropertyDescription | PropertyDescription[];
additionalItems?: boolean | PropertyDescription;
alias?: string;
aliases?: string[];
description?: string;
Expand Down Expand Up @@ -53,6 +54,10 @@ type PropertyDescription = {
minLength?: number;
maxLength?: number;

// Arrays Only
minItems?: number;
maxItems?: number;

// Objects Only
patternProperties?: {
[pattern: string]: PropertyDescription;
Expand Down Expand Up @@ -156,7 +161,8 @@ function coerceType(prop: PropertyDescription | undefined, value: any) {
) {
return Number(value);
} else if (prop.type == 'array') {
return value.split(',').map((v) => coerceType(prop.items, v));
const itemSchema = Array.isArray(prop.items) ? undefined : prop.items;
return value.split(',').map((v) => coerceType(itemSchema, v));
} else {
return value;
}
Expand Down Expand Up @@ -487,9 +493,47 @@ function validateProperty(
}
} else if (Array.isArray(value)) {
if (schema.type !== 'array') throwInvalidSchema(propName, schema);
value.forEach((valueInArray) =>
validateProperty(propName, valueInArray, schema.items || {}, definitions)
);
if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
throwInvalidSchema(propName, schema);
}
if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
throwInvalidSchema(propName, schema);
}
if (Array.isArray(schema.items)) {
// Tuple validation: each item is validated against the corresponding positional schema
value.forEach((valueInArray, index) => {
if (index < (schema.items as PropertyDescription[]).length) {
validateProperty(
propName,
valueInArray,
(schema.items as PropertyDescription[])[index],
definitions
);
} else if (schema.additionalItems === false) {
throwInvalidSchema(propName, schema);
} else if (
schema.additionalItems &&
typeof schema.additionalItems === 'object'
) {
validateProperty(
propName,
valueInArray,
schema.additionalItems,
definitions
);
}
// If additionalItems is not specified or true, additional items are allowed
});
} else {
value.forEach((valueInArray) =>
validateProperty(
propName,
valueInArray,
(schema.items as PropertyDescription) || {},
definitions
)
);
}
} else if (value === null) {
// Special handling for null since typeof null === 'object' in JavaScript
// null is valid if schema.type is 'null' or if it's an array containing 'null'
Expand Down Expand Up @@ -841,7 +885,11 @@ export function getPromptsForSchema(
type: 'confirm',
message,
};
} else if (v.type === 'array' && v.items?.enum) {
} else if (
v.type === 'array' &&
!Array.isArray(v.items) &&
v.items?.enum
) {
v['x-prompt'] = {
type: 'multiselect',
items: v.items.enum,
Expand Down
Loading