Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Report config name in error messages #128

Merged
merged 11 commits into from
Apr 1, 2024
99 changes: 89 additions & 10 deletions src/config-array.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,70 @@ const CONFIG_TYPES = new Set(['array', 'function']);

const FILES_AND_IGNORES_SCHEMA = new ObjectSchema(filesAndIgnoresSchema);

/**
* Wrapper error for config validation errors that adds a name to the front of the
* error message.
*/
class ConfigError extends Error {

/**
* Creates a new instance.
* @param {string} name The config object name causing the error.
* @param {number} index The index of the config object in the array.
* @param {Error} source The source error.
*/
constructor(name, index, source) {
super(`Config ${name}: ${source.message}`, { cause: source });

// copy over custom properties that aren't represented
for (const key of Object.keys(source)) {
if (!(key in this)) {
this[key] = source[key];
}
}

/**
* The name of the error.
* @type {string}
* @readonly
*/
this.name = 'ConfigError';

/**
* The index of the config object in the array.
* @type {number}
* @readonly
*/
this.index = index;
}
}

/**
* Gets the name of a config object.
* @param {object} config The config object to get the name of.
* @returns {string} The name of the config object.
*/
function getConfigName(config) {
if (typeof config.name === 'string' && config.name) {
return `"${config.name}"`;
}

return '(unnamed)';
}


/**
* Rethrows a config error with additional information about the config object.
* @param {object} config The config object to get the name of.
* @param {number} index The index of the config object in the array.
* @param {Error} error The error to rethrow.
* @throws {ConfigError} When the error is rethrown for a config.
*/
function rethrowConfigError(config, index, error) {
const configName = getConfigName(config, index);
nzakas marked this conversation as resolved.
Show resolved Hide resolved
throw new ConfigError(configName, index, error);
}

/**
* Shorthand for checking if a value is a string.
* @param {any} value The value to check.
Expand All @@ -43,23 +107,34 @@ function isString(value) {
}

/**
* Asserts that the files and ignores keys of a config object are valid as per base schema.
* @param {object} config The config object to check.
* Creates a function that asserts that the files and ignores keys
* of a config object are valid as per base schema.
* @param {Object} config The config object to check.
* @param {number} index The index of the config object in the array.
* @returns {void}
* @throws {TypeError} If the files and ignores keys of a config object are not valid.
* @throws {ConfigError} If the files and ignores keys of a config object are not valid.
*/
function assertValidFilesAndIgnores(config) {
function assertValidFilesAndIgnores(config, index) {

if (!config || typeof config !== 'object') {
return;
}

const validateConfig = { };

if ('files' in config) {
validateConfig.files = config.files;
}

if ('ignores' in config) {
validateConfig.ignores = config.ignores;
}
FILES_AND_IGNORES_SCHEMA.validate(validateConfig);

try {
FILES_AND_IGNORES_SCHEMA.validate(validateConfig);
} catch (validationError) {
rethrowConfigError(config, index, validationError);
}
}

/**
Expand Down Expand Up @@ -388,7 +463,7 @@ export class ConfigArray extends Array {
/**
* Tracks if the array has been normalized.
* @property isNormalized
* @type boolean
* @type {boolean}
* @private
*/
this[ConfigArraySymbol.isNormalized] = normalized;
Expand All @@ -407,7 +482,7 @@ export class ConfigArray extends Array {
* The path of the config file that this array was loaded from.
* This is used to calculate filename matches.
* @property basePath
* @type string
* @type {string}
*/
this.basePath = basePath;

Expand All @@ -416,14 +491,14 @@ export class ConfigArray extends Array {
/**
* The supported config types.
* @property configTypes
* @type Array<string>
* @type {Array<string>}
*/
this.extraConfigTypes = Object.freeze([...extraConfigTypes]);

/**
* A cache to store calculated configs for faster repeat lookup.
* @property configCache
* @type Map
* @type {Map<string, Object>}
* @private
*/
this[ConfigArraySymbol.configCache] = new Map();
Expand Down Expand Up @@ -809,7 +884,11 @@ export class ConfigArray extends Array {
// otherwise construct the config

finalConfig = matchingConfigIndices.reduce((result, index) => {
return this[ConfigArraySymbol.schema].merge(result, this[index]);
try {
return this[ConfigArraySymbol.schema].merge(result, this[index]);
} catch (validationError) {
rethrowConfigError(this[index], index, validationError);
}
}, {}, this);

finalConfig = this[ConfigArraySymbol.finalizeConfig](finalConfig);
Expand Down
52 changes: 46 additions & 6 deletions tests/config-array.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,10 +369,11 @@ describe('ConfigArray', () => {
title: 'should throw an error when files contains an invalid element',
configs: [
{
name: '',
files: ['*.js', undefined]
}
],
expectedError: 'Key "files": Items must be a string, a function, or an array of strings and functions.'
expectedError: 'Config (unnamed): Key "files": Items must be a string, a function, or an array of strings and functions.'
});

testValidationError({
Expand All @@ -382,28 +383,30 @@ describe('ConfigArray', () => {
ignores: undefined
}
],
expectedError: 'Key "ignores": Expected value to be an array.'
expectedError: 'Config (unnamed): Key "ignores": Expected value to be an array.'
});

testValidationError({
title: 'should throw an error when a global ignores contains an invalid element',
configs: [
{
name: 'foo',
ignores: ['ignored/**', -1]
}
],
expectedError: 'Key "ignores": Expected array to only contain strings and functions.'
expectedError: 'Config "foo": Key "ignores": Expected array to only contain strings and functions.'
});

testValidationError({
title: 'should throw an error when a non-global ignores contains an invalid element',
configs: [
{
name: 'foo',
files: ['*.js'],
ignores: [-1]
}
],
expectedError: 'Key "ignores": Expected array to only contain strings and functions.'
expectedError: 'Config "foo": Key "ignores": Expected array to only contain strings and functions.'
});

it('should throw an error when a config is not an object', async () => {
Expand All @@ -423,7 +426,7 @@ describe('ConfigArray', () => {

});

it('should throw an error when name is not a string', async () => {
it('should throw an error when base config name is not a string', async () => {
configs = new ConfigArray([
{
files: ['**'],
Expand All @@ -436,7 +439,25 @@ describe('ConfigArray', () => {
configs.getConfig(path.resolve(basePath, 'foo.js'));
})
.to
.throw('Key "name": Property must be a string.');
.throw('Config (unnamed): Key "name": Property must be a string.');

});

it('should throw an error when additional config name is not a string', async () => {
configs = new ConfigArray([{}], { basePath });
configs.push(
{
files: ['**'],
name: true
}
);
await configs.normalize();

expect(() => {
configs.getConfig(path.resolve(basePath, 'foo.js'));
})
.to
.throw('Config (unnamed): Key "name": Property must be a string.');

});
});
Expand Down Expand Up @@ -732,6 +753,25 @@ describe('ConfigArray', () => {
expect(config.defs.universal).to.be.true;
});

it('should throw an error when defs doesn\'t pass validation', async () => {
const configs = new ConfigArray([
{
files: ['**/*.js'],
defs: 'foo',
name: 'bar'
}
], { basePath, schema });

await configs.normalize();

const filename = path.resolve(basePath, 'foo.js');
expect(() => {
configs.getConfig(filename);
})
.to
.throw('Config "bar": Key "defs": Object expected.');
});

it('should calculate correct config when passed JS filename that matches a function config returning an array', () => {
const filename1 = path.resolve(basePath, 'baz.test.js');
const config1 = configs.getConfig(filename1);
Expand Down