diff --git a/jest.config.js b/jest.config.js index e2c30f16d..481190024 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,9 +8,7 @@ const projectDefault = { preset: 'ts-jest', moduleNameMapper: { ...mapValues(pathsToModuleNameMapper(compilerOptions.paths), v => path.join(__dirname, v)), - '^@stoplight/spectral-test-utils$': '/test-utils/node/index.ts', - '^nimma/fallbacks$': '/node_modules/nimma/dist/cjs/fallbacks/index.js', - '^nimma/legacy$': '/node_modules/nimma/dist/legacy/cjs/index.js', + '^@stoplight/spectral\\-test\\-utils$': '/test-utils/node/index.ts', }, testEnvironment: 'node', globals: { diff --git a/karma.conf.ts b/karma.conf.ts index bc18c36ec..34ca0e28a 100644 --- a/karma.conf.ts +++ b/karma.conf.ts @@ -1,6 +1,7 @@ // Karma configuration // Generated on Tue Jul 02 2019 17:18:30 GMT+0200 (Central European Summer Time) +import * as path from 'path'; import type { TransformCallback, TransformContext } from 'karma-typescript'; import type { Config } from 'karma'; @@ -14,7 +15,7 @@ module.exports = (config: Config): void => { frameworks: ['jasmine', 'karma-typescript'], // list of files / patterns to load in the browser - files: ['./__karma__/jest.ts', 'packages/*/src/**/*.ts'], + files: ['./__karma__/jest.ts', './test-utils/*.ts', 'packages/*/src/**/*.ts'], // list of files / patterns to exclude exclude: ['packages/cli/**', 'packages/ruleset-bundler/src/plugins/commonjs.ts', '**/*.jest.test.ts'], @@ -24,6 +25,7 @@ module.exports = (config: Config): void => { preprocessors: { 'packages/*/src/**/*.ts': ['karma-typescript'], './__karma__/**/*.ts': ['karma-typescript'], + './test-utils/*.ts': ['karma-typescript'], }, // @ts-expect-error: non-standard - karmaTypeScriptConfig @@ -35,6 +37,7 @@ module.exports = (config: Config): void => { resolve: { alias: { '@stoplight/spectral-test-utils': require.resolve('./test-utils/browser/index.js'), + '@stoplight/spectral-test-utils/matchers': path.join(__dirname, './test-utils/matchers.ts'), nimma: require.resolve('./node_modules/nimma/dist/legacy/cjs/index.js'), 'nimma/fallbacks': require.resolve('./node_modules/nimma/dist/legacy/cjs/fallbacks/index.js'), 'nimma/legacy': require.resolve('./node_modules/nimma/dist/legacy/cjs/index.js'), diff --git a/package.json b/package.json index 4f92b8ada..c0d6a2443 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,6 @@ "jest": "^27.4.3", "jest-mock": "^27.4.2", "jest-when": "^3.4.2", - "json-schema": "^0.4.0", "karma": "^6.1.1", "karma-chrome-launcher": "^3.1.0", "karma-jasmine": "^3.3.1", diff --git a/packages/cli/src/services/__tests__/linter.test.ts b/packages/cli/src/services/__tests__/linter.test.ts index 84ed44522..744941aec 100644 --- a/packages/cli/src/services/__tests__/linter.test.ts +++ b/packages/cli/src/services/__tests__/linter.test.ts @@ -1,8 +1,12 @@ +import '@stoplight/spectral-test-utils/matchers'; + import { join, resolve } from '@stoplight/path'; import nock from 'nock'; import * as yargs from 'yargs'; import { DiagnosticSeverity } from '@stoplight/types'; import { RulesetValidationError } from '@stoplight/spectral-core'; +import '@stoplight/spectral-test-utils/matchers'; +import AggregateError = require('es-aggregate-error'); import * as process from 'process'; import lintCommand from '../../commands/lint'; @@ -198,8 +202,32 @@ describe('Linter service', () => { }); it('fails trying to extend an invalid relative ruleset', () => { - return expect(run(`lint ${validCustomOas3SpecPath} -r ${invalidNestedRulesetPath}`)).rejects.toThrowError( - RulesetValidationError, + return expect( + run(`lint ${validCustomOas3SpecPath} -r ${invalidNestedRulesetPath}`), + ).rejects.toThrowAggregateError( + new AggregateError([ + new RulesetValidationError('must be equal to one of the allowed values', [ + 'rules', + 'rule-with-invalid-enum', + ]), + new RulesetValidationError('the rule must have at least "given" and "then" properties', [ + 'rules', + 'rule-without-given-nor-them', + ]), + new RulesetValidationError('allowed types are "style" and "validation"', [ + 'rules', + 'rule-with-invalid-enum', + 'type', + ]), + new RulesetValidationError('must be equal to one of the allowed values', [ + 'rules', + 'rule-without-given-nor-them', + ]), + new RulesetValidationError( + 'the value has to be one of: 0, 1, 2, 3 or "error", "warn", "info", "hint", "off"', + ['rules', 'rule-with-invalid-enum', 'severity'], + ), + ]), ); }); }); @@ -212,8 +240,30 @@ describe('Linter service', () => { }); it('outputs "invalid ruleset" error', () => { - return expect(run(`lint ${validOas3SpecPath} -r ${invalidRulesetPath}`)).rejects.toThrowError( - RulesetValidationError, + return expect(run(`lint ${validOas3SpecPath} -r ${invalidRulesetPath}`)).rejects.toThrowAggregateError( + new AggregateError([ + new RulesetValidationError('must be equal to one of the allowed values', [ + 'rules', + 'rule-with-invalid-enum', + ]), + new RulesetValidationError('the rule must have at least "given" and "then" properties', [ + 'rules', + 'rule-without-given-nor-them', + ]), + new RulesetValidationError('allowed types are "style" and "validation"', [ + 'rules', + 'rule-with-invalid-enum', + 'type', + ]), + new RulesetValidationError('must be equal to one of the allowed values', [ + 'rules', + 'rule-without-given-nor-them', + ]), + new RulesetValidationError( + 'the value has to be one of: 0, 1, 2, 3 or "error", "warn", "info", "hint", "off"', + ['rules', 'rule-with-invalid-enum', 'severity'], + ), + ]), ); }); diff --git a/packages/core/package.json b/packages/core/package.json index b93c49a2e..f75e00fda 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,11 +45,13 @@ "@stoplight/spectral-ref-resolver": "^1.0.0", "@stoplight/spectral-runtime": "^1.0.0", "@stoplight/types": "13.0.0", + "@types/es-aggregate-error": "^1.0.2", "@types/json-schema": "^7.0.7", "ajv": "^8.6.0", "ajv-errors": "~3.0.0", "ajv-formats": "~2.1.0", "blueimp-md5": "2.18.0", + "es-aggregate-error": "^1.0.7", "jsonpath-plus": "6.0.1", "lodash": "~4.17.21", "lodash.topath": "^4.5.2", diff --git a/packages/core/src/ruleset/__tests__/__fixtures__/overrides/formats.ts b/packages/core/src/ruleset/__tests__/__fixtures__/overrides/formats.ts index 1e5805cdb..fd6460068 100644 --- a/packages/core/src/ruleset/__tests__/__fixtures__/overrides/formats.ts +++ b/packages/core/src/ruleset/__tests__/__fixtures__/overrides/formats.ts @@ -22,7 +22,9 @@ const ruleset: RulesetDefinition = { then: { function: schema, functionOptions: { - type: 'number', + schema: { + type: 'number', + }, }, }, }, @@ -39,7 +41,9 @@ const ruleset: RulesetDefinition = { then: { function: schema, functionOptions: { - type: 'boolean', + schema: { + type: 'boolean', + }, }, }, }, diff --git a/packages/core/src/ruleset/__tests__/ruleset.test.ts b/packages/core/src/ruleset/__tests__/ruleset.test.ts index 2fdc3fccd..5116f320c 100644 --- a/packages/core/src/ruleset/__tests__/ruleset.test.ts +++ b/packages/core/src/ruleset/__tests__/ruleset.test.ts @@ -259,7 +259,7 @@ describe('Ruleset', () => { describe('error handling', () => { it('given empty ruleset, should throw a user friendly error', () => { expect(() => new Ruleset({})).toThrowError( - new RulesetValidationError('Ruleset must have rules or extends or overrides defined'), + new RulesetValidationError('Ruleset must have rules or extends or overrides defined', []), ); }); }); diff --git a/packages/core/src/ruleset/function.ts b/packages/core/src/ruleset/function.ts index 6741a0dce..2a680ab29 100644 --- a/packages/core/src/ruleset/function.ts +++ b/packages/core/src/ruleset/function.ts @@ -8,56 +8,58 @@ import type { JSONSchema7 } from 'json-schema'; import { printPath, PrintStyle, printValue } from '@stoplight/spectral-runtime'; -import { RulesetValidationError } from './validation'; +import { RulesetValidationError } from './validation/index'; import { IFunctionResult, JSONSchema, RulesetFunction, RulesetFunctionWithValidator } from '../types'; +import { isObject } from 'lodash'; +import AggregateError = require('es-aggregate-error'); const ajv = new Ajv({ allErrors: true, allowUnionTypes: true, strict: true, keywords: ['x-internal'] }); ajvErrors(ajv); addFormats(ajv); export class RulesetFunctionValidationError extends RulesetValidationError { - constructor(fn: string, errors: ErrorObject[]) { - const messages = errors.map(error => { - switch (error.keyword) { - case 'type': { - const path = printPath(error.instancePath.slice(1).split('/'), PrintStyle.Dot); - const values = Array.isArray(error.params.type) ? error.params.type.join(', ') : String(error.params.type); - - return `"${fn}" function and its "${path}" option accepts only the following types: ${values}`; - } - - case 'required': { - const missingProperty = (error as RequiredError).params.missingProperty; - const missingPropertyPath = - error.instancePath === '' - ? missingProperty - : printPath([...error.instancePath.slice(1).split('/'), missingProperty], PrintStyle.Dot); - - return `"${fn}" function is missing "${missingPropertyPath}" option`; - } - - case 'additionalProperties': { - const additionalProperty = (error as AdditionalPropertiesError).params.additionalProperty; - const additionalPropertyPath = - error.instancePath === '' - ? additionalProperty - : printPath([...error.instancePath.slice(1).split('/'), additionalProperty], PrintStyle.Dot); - - return `"${fn}" function does not support "${additionalPropertyPath}" option`; - } - - case 'enum': { - const path = printPath(error.instancePath.slice(1).split('/'), PrintStyle.Dot); - const values = (error as EnumError).params.allowedValues.map(printValue).join(', '); - - return `"${fn}" function and its "${path}" option accepts only the following values: ${values}`; - } - default: - return error.message; + constructor(fn: string, error: ErrorObject) { + super(RulesetFunctionValidationError.printMessage(fn, error), error.instancePath.slice(1).split('/')); + } + + private static printMessage(fn: string, error: ErrorObject): string { + switch (error.keyword) { + case 'type': { + const path = printPath(error.instancePath.slice(1).split('/'), PrintStyle.Dot); + const values = Array.isArray(error.params.type) ? error.params.type.join(', ') : String(error.params.type); + + return `"${fn}" function and its "${path}" option accepts only the following types: ${values}`; + } + + case 'required': { + const missingProperty = (error as RequiredError).params.missingProperty; + const missingPropertyPath = + error.instancePath === '' + ? missingProperty + : printPath([...error.instancePath.slice(1).split('/'), missingProperty], PrintStyle.Dot); + + return `"${fn}" function is missing "${missingPropertyPath}" option`; } - }); - super(messages.join('\n')); + case 'additionalProperties': { + const additionalProperty = (error as AdditionalPropertiesError).params.additionalProperty; + const additionalPropertyPath = + error.instancePath === '' + ? additionalProperty + : printPath([...error.instancePath.slice(1).split('/'), additionalProperty], PrintStyle.Dot); + + return `"${fn}" function does not support "${additionalPropertyPath}" option`; + } + + case 'enum': { + const path = printPath(error.instancePath.slice(1).split('/'), PrintStyle.Dot); + const values = (error as EnumError).params.allowedValues.map(printValue).join(', '); + + return `"${fn}" function and its "${path}" option accepts only the following values: ${values}`; + } + default: + return error.message ?? 'unknown error'; + } } } @@ -138,25 +140,27 @@ export function createRulesetFunction( Reflect.defineProperty(wrappedFn, 'name', { value: fn.name }); - const validOpts = new Set(); + const validOpts = new WeakSet(); wrappedFn.validator = function (o: unknown): asserts o is O { - if (validOpts.has(o)) return; // I don't like this. + if (isObject(o) && validOpts.has(o)) return; // I don't like this. if (validateOptions(o)) { - validOpts.add(o); + if (isObject(o)) validOpts.add(o); return; } if (options === null) { - throw new TypeError(`"${fn.name || ''}" function does not accept any options`); + throw new RulesetValidationError(`"${fn.name || ''}" function does not accept any options`, []); } else if ( 'errors' in validateOptions && Array.isArray(validateOptions.errors) && validateOptions.errors.length > 0 ) { - throw new RulesetFunctionValidationError(fn.name || '', validateOptions.errors); + throw new AggregateError( + validateOptions.errors.map(error => new RulesetFunctionValidationError(fn.name || '', error)), + ); } else { - throw new Error(`"functionOptions" of "${fn.name || ''}" function must be valid`); + throw new RulesetValidationError(`"functionOptions" of "${fn.name || ''}" function must be valid`, []); } }; diff --git a/packages/core/src/ruleset/mergers/rules.ts b/packages/core/src/ruleset/mergers/rules.ts index 5140a3e9c..ab7933a4f 100644 --- a/packages/core/src/ruleset/mergers/rules.ts +++ b/packages/core/src/ruleset/mergers/rules.ts @@ -44,7 +44,7 @@ export function mergeRule( owner: existingRule.owner, }); } else { - assertValidRule(rule); + assertValidRule(rule, name); return new Rule(name, rule, ruleset); } diff --git a/packages/core/src/ruleset/meta/js-extensions.json b/packages/core/src/ruleset/meta/js-extensions.json index 1d497c334..e6c717d8c 100644 --- a/packages/core/src/ruleset/meta/js-extensions.json +++ b/packages/core/src/ruleset/meta/js-extensions.json @@ -40,12 +40,12 @@ }, "Format": { "$anchor": "format", - "spectral-runtime": "format", + "x-spectral-runtime": "format", "errorMessage": "must be a valid format" }, "Function": { "$anchor": "function", - "spectral-runtime": "function", + "x-spectral-runtime": "ruleset-function", "type": "object", "properties": { "function": true diff --git a/packages/core/src/ruleset/validation/__tests__/validation.test.ts b/packages/core/src/ruleset/validation/__tests__/validation.test.ts index 81e7715c5..9f9e2b160 100644 --- a/packages/core/src/ruleset/validation/__tests__/validation.test.ts +++ b/packages/core/src/ruleset/validation/__tests__/validation.test.ts @@ -1,15 +1,20 @@ -import { truthy } from '@stoplight/spectral-functions'; -import type { Format } from '../../format'; +import '@stoplight/spectral-test-utils/matchers'; import { assertValidRuleset, RulesetValidationError } from '../index'; +import AggregateError = require('es-aggregate-error'); import invalidRuleset from './__fixtures__/invalid-ruleset'; import validRuleset from './__fixtures__/valid-flat-ruleset'; +import type { Format } from '../../format'; import { RulesetDefinition, RulesetOverridesDefinition } from '../../types'; const formatA: Format = () => false; const formatB: Format = () => false; +function truthy() { + // no-op +} + describe('JS Ruleset Validation', () => { it('given primitive type, throws', () => { expect(assertValidRuleset.bind(null, null)).toThrow('Provided ruleset is not an object'); @@ -33,10 +38,18 @@ describe('JS Ruleset Validation', () => { }); it('given invalid ruleset, throws', () => { - expect(assertValidRuleset.bind(null, invalidRuleset)).toThrow( - new RulesetValidationError(`Error at #/rules/no-given-no-then: the rule must have at least "given" and "then" properties -Error at #/rules/rule-with-invalid-enum/type: allowed types are "style" and "validation" -Error at #/rules/rule-with-invalid-enum/severity: the value has to be one of: 0, 1, 2, 3 or "error", "warn", "info", "hint", "off"`), + expect(assertValidRuleset.bind(null, invalidRuleset)).toThrowAggregateError( + new AggregateError([ + new RulesetValidationError('the rule must have at least "given" and "then" properties', [ + '/rules/no-given-no-then', + ]), + new RulesetValidationError('allowed types are "style" and "validation"', [ + '#/rules/rule-with-invalid-enum/type', + ]), + new RulesetValidationError('the value has to be one of: 0, 1, 2, 3 or "error", "warn", "info", "hint", "off"', [ + '#/rules/rule-with-invalid-enum/severity', + ]), + ]), ); }); @@ -47,8 +60,8 @@ Error at #/rules/rule-with-invalid-enum/severity: the value has to be one of: 0, it.each([false, 2, null, 'foo', '12.foo.com'])( 'given invalid %s documentationUrl in a rule, throws', documentationUrl => { - expect(assertValidRuleset.bind(null, { documentationUrl, rules: {} })).toThrow( - new RulesetValidationError('Error at #/documentationUrl: must be a valid URL'), + expect(assertValidRuleset.bind(null, { documentationUrl, rules: {} })).toThrowAggregateError( + new AggregateError([new RulesetValidationError('must be a valid URL', ['documentationUrl'])]), ); expect( @@ -58,12 +71,14 @@ Error at #/rules/rule-with-invalid-enum/severity: the value has to be one of: 0, documentationUrl, given: '$', then: { - function: '', + function: truthy, }, }, }, }), - ).toThrow(new RulesetValidationError('Error at #/rules/rule/documentationUrl: must be a valid URL')); + ).toThrowAggregateError( + new AggregateError([new RulesetValidationError('must be a valid URL', ['rules', 'rule', 'documentationUrl'])]), + ); }, ); @@ -82,7 +97,7 @@ Error at #/rules/rule-with-invalid-enum/severity: the value has to be one of: 0, documentationUrl: 'https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md', given: '$', then: { - function: '', + function: truthy, }, }, }, @@ -90,21 +105,6 @@ Error at #/rules/rule-with-invalid-enum/severity: the value has to be one of: 0, ).not.toThrow(); }); - it.each([false, 2, null])('given invalid %s description in a ruleset, throws', description => { - expect(assertValidRuleset.bind(null, { description, rules: {} })).toThrow( - new RulesetValidationError('Error at #/description: must be string'), - ); - }); - - it('recognizes valid description in a ruleset', () => { - expect( - assertValidRuleset.bind(null, { - description: 'This is the ruleset description', - rules: {}, - }), - ).not.toThrow(); - }); - it.each(['error', 'warn', 'info', 'hint', 'off'])('recognizes human-readable %s severity', severity => { expect( assertValidRuleset.bind(null, { @@ -120,7 +120,7 @@ Error at #/rules/rule-with-invalid-enum/severity: the value has to be one of: 0, rule: { given: '$.info', then: { - function: 'truthy', + function: truthy, }, severity, }, @@ -146,19 +146,24 @@ Error at #/rules/rule-with-invalid-enum/severity: the value has to be one of: 0, ).not.toThrow(); }); - it.each<[unknown, string]>([ - [[[{ rules: {} }, 'test']], `Error at #/extends/0/1: allowed types are "off", "recommended" and "all"`], + it.each<[unknown, RulesetValidationError[]]>([ + [ + [[{ rules: {} }, 'test']], + [new RulesetValidationError('allowed types are "off", "recommended" and "all"', ['extends', '0', '1'])], + ], [ [[{ rules: {} }, 'test'], 'foo'], - `Error at #/extends/1: must be a valid ruleset -Error at #/extends/0/1: allowed types are "off", "recommended" and "all"`, + [ + new RulesetValidationError('must be a valid ruleset', ['extends', '1']), + new RulesetValidationError('allowed types are "off", "recommended" and "all"', ['extends', '0', '1']), + ], ], - ])('recognizes invalid array-ish extends syntax %p', (_extends, message) => { + ])('recognizes invalid array-ish extends syntax %p', (_extends, errors) => { expect( assertValidRuleset.bind(null, { extends: _extends, }), - ).toThrow(new RulesetValidationError(message)); + ).toThrowAggregateError(new AggregateError(errors)); }); it('recognizes valid ruleset formats syntax', () => { @@ -173,18 +178,20 @@ Error at #/extends/0/1: allowed types are "off", "recommended" and "all"`, it.each([ [ [2, 'a'], - `Error at #/formats/0: must be a valid format -Error at #/formats/1: must be a valid format`, + new AggregateError([ + new RulesetValidationError('must be a valid format', ['formats', '0']), + new RulesetValidationError('must be a valid format', ['formats', '1']), + ]), ], - [2, 'Error at #/formats: must be an array of formats'], - [[''], 'Error at #/formats/0: must be a valid format'], + [2, new AggregateError([new RulesetValidationError('must be an array of formats', ['formats'])])], + [[''], new AggregateError([new RulesetValidationError('must be a valid format', ['formats', '0'])])], ])('recognizes invalid ruleset %p formats syntax', (formats, error) => { expect( assertValidRuleset.bind(null, { formats, rules: {}, }), - ).toThrow(new RulesetValidationError(error)); + ).toThrowAggregateError(error); }); it('recognizes valid rule formats syntax', () => { @@ -195,7 +202,7 @@ Error at #/formats/1: must be a valid format`, rule: { given: '$.info', then: { - function: 'truthy', + function: truthy, }, formats: [formatA], }, @@ -207,10 +214,12 @@ Error at #/formats/1: must be a valid format`, it.each([ [ [2, 'a'], - `Error at #/rules/rule/formats/0: must be a valid format -Error at #/rules/rule/formats/1: must be a valid format`, + new AggregateError([ + new RulesetValidationError('must be a valid format', ['rules', 'rule', 'formats', '0']), + new RulesetValidationError('must be a valid format', ['rules', 'rule', 'formats', '1']), + ]), ], - [2, 'Error at #/rules/rule/formats: must be an array of formats'], + [2, new AggregateError([new RulesetValidationError('must be an array of formats', ['rules', 'rule', 'formats'])])], ])('recognizes invalid rule %p formats syntax', (formats, error) => { expect( assertValidRuleset.bind(null, { @@ -218,13 +227,13 @@ Error at #/rules/rule/formats/1: must be a valid format`, rule: { given: '$.info', then: { - function: 'truthy', + function: truthy, }, formats, }, }, }), - ).toThrow(new RulesetValidationError(error)); + ).toThrowAggregateError(error); }); describe('overrides validation', () => { @@ -233,7 +242,7 @@ Error at #/rules/rule/formats/1: must be a valid format`, assertValidRuleset.bind(null, { overrides: null, }), - ).toThrow(new RulesetValidationError('Error at #/overrides: must be array')); + ).toThrowAggregateError(new AggregateError([new RulesetValidationError('must be array', ['overrides'])])); }); it('given an empty overrides, throws', () => { @@ -241,7 +250,7 @@ Error at #/rules/rule/formats/1: must be a valid format`, assertValidRuleset.bind(null, { overrides: [], }), - ).toThrow(new RulesetValidationError('Error at #/overrides: must not be empty')); + ).toThrowAggregateError(new AggregateError([new RulesetValidationError('must not be empty', ['overrides'])])); }); it('given an invalid pattern, throws', () => { @@ -249,10 +258,13 @@ Error at #/rules/rule/formats/1: must be a valid format`, assertValidRuleset.bind(null, { overrides: [2], }), - ).toThrow( - new RulesetValidationError( - 'Error at #/overrides/0: must be a override, i.e. { "files": ["v2/**/*.json"], "rules": {} }', - ), + ).toThrowAggregateError( + new AggregateError([ + new RulesetValidationError('must be a override, i.e. { "files": ["v2/**/*.json"], "rules": {} }', [ + 'overrides', + '0', + ]), + ]), ); }); @@ -261,16 +273,28 @@ Error at #/rules/rule/formats/1: must be a valid format`, rules: {}, }; - it.each<[Partial, string]>([ - [{ extends: [rulesetA] }, 'Error at #/overrides/0: must contain rules when JSON Pointers are defined'], - [{ formats: [formatB] }, 'Error at #/overrides/0: must contain rules when JSON Pointers are defined'], + it.each<[Partial, RulesetValidationError]>([ + [ + { extends: [rulesetA] }, + new RulesetValidationError('must contain rules when JSON Pointers are defined', ['overrides', '0']), + ], + [ + { formats: [formatB] }, + new RulesetValidationError('must contain rules when JSON Pointers are defined', ['overrides', '0']), + ], [ { rules: {}, formats: [formatB] }, - 'Error at #/overrides/0: must not override any other property than rules when JSON Pointers are defined', + new RulesetValidationError('must not override any other property than rules when JSON Pointers are defined', [ + 'overrides', + '0', + ]), ], [ { rules: {}, extends: [rulesetA] }, - 'Error at #/overrides/0: must not override any other property than rules when JSON Pointers are defined', + new RulesetValidationError('must not override any other property than rules when JSON Pointers are defined', [ + 'overrides', + '0', + ]), ], [ { @@ -283,7 +307,10 @@ Error at #/rules/rule/formats/1: must be a valid format`, }, }, }, - 'Error at #/overrides/0/rules/definition: the value has to be one of: 0, 1, 2, 3 or "error", "warn", "info", "hint", "off"', + new RulesetValidationError( + 'the value has to be one of: 0, 1, 2, 3 or "error", "warn", "info", "hint", "off"', + ['overrides', '0', 'rules', 'definition'], + ), ], ])('given an override containing a pointer and %p, throws', (ruleset, error) => { expect( @@ -295,7 +322,7 @@ Error at #/rules/rule/formats/1: must be a valid format`, }, ], }), - ).toThrow(new RulesetValidationError(error)); + ).toThrowAggregateError(new AggregateError([error])); }); it.each([ @@ -355,7 +382,7 @@ Error at #/rules/rule/formats/1: must be a valid format`, rules: {}, aliases: null, }), - ).toThrow(new RulesetValidationError('Error at #/aliases: must be object')); + ).toThrowAggregateError(new AggregateError([new RulesetValidationError('must be object', ['aliases'])])); }); it.each([null, 5])('recognizes %p as an invalid type of aliases', value => { @@ -366,10 +393,13 @@ Error at #/rules/rule/formats/1: must be a valid format`, alias: [value], }, }), - ).toThrow( - new RulesetValidationError( - 'Error at #/aliases/alias/0: must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset', - ), + ).toThrowAggregateError( + new AggregateError([ + new RulesetValidationError( + 'must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset', + ['aliases', 'alias', '0'], + ), + ]), ); }); @@ -381,28 +411,46 @@ Error at #/rules/rule/formats/1: must be a valid format`, [key]: ['$.foo'], }, }), - ).toThrow( - new RulesetValidationError( - 'Error at #/aliases: to avoid confusion the name must match /^[A-Za-z][A-Za-z0-9_-]*$/ regular expression', - ), + ).toThrowAggregateError( + new AggregateError([ + new RulesetValidationError( + 'to avoid confusion the name must match /^[A-Za-z][A-Za-z0-9_-]*$/ regular expression', + ['aliases'], + ), + ]), ); }); - it.each<[unknown[], string]>([ + it.each<[unknown[], RulesetValidationError[]]>([ [ [''], - 'Error at #/aliases/PathItem/0: must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset', + [ + new RulesetValidationError( + 'must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset', + ['aliases', 'PathItem'], + ), + ], ], [ ['foo'], - 'Error at #/aliases/PathItem/0: must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset', + [ + new RulesetValidationError( + 'must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset', + ['aliases', 'PathItem'], + ), + ], ], - [[], 'Error at #/aliases/PathItem: must be a non-empty array of expressions'], + [[], [new RulesetValidationError('must be a non-empty array of expressions', ['aliases', 'PathItem'])]], [ [0], - 'Error at #/aliases/PathItem/0: must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset', + [ + new RulesetValidationError( + 'must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset', + ['aliases', 'PathItem'], + ), + ], ], - ])('given %s value used as an alias, throws', (value, error) => { + ])('given %s value used as an alias, throws', (value, errors) => { expect( assertValidRuleset.bind(null, { rules: {}, @@ -410,7 +458,7 @@ Error at #/rules/rule/formats/1: must be a valid format`, PathItem: value, }, }), - ).toThrow(new RulesetValidationError(error)); + ).toThrowAggregateError(new AggregateError(errors)); }); describe('given scoped aliases', () => { @@ -422,10 +470,10 @@ Error at #/rules/rule/formats/1: must be a valid format`, alias: {}, }, }), - ).toThrow( - new RulesetValidationError( - 'Error at #/aliases/alias: targets must be present and have at least a single alias definition', - ), + ).toThrowAggregateError( + new AggregateError([ + new RulesetValidationError('targets must be present and have at least a single alias definition', []), + ]), ); }); @@ -478,7 +526,9 @@ Error at #/rules/rule/formats/1: must be a valid format`, }, }, }), - ).toThrow(new RulesetValidationError('Error at #/aliases/SchemaObject/targets: must be array')); + ).toThrowAggregateError( + new AggregateError([new RulesetValidationError('must be array', ['aliases', 'SchemaObject', 'targets'])]), + ); }); it('demands some target', () => { @@ -491,10 +541,14 @@ Error at #/rules/rule/formats/1: must be a valid format`, }, }, }), - ).toThrow( - new RulesetValidationError( - 'Error at #/aliases/SchemaObject/targets: targets must have at least a single alias definition', - ), + ).toThrowAggregateError( + new AggregateError([ + new RulesetValidationError('targets must have at least a single alias definition', [ + 'aliases', + 'SchemaObject', + 'targets', + ]), + ]), ); }); @@ -508,10 +562,15 @@ Error at #/rules/rule/formats/1: must be a valid format`, }, }, }), - ).toThrow( - new RulesetValidationError( - 'Error at #/aliases/SchemaObject/targets/0: a valid target must contain given and non-empty formats', - ), + ).toThrowAggregateError( + new AggregateError([ + new RulesetValidationError('a valid target must contain given and non-empty formats', [ + 'aliases', + 'SchemaObject', + 'targets', + '0', + ]), + ]), ); }); @@ -534,11 +593,25 @@ Error at #/rules/rule/formats/1: must be a valid format`, }, }, }), - ).toThrow( - new RulesetValidationError( - `Error at #/aliases/SchemaObject/targets/0/formats/0: must be a valid format -Error at #/aliases/SchemaObject/targets/1/formats/1: must be a valid format`, - ), + ).toThrowAggregateError( + new AggregateError([ + new RulesetValidationError('must be a valid format', [ + 'aliases', + 'SchemaObject', + 'targets', + '0', + 'formats', + '0', + ]), + new RulesetValidationError('must be a valid format', [ + 'aliases', + 'SchemaObject', + 'targets', + '1', + 'formats', + '1', + ]), + ]), ); }); @@ -561,10 +634,13 @@ Error at #/aliases/SchemaObject/targets/1/formats/1: must be a valid format`, }, }, }), - ).toThrow( - new RulesetValidationError( - `Error at #/aliases/SchemaObject/targets/1/given/0: must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset`, - ), + ).toThrowAggregateError( + new AggregateError([ + new RulesetValidationError( + 'must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset', + ['aliases', 'SchemaObject', 'targets', '1', 'given', '0'], + ), + ]), ); }); }); @@ -579,7 +655,7 @@ Error at #/aliases/SchemaObject/targets/1/formats/1: must be a valid format`, rule: { given: '$', then: { - function: 'foo', + function: truthy, }, }, }, @@ -593,7 +669,7 @@ Error at #/aliases/SchemaObject/targets/1/formats/1: must be a valid format`, given: '$', then: { field: 'test', - function: 'foo', + function: truthy, }, }, }, @@ -634,9 +710,17 @@ Error at #/aliases/SchemaObject/targets/1/formats/1: must be a valid format`, duplicateKeys: 'foo', }, }), - ).toThrow( - new RulesetValidationError(`Error at #/parserOptions/duplicateKeys: the value has to be one of: 0, 1, 2, 3 or "error", "warn", "info", "hint", "off" -Error at #/parserOptions/incompatibleValues: the value has to be one of: 0, 1, 2, 3 or "error", "warn", "info", "hint", "off"`), + ).toThrowAggregateError( + new AggregateError([ + new RulesetValidationError( + 'the value has to be one of: 0, 1, 2, 3 or "error", "warn", "info", "hint", "off"', + ['parserOptions', 'duplicateKeys'], + ), + new RulesetValidationError( + 'the value has to be one of: 0, 1, 2, 3 or "error", "warn", "info", "hint", "off"', + ['parserOptions', 'incompatibleValues'], + ), + ]), ); }); }); @@ -657,14 +741,19 @@ describe('JSON Ruleset Validation', () => { ).not.toThrow(); }); - it.each<[unknown, string]>([ - [[['test', 'test']], `Error at #/extends/0/1: allowed types are "off", "recommended" and "all"`], + it.each<[unknown, RulesetValidationError[]]>([ + [ + [['test', 'test']], + [new RulesetValidationError('allowed types are "off", "recommended" and "all"', ['extends', '0', '1'])], + ], [ [['bar', 'test'], {}], - `Error at #/extends/1: must be string -Error at #/extends/0/1: allowed types are "off", "recommended" and "all"`, + [ + new RulesetValidationError('must be string', ['extends', '1']), + new RulesetValidationError(`allowed types are "off", "recommended" and "all"`, ['extends', '0', '1']), + ], ], - ])('recognizes invalid array-ish extends syntax %p', (_extends, message) => { + ])('recognizes invalid array-ish extends syntax %p', (_extends, errors) => { expect( assertValidRuleset.bind( null, @@ -673,7 +762,7 @@ Error at #/extends/0/1: allowed types are "off", "recommended" and "all"`, }, 'json', ), - ).toThrow(new RulesetValidationError(message)); + ).toThrowAggregateError(new AggregateError(errors)); }); it('recognizes valid ruleset formats syntax', () => { @@ -689,15 +778,17 @@ Error at #/extends/0/1: allowed types are "off", "recommended" and "all"`, ).not.toThrow(); }); - it.each([ + it.each<[unknown, RulesetValidationError[]]>([ [ [2, 'a'], - `Error at #/formats/0: must be a valid format -Error at #/formats/1: must be a valid format`, + [ + new RulesetValidationError('must be a valid format', ['formats', '0']), + new RulesetValidationError('must be a valid format', ['formats', '1']), + ], ], - [2, 'Error at #/formats: must be an array of formats'], - [[''], 'Error at #/formats/0: must be a valid format'], - ])('recognizes invalid ruleset %p formats syntax', (formats, error) => { + [2, [new RulesetValidationError('must be an array of formats', ['formats'])]], + [[''], [new RulesetValidationError('must be a valid format', ['formats', '0'])]], + ])('recognizes invalid ruleset %p formats syntax', (formats, errors) => { expect( assertValidRuleset.bind( null, @@ -707,7 +798,7 @@ Error at #/formats/1: must be a valid format`, }, 'json', ), - ).toThrow(new RulesetValidationError(error)); + ).toThrowAggregateError(new AggregateError(errors)); }); it('recognizes valid rule formats syntax', () => { @@ -731,14 +822,16 @@ Error at #/formats/1: must be a valid format`, ).not.toThrow(); }); - it.each([ + it.each<[unknown, RulesetValidationError[]]>([ [ [2, 'a'], - `Error at #/rules/rule/formats/0: must be a valid format -Error at #/rules/rule/formats/1: must be a valid format`, + [ + new RulesetValidationError('must be a valid format', ['rules', 'rule', 'formats', '0']), + new RulesetValidationError('must be a valid format', ['rules', 'rule', 'formats', '1']), + ], ], - [2, 'Error at #/rules/rule/formats: must be an array of formats'], - ])('recognizes invalid rule %p formats syntax', (formats, error) => { + [2, [new RulesetValidationError('must be an array of formats', ['rules', 'rule', 'formats'])]], + ])('recognizes invalid rule %p formats syntax', (formats, errors) => { expect( assertValidRuleset.bind( null, @@ -755,6 +848,6 @@ Error at #/rules/rule/formats/1: must be a valid format`, }, 'json', ), - ).toThrow(new RulesetValidationError(error)); + ).toThrowAggregateError(new AggregateError(errors)); }); }); diff --git a/packages/core/src/ruleset/validation/ajv.ts b/packages/core/src/ruleset/validation/ajv.ts index 88fecdadc..56227d0a2 100644 --- a/packages/core/src/ruleset/validation/ajv.ts +++ b/packages/core/src/ruleset/validation/ajv.ts @@ -30,11 +30,14 @@ export function createValidator(format: 'js' | 'json'): ValidateFunction { addFormats(ajv); addErrors(ajv); ajv.addKeyword({ - keyword: 'spectral-runtime', + keyword: 'x-spectral-runtime', schemaType: 'string', error: { - message(ctx) { - return _`${ctx.data}[Symbol.for(${message})]`; + message(cxt) { + return _`${cxt.data}[Symbol.for(${message})]`; + }, + params(cxt) { + return _`${cxt.data}[Symbol.for(${message})] ? { "errors": ${cxt.data}[Symbol.for(${message})].errors || [${cxt.data}[Symbol.for(${message})]] } : {}`; }, }, code(cxt) { @@ -47,7 +50,7 @@ export function createValidator(format: 'js' | 'json'): ValidateFunction { case 'ruleset-function': cxt.pass(_`typeof ${data}.function === "function"`); cxt.pass( - _`(() => { try { ${data}.function.validator && ${data}.function.validator('functionOptions' in ${data} ? ${data} : null); } catch (e) { ${data}[${message}] = e.message } })()`, + _`(() => { try { ${data}.function.validator && ${data}.function.validator('functionOptions' in ${data} ? ${data}.functionOptions : null); return true; } catch (e) { ${data}[Symbol.for(${message})] = e; return false; } })()`, ); break; } diff --git a/packages/core/src/ruleset/validation/assertions.ts b/packages/core/src/ruleset/validation/assertions.ts index f8702feb9..704c1f34b 100644 --- a/packages/core/src/ruleset/validation/assertions.ts +++ b/packages/core/src/ruleset/validation/assertions.ts @@ -1,33 +1,34 @@ import { isPlainObject } from '@stoplight/json'; import { createValidator } from './ajv'; -import { RulesetAjvValidationError, RulesetValidationError } from './errors'; +import { convertAjvErrors, RulesetValidationError } from './errors'; import type { FileRuleDefinition, RuleDefinition, RulesetDefinition } from '../types'; +import AggregateError from 'es-aggregate-error'; export function assertValidRuleset( ruleset: unknown, format: 'js' | 'json' = 'js', ): asserts ruleset is RulesetDefinition { if (!isPlainObject(ruleset)) { - throw new Error('Provided ruleset is not an object'); + throw new RulesetValidationError('Provided ruleset is not an object', []); } if (!('rules' in ruleset) && !('extends' in ruleset) && !('overrides' in ruleset)) { - throw new RulesetValidationError('Ruleset must have rules or extends or overrides defined'); + throw new RulesetValidationError('Ruleset must have rules or extends or overrides defined', []); } const validate = createValidator(format); if (!validate(ruleset)) { - throw new RulesetAjvValidationError(ruleset, validate.errors ?? []); + throw new AggregateError(convertAjvErrors(validate.errors ?? [])); } } -export function isValidRule(rule: FileRuleDefinition): rule is RuleDefinition { +function isRuleDefinition(rule: FileRuleDefinition): rule is RuleDefinition { return typeof rule === 'object' && rule !== null && !Array.isArray(rule) && ('given' in rule || 'then' in rule); } -export function assertValidRule(rule: FileRuleDefinition): asserts rule is RuleDefinition { - if (!isValidRule(rule)) { - throw new TypeError('Invalid rule'); +export function assertValidRule(rule: FileRuleDefinition, name: string): asserts rule is RuleDefinition { + if (!isRuleDefinition(rule)) { + throw new RulesetValidationError('Rule definition expected', ['rules', name]); } } diff --git a/packages/core/src/ruleset/validation/errors.ts b/packages/core/src/ruleset/validation/errors.ts index 3f3d120ef..0f00eb0e3 100644 --- a/packages/core/src/ruleset/validation/errors.ts +++ b/packages/core/src/ruleset/validation/errors.ts @@ -1,8 +1,10 @@ -import { ErrorObject } from 'ajv'; -import { printPath, PrintStyle } from '@stoplight/spectral-runtime'; +import type { ErrorObject } from 'ajv'; +import type { IDiagnostic, JsonPath } from '@stoplight/types'; -export class RulesetValidationError extends Error { - constructor(public readonly message: string) { +type RulesetValidationSingleError = Pick; + +export class RulesetValidationError extends Error implements RulesetValidationSingleError { + constructor(public readonly message: string, public readonly path: JsonPath) { super(message); } } @@ -10,56 +12,49 @@ export class RulesetValidationError extends Error { const RULE_INSTANCE_PATH = /^\/rules\/[^/]+/; const GENERIC_INSTANCE_PATH = /^\/(?:aliases|extends|overrides(?:\/\d+\/extends)?)/; -export class RulesetAjvValidationError extends RulesetValidationError { - constructor(public ruleset: Record, public errors: ErrorObject[]) { - super(RulesetAjvValidationError.serializeAjvErrors(ruleset, errors)); - } - - public static serializeAjvErrors(ruleset: Record, errors: ErrorObject[]): string { - const sortedErrors = [...errors] - .sort((errorA, errorB) => { - const diff = errorA.instancePath.length - errorB.instancePath.length; - return diff === 0 ? (errorA.keyword === 'errorMessage' && errorB.keyword !== 'errorMessage' ? -1 : 0) : diff; - }) - .filter((error, i, sortedErrors) => i === 0 || sortedErrors[i - 1].instancePath !== error.instancePath); - - const filteredErrors: ErrorObject[] = []; - - l: for (let i = 0; i < sortedErrors.length; i++) { - const error = sortedErrors[i]; - const prevError = i === 0 ? null : sortedErrors[i - 1]; - - if (GENERIC_INSTANCE_PATH.test(error.instancePath)) { - let x = 1; - while (i + x < sortedErrors.length) { - if ( - sortedErrors[i + x].instancePath.startsWith(error.instancePath) || - !GENERIC_INSTANCE_PATH.test(sortedErrors[i + x].instancePath) - ) { - continue l; - } - - x++; +export function convertAjvErrors(errors: ErrorObject[]): RulesetValidationError[] { + const sortedErrors = [...errors] + .sort((errorA, errorB) => { + const diff = errorA.instancePath.length - errorB.instancePath.length; + return diff === 0 ? (errorA.keyword === 'errorMessage' && errorB.keyword !== 'errorMessage' ? -1 : 0) : diff; + }) + .filter((error, i, sortedErrors) => i === 0 || sortedErrors[i - 1].instancePath !== error.instancePath); + + const filteredErrors: ErrorObject[] = []; + + l: for (let i = 0; i < sortedErrors.length; i++) { + const error = sortedErrors[i]; + const prevError = i === 0 ? null : sortedErrors[i - 1]; + + if (GENERIC_INSTANCE_PATH.test(error.instancePath)) { + let x = 1; + while (i + x < sortedErrors.length) { + if ( + sortedErrors[i + x].instancePath.startsWith(error.instancePath) || + !GENERIC_INSTANCE_PATH.test(sortedErrors[i + x].instancePath) + ) { + continue l; } - } else if (prevError === null) { - filteredErrors.push(error); - continue; - } else { - const match = RULE_INSTANCE_PATH.exec(error.instancePath); - if (match !== null && match[0] !== match.input && match[0] === prevError.instancePath) { - filteredErrors.pop(); - } + x++; } - + } else if (prevError === null) { filteredErrors.push(error); + continue; + } else { + const match = RULE_INSTANCE_PATH.exec(error.instancePath); + + if (match !== null && match[0] !== match.input && match[0] === prevError.instancePath) { + filteredErrors.pop(); + } } - return filteredErrors - .map( - ({ message, instancePath }) => - `Error at ${printPath(instancePath.slice(1).split('/'), PrintStyle.Pointer)}: ${message ?? ''}`, - ) - .join('\n'); + filteredErrors.push(error); } + + return filteredErrors.flatMap(error => + error.keyword === 'x-spectral-runtime' + ? (error.params.errors as RulesetValidationError[]) + : new RulesetValidationError(error.message ?? 'unknown error', error.instancePath.slice(1).split('/')), + ); } diff --git a/packages/functions/src/__tests__/__helpers__/tester.ts b/packages/functions/src/__tests__/__helpers__/tester.ts index 21a1c03ea..8bf0d6a39 100644 --- a/packages/functions/src/__tests__/__helpers__/tester.ts +++ b/packages/functions/src/__tests__/__helpers__/tester.ts @@ -6,7 +6,9 @@ import { IRuleResult, RulesetFunction, RulesetFunctionWithValidator, + RulesetValidationError, } from '@stoplight/spectral-core'; +import { isAggregateError } from '@stoplight/spectral-core/src/guards/isAggregateError'; export default async function ( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -16,49 +18,37 @@ export default async function ( rule?: Partial> & { then?: Partial }, ): Promise[]> { const s = new Spectral(); - s.setRuleset({ - rules: { - 'my-rule': { - given: '$', - ...rule, - then: { - ...(rule?.then as Ruleset['rules']['then']), - function: fn, - functionOptions: opts, + try { + s.setRuleset({ + rules: { + 'my-rule': { + given: '$', + ...rule, + then: { + ...(rule?.then as Ruleset['rules']['then']), + function: fn, + functionOptions: opts, + }, }, }, - }, - }); - - try { - const results = await s.run(input instanceof Document ? input : JSON.stringify(input)); - return results - .filter(result => result.code === 'my-rule') - .map(error => ({ - path: error.path, - message: error.message, - })); - } catch (error: unknown) { - if (!(error instanceof Error)) { - throw error; - } - - const errors = Array.isArray((error as Error & { errors?: unknown }).errors) - ? (error as Error & { errors: unknown[] }).errors - : [error]; - - if (errors.length === 1) { - throw getCause(errors[0]); - } else { - throw error; + }); + } catch (ex) { + if (isAggregateError(ex)) { + for (const e of ex.errors) { + if (e instanceof RulesetValidationError) { + e.path.length = 0; + } + } } - } -} -function getCause(error: unknown): unknown { - if (error instanceof Error && 'cause' in (error as Error & { cause?: unknown })) { - return getCause((error as Error & { cause?: unknown }).cause); + throw ex; } - return error; + const results = await s.run(input instanceof Document ? input : JSON.stringify(input)); + return results + .filter(result => result.code === 'my-rule') + .map(error => ({ + path: error.path, + message: error.message, + })); } diff --git a/packages/functions/src/__tests__/alphabetical.test.ts b/packages/functions/src/__tests__/alphabetical.test.ts index 7ef63b41a..f2883ba73 100644 --- a/packages/functions/src/__tests__/alphabetical.test.ts +++ b/packages/functions/src/__tests__/alphabetical.test.ts @@ -2,9 +2,12 @@ import * as Parsers from '@stoplight/spectral-parsers'; import { Document, RulesetValidationError } from '@stoplight/spectral-core'; import testFunction from './__helpers__/tester'; import alphabetical from '../alphabetical'; +import AggregateError = require('es-aggregate-error'); const runAlphabetical = testFunction.bind(null, alphabetical); +import '@stoplight/spectral-test-utils/matchers'; + describe('Core Functions / Alphabetical', () => { it('given falsy target, should return no error message', async () => { expect(await runAlphabetical(false)).toEqual([]); @@ -137,7 +140,9 @@ describe('Core Functions / Alphabetical', () => { ], [{ keyedBy: 2 }, '"alphabetical" function and its "keyedBy" option accepts only the following types: string'], ])('given invalid %p options, should throw', async (opts, error) => { - await expect(runAlphabetical([], opts)).rejects.toThrow(new RulesetValidationError(error)); + await expect(runAlphabetical([], opts)).rejects.toThrowAggregateError( + new AggregateError([new RulesetValidationError(error, [])]), + ); }); }); }); diff --git a/packages/functions/src/__tests__/casing.test.ts b/packages/functions/src/__tests__/casing.test.ts index 612a220f5..24150bcf9 100644 --- a/packages/functions/src/__tests__/casing.test.ts +++ b/packages/functions/src/__tests__/casing.test.ts @@ -1,6 +1,8 @@ +import '@stoplight/spectral-test-utils/matchers'; import { RulesetValidationError } from '@stoplight/spectral-core'; import casing, { CasingType } from '../casing'; import testFunction from './__helpers__/tester'; +import AggregateError = require('es-aggregate-error'); const runCasing = testFunction.bind(null, casing); @@ -372,19 +374,27 @@ describe('Core Functions / Casing', () => { await expect(runCasing('foo', opts)).resolves.toBeInstanceOf(Array); }); - it.each<[unknown, string]>([ + it.each<[unknown, RulesetValidationError[]]>([ [ { type: 'foo' }, - '"casing" function and its "type" option accept the following values: flat, camel, pascal, kebab, cobol, snake, macro', + [ + new RulesetValidationError( + '"casing" function and its "type" option accept the following values: flat, camel, pascal, kebab, cobol, snake, macro', + [], + ), + ], + ], + [ + { type: 'macro', foo: true }, + [new RulesetValidationError('"casing" function does not support "foo" option', [])], ], - [{ type: 'macro', foo: true }, '"casing" function does not support "foo" option'], [ { type: 'pascal', disallowDigits: false, separator: {}, }, - '"casing" function is missing "separator.char" option', + [new RulesetValidationError('"casing" function is missing "separator.char" option', [])], ], [ { @@ -392,11 +402,11 @@ describe('Core Functions / Casing', () => { disallowDigits: false, separator: { allowLeading: true }, }, - '"casing" function is missing "separator.char" option', + [new RulesetValidationError('"casing" function is missing "separator.char" option', [])], ], [ { type: 'snake', separator: { char: 'a', foo: true } }, - '"casing" function does not support "separator.foo" option', + [new RulesetValidationError('"casing" function does not support "separator.foo" option', [])], ], [ { @@ -405,7 +415,12 @@ describe('Core Functions / Casing', () => { char: 'fo', }, }, - '"casing" function and its "separator.char" option accepts only char, i.e. "I" or "/"', + [ + new RulesetValidationError( + '"casing" function and its "separator.char" option accepts only char, i.e. "I" or "/"', + [], + ), + ], ], [ { @@ -414,10 +429,15 @@ describe('Core Functions / Casing', () => { char: null, }, }, - '"casing" function and its "separator.char" option accepts only char, i.e. "I" or "/"', + [ + new RulesetValidationError( + '"casing" function and its "separator.char" option accepts only char, i.e. "I" or "/"', + [], + ), + ], ], - ])('given invalid %p options, should throw', async (opts, error) => { - await expect(runCasing('foo', opts)).rejects.toThrow(new RulesetValidationError(error)); + ])('given invalid %p options, should throw', async (opts, errors) => { + await expect(runCasing('foo', opts)).rejects.toThrowAggregateError(new AggregateError(errors)); }); }); }); diff --git a/packages/functions/src/__tests__/defined.test.ts b/packages/functions/src/__tests__/defined.test.ts index 18a848870..deede0bfd 100644 --- a/packages/functions/src/__tests__/defined.test.ts +++ b/packages/functions/src/__tests__/defined.test.ts @@ -1,6 +1,9 @@ +import '@stoplight/spectral-test-utils/matchers'; + import defined from '../defined'; import testFunction from './__helpers__/tester'; import { RulesetValidationError } from '@stoplight/spectral-core'; +import AggregateError = require('es-aggregate-error'); const runDefined = testFunction.bind(null, defined); @@ -20,8 +23,8 @@ describe('Core Functions / Defined', () => { describe('validation', () => { it.each([{}, 2])('given invalid %p options, should throw', async opts => { - await expect(runDefined([], opts)).rejects.toThrow( - new RulesetValidationError('"defined" function does not accept any options'), + await expect(runDefined([], opts)).rejects.toThrowAggregateError( + new AggregateError([new RulesetValidationError('"defined" function does not accept any options', [])]), ); }); }); diff --git a/packages/functions/src/__tests__/enumeration.test.ts b/packages/functions/src/__tests__/enumeration.test.ts index eba56c0e5..23dfe379f 100644 --- a/packages/functions/src/__tests__/enumeration.test.ts +++ b/packages/functions/src/__tests__/enumeration.test.ts @@ -1,6 +1,9 @@ +import '@stoplight/spectral-test-utils/matchers'; + import { RulesetValidationError } from '@stoplight/spectral-core'; import enumeration from '../enumeration'; import testFunction from './__helpers__/tester'; +import AggregateError = require('es-aggregate-error'); const runEnumeration = testFunction.bind(null, enumeration); @@ -31,30 +34,45 @@ describe('Core Functions / Enumeration', () => { ).toEqual([]); }); - it.each<[unknown, string]>([ + it.each<[unknown, RulesetValidationError[]]>([ [ { values: ['foo', 2], foo: true, }, - '"enumeration" function does not support "foo" option', + [new RulesetValidationError('"enumeration" function does not support "foo" option', [])], ], [ { values: [{}], }, - '"enumeration" and its "values" option support only arrays of primitive values, i.e. ["Berlin", "London", "Paris"]', + [ + new RulesetValidationError( + '"enumeration" and its "values" option support only arrays of primitive values, i.e. ["Berlin", "London", "Paris"]', + [], + ), + ], ], [ null, - '"enumeration" function has invalid options specified. Example valid options: { "values": ["Berlin", "London", "Paris"] }, { "values": [2, 3, 5, 8, 13, 21] }', + [ + new RulesetValidationError( + '"enumeration" function has invalid options specified. Example valid options: { "values": ["Berlin", "London", "Paris"] }, { "values": [2, 3, 5, 8, 13, 21] }', + [], + ), + ], ], [ 2, - '"enumeration" function has invalid options specified. Example valid options: { "values": ["Berlin", "London", "Paris"] }, { "values": [2, 3, 5, 8, 13, 21] }', + [ + new RulesetValidationError( + '"enumeration" function has invalid options specified. Example valid options: { "values": ["Berlin", "London", "Paris"] }, { "values": [2, 3, 5, 8, 13, 21] }', + [], + ), + ], ], - ])('given invalid %p options, should throw', async (opts, error) => { - await expect(runEnumeration('foo', opts)).rejects.toThrow(new RulesetValidationError(error)); + ])('given invalid %p options, should throw', async (opts, errors) => { + await expect(runEnumeration('foo', opts)).rejects.toThrowAggregateError(new AggregateError(errors)); }); }); }); diff --git a/packages/functions/src/__tests__/falsy.test.ts b/packages/functions/src/__tests__/falsy.test.ts index 1f5773318..13f620395 100644 --- a/packages/functions/src/__tests__/falsy.test.ts +++ b/packages/functions/src/__tests__/falsy.test.ts @@ -1,6 +1,9 @@ +import '@stoplight/spectral-test-utils/matchers'; + import { RulesetValidationError } from '@stoplight/spectral-core'; import falsy from '../falsy'; import testFunction from './__helpers__/tester'; +import AggregateError = require('es-aggregate-error'); const runFalsy = testFunction.bind(null, falsy); @@ -20,8 +23,8 @@ describe('Core Functions / Falsy', () => { describe('validation', () => { it.each([{}, 2])('given invalid %p options, should throw', async opts => { - await expect(runFalsy([], opts)).rejects.toThrow( - new RulesetValidationError('"falsy" function does not accept any options'), + await expect(runFalsy([], opts)).rejects.toThrowAggregateError( + new AggregateError([new RulesetValidationError('"falsy" function does not accept any options', [])]), ); }); }); diff --git a/packages/functions/src/__tests__/length.test.ts b/packages/functions/src/__tests__/length.test.ts index 816c909a7..54ffd46f2 100644 --- a/packages/functions/src/__tests__/length.test.ts +++ b/packages/functions/src/__tests__/length.test.ts @@ -1,6 +1,8 @@ import { RulesetValidationError } from '@stoplight/spectral-core'; import testFunction from './__helpers__/tester'; import length from '../length'; +import '@stoplight/spectral-test-utils/matchers'; +import AggregateError = require('es-aggregate-error'); const runLength = testFunction.bind(null, length); @@ -52,31 +54,66 @@ describe('Core Functions / Length', () => { expect(await runLength('foo', opts)).toEqual([]); }); - it.each<[unknown, string]>([ + it.each<[unknown, RulesetValidationError[]]>([ [ null, - '"length" function has invalid options specified. Example valid options: { "min": 2 }, { "max": 5 }, { "min": 0, "max": 10 }', + [ + new RulesetValidationError( + '"length" function has invalid options specified. Example valid options: { "min": 2 }, { "max": 5 }, { "min": 0, "max": 10 }', + [], + ), + ], ], [ 2, - '"length" function has invalid options specified. Example valid options: { "min": 2 }, { "max": 5 }, { "min": 0, "max": 10 }', + [ + new RulesetValidationError( + '"length" function has invalid options specified. Example valid options: { "min": 2 }, { "max": 5 }, { "min": 0, "max": 10 }', + [], + ), + ], ], [ { min: 2, foo: true, }, - '"length" function does not support "foo" option', + [new RulesetValidationError('"length" function does not support "foo" option', [])], + ], + [ + { min: '2' }, + [ + new RulesetValidationError( + '"length" function and its "min" option accepts only the following types: number', + [], + ), + ], + ], + + [ + { max: '2' }, + [ + new RulesetValidationError( + `"length" function and its "max" option accepts only the following types: number`, + [], + ), + ], ], - [{ min: '2' }, '"length" function and its "min" option accepts only the following types: number'], - [{ max: '2' }, `"length" function and its "max" option accepts only the following types: number`], [ { min: '4', max: '2' }, - `"length" function and its "min" option accepts only the following types: number -"length" function and its "max" option accepts only the following types: number`, + [ + new RulesetValidationError( + `"length" function and its "min" option accepts only the following types: number`, + [], + ), + new RulesetValidationError( + `"length" function and its "max" option accepts only the following types: number`, + [], + ), + ], ], - ])('given invalid %p options, should throw', async (opts, error) => { - await expect(runLength('foo', opts)).rejects.toThrow(new RulesetValidationError(error)); + ])('given invalid %p options, should throw', async (opts, errors) => { + await expect(runLength('foo', opts)).rejects.toThrowAggregateError(new AggregateError(errors)); }); }); }); diff --git a/packages/functions/src/__tests__/pattern.test.ts b/packages/functions/src/__tests__/pattern.test.ts index 7f2d2a9ad..9bcfcc424 100644 --- a/packages/functions/src/__tests__/pattern.test.ts +++ b/packages/functions/src/__tests__/pattern.test.ts @@ -1,6 +1,9 @@ +import '@stoplight/spectral-test-utils/matchers'; + import { RulesetValidationError } from '@stoplight/spectral-core'; import testFunction from './__helpers__/tester'; import pattern from '../pattern'; +import AggregateError = require('es-aggregate-error'); const runPattern = testFunction.bind(null, pattern); @@ -49,25 +52,51 @@ describe('Core Functions / Pattern', () => { }, ); - it.each<[unknown, string]>([ + it.each<[unknown, RulesetValidationError[]]>([ [ null, - '"pattern" function has invalid options specified. Example valid options: { "match": "^Stoplight" }, { "notMatch": "Swagger" }, { "match": "Stoplight", "notMatch": "Swagger" }', + [ + new RulesetValidationError( + '"pattern" function has invalid options specified. Example valid options: { "match": "^Stoplight" }, { "notMatch": "Swagger" }, { "match": "Stoplight", "notMatch": "Swagger" }', + [], + ), + ], ], [ {}, - `"pattern" function has invalid options specified. Example valid options: { "match": "^Stoplight" }, { "notMatch": "Swagger" }, { "match": "Stoplight", "notMatch": "Swagger" }`, + [ + new RulesetValidationError( + `"pattern" function has invalid options specified. Example valid options: { "match": "^Stoplight" }, { "notMatch": "Swagger" }, { "match": "Stoplight", "notMatch": "Swagger" }`, + [], + ), + ], + ], + [{ foo: true }, [new RulesetValidationError('"pattern" function does not support "foo" option', [])]], + [ + { match: 2 }, + [new RulesetValidationError('"pattern" function and its "match" option must be string or RegExp instance', [])], + ], + [ + { notMatch: null }, + [ + new RulesetValidationError( + '"pattern" function and its "notMatch" option must be string or RegExp instance', + [], + ), + ], ], - [{ foo: true }, '"pattern" function does not support "foo" option'], - [{ match: 2 }, '"pattern" function and its "match" option must be string or RegExp instance'], - [{ notMatch: null }, '"pattern" function and its "notMatch" option must be string or RegExp instance'], [ { match: 4, notMatch: 10 }, - `"pattern" function and its "match" option must be string or RegExp instance -"pattern" function and its "notMatch" option must be string or RegExp instance`, + [ + new RulesetValidationError(`"pattern" function and its "match" option must be string or RegExp instance`, []), + new RulesetValidationError( + `"pattern" function and its "notMatch" option must be string or RegExp instance`, + [], + ), + ], ], - ])('given invalid %p options, should throw', async (opts, error) => { - await expect(runPattern('abc', opts)).rejects.toThrow(new RulesetValidationError(error)); + ])('given invalid %p options, should throw', async (opts, errors) => { + await expect(runPattern('abc', opts)).rejects.toThrowAggregateError(new AggregateError(errors)); }); }); }); diff --git a/packages/functions/src/__tests__/schema.test.ts b/packages/functions/src/__tests__/schema.test.ts index a5cd6cb49..562226d5f 100644 --- a/packages/functions/src/__tests__/schema.test.ts +++ b/packages/functions/src/__tests__/schema.test.ts @@ -2,6 +2,8 @@ import type { JSONSchema6 as JSONSchema } from 'json-schema'; import schema from '../schema'; import { RulesetValidationError } from '@stoplight/spectral-core'; import testFunction from './__helpers__/tester'; +import '@stoplight/spectral-test-utils/matchers'; +import AggregateError = require('es-aggregate-error'); const runSchema = testFunction.bind(null, schema); @@ -476,28 +478,57 @@ describe('Core Functions / Schema', () => { expect(await runSchema('', opts)).toBeInstanceOf(Array); }); - it.each<[unknown, string]>([ + it.each<[unknown, RulesetValidationError[]]>([ [ 2, - '"schema" function has invalid options specified. Example valid options: { "schema": { /* any JSON Schema can be defined here */ } , { "schema": { "type": "object" }, "dialect": "auto" }', + [ + new RulesetValidationError( + '"schema" function has invalid options specified. Example valid options: { "schema": { /* any JSON Schema can be defined here */ } , { "schema": { "type": "object" }, "dialect": "auto" }', + [], + ), + ], + ], + [ + { schema: { type: 'object' }, foo: true }, + [new RulesetValidationError('"schema" function does not support "foo" option', [])], + ], + [ + { schema: { type: 'object' }, oasVersion: 1 }, + [new RulesetValidationError('"schema" function does not support "oasVersion" option', [])], ], - [{ schema: { type: 'object' }, foo: true }, '"schema" function does not support "foo" option'], - [{ schema: { type: 'object' }, oasVersion: 1 }, '"schema" function does not support "oasVersion" option'], [ { schema: { type: 'object' }, dialect: 'foo' }, - '"schema" function and its "dialect" option accepts only the following values: "auto", "draft4", "draft6", "draft7", "draft2019-09", "draft2020-12"', + [ + new RulesetValidationError( + '"schema" function and its "dialect" option accepts only the following values: "auto", "draft4", "draft6", "draft7", "draft2019-09", "draft2020-12"', + [], + ), + ], ], [ { schema: { type: 'object' }, allErrors: null }, - '"schema" function and its "allErrors" option accepts only the following types: boolean', + [ + new RulesetValidationError( + '"schema" function and its "allErrors" option accepts only the following types: boolean', + [], + ), + ], ], [ { schema: null, allErrors: null }, - `"schema" function and its "schema" option accepts only the following types: object -"schema" function and its "allErrors" option accepts only the following types: boolean`, + [ + new RulesetValidationError( + `"schema" function and its "schema" option accepts only the following types: object`, + [], + ), + new RulesetValidationError( + `"schema" function and its "allErrors" option accepts only the following types: boolean`, + [], + ), + ], ], - ])('given invalid %p options, should throw', async (opts, error) => { - await expect(runSchema([], opts)).rejects.toThrow(new RulesetValidationError(error)); + ])('given invalid %p options, should throw', async (opts, errors) => { + await expect(runSchema([], opts)).rejects.toThrowAggregateError(new AggregateError(errors)); }); }); }); diff --git a/packages/functions/src/__tests__/truthy.test.ts b/packages/functions/src/__tests__/truthy.test.ts index 2a8f088c7..d49ff8ab7 100644 --- a/packages/functions/src/__tests__/truthy.test.ts +++ b/packages/functions/src/__tests__/truthy.test.ts @@ -1,6 +1,9 @@ +import '@stoplight/spectral-test-utils/matchers'; + import { RulesetValidationError } from '@stoplight/spectral-core'; import truthy from '../truthy'; import testFunction from './__helpers__/tester'; +import AggregateError = require('es-aggregate-error'); const runTruthy = testFunction.bind(null, truthy); @@ -20,8 +23,8 @@ describe('Core Functions / Truthy', () => { describe('validation', () => { it.each([{}, 2])('given invalid %p options, should throw', async opts => { - await expect(runTruthy([], opts)).rejects.toThrow( - new RulesetValidationError('"truthy" function does not accept any options'), + await expect(runTruthy([], opts)).rejects.toThrowAggregateError( + new AggregateError([new RulesetValidationError('"truthy" function does not accept any options', [])]), ); }); }); diff --git a/packages/functions/src/__tests__/unreferencedReusableObject.test.ts b/packages/functions/src/__tests__/unreferencedReusableObject.test.ts index 98324c130..c49bee68c 100644 --- a/packages/functions/src/__tests__/unreferencedReusableObject.test.ts +++ b/packages/functions/src/__tests__/unreferencedReusableObject.test.ts @@ -1,6 +1,9 @@ +import '@stoplight/spectral-test-utils/matchers'; + import { RulesetValidationError } from '@stoplight/spectral-core'; import testFunction from './__helpers__/tester'; import unreferencedReusableObject from '../unreferencedReusableObject'; +import AggregateError = require('es-aggregate-error'); const runUnreferencedReusableObject = testFunction.bind(null, unreferencedReusableObject); @@ -24,40 +27,65 @@ describe('Core Functions / UnreferencedReusableObject', () => { expect(await runUnreferencedReusableObject({}, opts)).toEqual([]); }); - it.each<[unknown, string]>([ + it.each<[unknown, RulesetValidationError[]]>([ [ null, - '"unreferencedReusableObject" function has invalid options specified. Example valid options: { "reusableObjectsLocation": "#/components/schemas" }, { "reusableObjectsLocation": "#/$defs" }', + [ + new RulesetValidationError( + '"unreferencedReusableObject" function has invalid options specified. Example valid options: { "reusableObjectsLocation": "#/components/schemas" }, { "reusableObjectsLocation": "#/$defs" }', + [], + ), + ], ], [ 2, - '"unreferencedReusableObject" function has invalid options specified. Example valid options: { "reusableObjectsLocation": "#/components/schemas" }, { "reusableObjectsLocation": "#/$defs" }', + [ + new RulesetValidationError( + '"unreferencedReusableObject" function has invalid options specified. Example valid options: { "reusableObjectsLocation": "#/components/schemas" }, { "reusableObjectsLocation": "#/$defs" }', + [], + ), + ], ], [ {}, - '"unreferencedReusableObject" function is missing "reusableObjectsLocation" option. Example valid options: { "reusableObjectsLocation": "#/components/schemas" }, { "reusableObjectsLocation": "#/$defs" }', + [ + new RulesetValidationError( + '"unreferencedReusableObject" function is missing "reusableObjectsLocation" option. Example valid options: { "reusableObjectsLocation": "#/components/schemas" }, { "reusableObjectsLocation": "#/$defs" }', + [], + ), + ], ], [ { reusableObjectsLocation: '#', foo: true, }, - '"unreferencedReusableObject" function does not support "foo" option', + [new RulesetValidationError('"unreferencedReusableObject" function does not support "foo" option', [])], ], [ { reusableObjectsLocation: 2, }, - '"unreferencedReusableObject" and its "reusableObjectsLocation" option support only valid JSON Pointer fragments, i.e. "#", "#/foo", "#/paths/~1user"', + [ + new RulesetValidationError( + '"unreferencedReusableObject" and its "reusableObjectsLocation" option support only valid JSON Pointer fragments, i.e. "#", "#/foo", "#/paths/~1user"', + [], + ), + ], ], [ { reusableObjectsLocation: 'foo', }, - '"unreferencedReusableObject" and its "reusableObjectsLocation" option support only valid JSON Pointer fragments, i.e. "#", "#/foo", "#/paths/~1user"', + [ + new RulesetValidationError( + '"unreferencedReusableObject" and its "reusableObjectsLocation" option support only valid JSON Pointer fragments, i.e. "#", "#/foo", "#/paths/~1user"', + [], + ), + ], ], - ])('given invalid %p options, should throw', async (opts, error) => { - await expect(runUnreferencedReusableObject({}, opts)).rejects.toThrow(new RulesetValidationError(error)); + ])('given invalid %p options, should throw', async (opts, errors) => { + await expect(runUnreferencedReusableObject({}, opts)).rejects.toThrowAggregateError(new AggregateError(errors)); }); }); }); diff --git a/packages/functions/src/__tests__/xor.test.ts b/packages/functions/src/__tests__/xor.test.ts index b93c96137..908f8e3d1 100644 --- a/packages/functions/src/__tests__/xor.test.ts +++ b/packages/functions/src/__tests__/xor.test.ts @@ -1,6 +1,9 @@ +import '@stoplight/spectral-test-utils/matchers'; + import { RulesetValidationError } from '@stoplight/spectral-core'; import testFunction from './__helpers__/tester'; import xor from '../xor'; +import AggregateError = require('es-aggregate-error'); const runXor = testFunction.bind(null, xor); @@ -63,25 +66,67 @@ describe('Core Functions / Xor', () => { expect(await runXor([], opts)).toEqual([]); }); - it.each<[unknown, string]>([ + it.each<[unknown, RulesetValidationError[]]>([ [ null, - '"xor" function has invalid options specified. Example valid options: { "properties": ["id", "name"] }, { "properties": ["country", "street"] }', + [ + new RulesetValidationError( + '"xor" function has invalid options specified. Example valid options: { "properties": ["id", "name"] }, { "properties": ["country", "street"] }', + [], + ), + ], ], [ 2, - '"xor" function has invalid options specified. Example valid options: { "properties": ["id", "name"] }, { "properties": ["country", "street"] }', + [ + new RulesetValidationError( + '"xor" function has invalid options specified. Example valid options: { "properties": ["id", "name"] }, { "properties": ["country", "street"] }', + [], + ), + ], + ], + [ + { properties: ['foo', 'bar'], foo: true }, + [new RulesetValidationError('"xor" function does not support "foo" option', [])], ], - [{ properties: ['foo', 'bar'], foo: true }, '"xor" function does not support "foo" option'], [ { properties: ['foo', 'bar', 'baz'] }, - '"xor" and its "properties" option support 2-item tuples, i.e. ["id", "name"]', + [ + new RulesetValidationError( + '"xor" and its "properties" option support 2-item tuples, i.e. ["id", "name"]', + [], + ), + ], + ], + [ + { properties: ['foo', {}] }, + [ + new RulesetValidationError( + '"xor" and its "properties" option support 2-item tuples, i.e. ["id", "name"]', + [], + ), + ], + ], + [ + { properties: ['foo'] }, + [ + new RulesetValidationError( + '"xor" and its "properties" option support 2-item tuples, i.e. ["id", "name"]', + [], + ), + ], + ], + [ + { properties: [] }, + [ + new RulesetValidationError( + '"xor" and its "properties" option support 2-item tuples, i.e. ["id", "name"]', + [], + ), + ], ], - [{ properties: ['foo', {}] }, '"xor" and its "properties" option support 2-item tuples, i.e. ["id", "name"]'], - [{ properties: ['foo'] }, '"xor" and its "properties" option support 2-item tuples, i.e. ["id", "name"]'], - [{ properties: [] }, '"xor" and its "properties" option support 2-item tuples, i.e. ["id", "name"]'], - ])('given invalid %p options, should throw', async (opts, error) => { - await expect(runXor({}, opts)).rejects.toThrow(new RulesetValidationError(error)); + ])('given invalid %p options, should throw', async (opts, errors) => { + await expect(runXor({}, opts)).rejects.toThrowAggregateError(new AggregateError(errors)); }); }); }); diff --git a/test-utils/index.ts b/test-utils/index.ts index 1bd360767..f7a90b84a 100644 --- a/test-utils/index.ts +++ b/test-utils/index.ts @@ -1,6 +1,4 @@ -declare module '@stoplight/spectral-test-utils' { - type Body = string | Record; +type Body = string | Record; - export function serveAssets(mocks: Record): void; - export function mockResponses(mocks: Record>): void; -} +export declare function serveAssets(mocks: Record): void; +export declare function mockResponses(mocks: Record>): void; diff --git a/test-utils/matchers.ts b/test-utils/matchers.ts new file mode 100644 index 000000000..ca1376f09 --- /dev/null +++ b/test-utils/matchers.ts @@ -0,0 +1,34 @@ +import AggregateError = require('es-aggregate-error'); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface Matchers { + toThrowAggregateError(error: AggregateError): R; + } + } +} + +expect.extend({ + toThrowAggregateError(received, expected) { + let error: unknown; + if (typeof received === 'function') { + expect(received).toThrow(AggregateError); + try { + received(); + } catch (e) { + error = e; + } + } else { + error = received; + } + + expect(error).toBeInstanceOf(Error); + expect((error as AggregateError).errors).toEqual(expected.errors); + + return { + message: () => 'All errors matched!', + pass: true, + }; + }, +}); diff --git a/tsconfig.json b/tsconfig.json index b260097c4..0d8c67d25 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,9 @@ "@stoplight/spectral-ruleset-migrator": ["packages/ruleset-migrator/src/index.ts"], "@stoplight/spectral-rulesets": ["packages/rulesets/src/index.ts"], + "@stoplight/spectral-test-utils": ["test-utils/index.ts"], + "@stoplight/spectral-test-utils/matchers": ["test-utils/matchers.ts"], + "nimma/fallbacks": ["node_modules/nimma/dist/cjs/fallbacks/"], "nimma/legacy": ["node_modules/nimma/dist/legacy/cjs/"] }, diff --git a/yarn.lock b/yarn.lock index c222a3281..854ea8266 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2391,6 +2391,7 @@ __metadata: "@stoplight/spectral-runtime": ^1.0.0 "@stoplight/types": 13.0.0 "@stoplight/yaml": ^4.2.2 + "@types/es-aggregate-error": ^1.0.2 "@types/json-schema": ^7.0.7 "@types/minimatch": ^3.0.5 "@types/treeify": ^1.0.0 @@ -2398,6 +2399,7 @@ __metadata: ajv-errors: ~3.0.0 ajv-formats: ~2.1.0 blueimp-md5: 2.18.0 + es-aggregate-error: ^1.0.7 jsonpath-plus: 6.0.1 lodash: ~4.17.21 lodash.topath: ^4.5.2 @@ -3253,14 +3255,14 @@ __metadata: linkType: hard "ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.6.0, ajv@npm:^8.6.3, ajv@npm:^8.8.2": - version: 8.8.2 - resolution: "ajv@npm:8.8.2" + version: 8.9.0 + resolution: "ajv@npm:8.9.0" dependencies: fast-deep-equal: ^3.1.1 json-schema-traverse: ^1.0.0 require-from-string: ^2.0.2 uri-js: ^4.2.2 - checksum: 90849ef03c4f4f7051d15f655120137b89e3205537d683beebd39d95f40c0ca00ea8476cd999602d2f433863e7e4bf1b81d1869d1e07f4dcf56d71b6430a605c + checksum: 756c048bfa917b43bb84c8a0a53e6a489123203bc4bdec8cbeb8ec2d715674f5e61d49560a1a6ec83268af4f33bed324f5cb6d9c76d96849fd58ed7089b8e7f3 languageName: node linkType: hard @@ -6511,7 +6513,7 @@ __metadata: languageName: node linkType: hard -"globby@npm:^11.0.0, globby@npm:^11.0.1, globby@npm:^11.0.3": +"globby@npm:^11.0.0, globby@npm:^11.0.1": version: 11.1.0 resolution: "globby@npm:11.1.0" dependencies: @@ -6525,6 +6527,20 @@ __metadata: languageName: node linkType: hard +"globby@npm:^11.0.3": + version: 11.0.4 + resolution: "globby@npm:11.0.4" + dependencies: + array-union: ^2.1.0 + dir-glob: ^3.0.1 + fast-glob: ^3.2.9 + ignore: ^5.2.0 + merge2: ^1.4.1 + slash: ^3.0.0 + checksum: d3e02d5e459e02ffa578b45f040381c33e3c0538ed99b958f0809230c423337999867d7b0dbf752ce93c46157d3bbf154d3fff988a93ccaeb627df8e1841775b + languageName: node + linkType: hard + "graceful-fs@npm:*, graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.2": version: 4.2.9 resolution: "graceful-fs@npm:4.2.9" @@ -8103,13 +8119,6 @@ __metadata: languageName: node linkType: hard -"json-schema@npm:^0.4.0": - version: 0.4.0 - resolution: "json-schema@npm:0.4.0" - checksum: 66389434c3469e698da0df2e7ac5a3281bcff75e797a5c127db7c5b56270e01ae13d9afa3c03344f76e32e81678337a8c912bdbb75101c62e487dc3778461d72 - languageName: node - linkType: hard - "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -11225,7 +11234,6 @@ __metadata: jest: ^27.4.3 jest-mock: ^27.4.2 jest-when: ^3.4.2 - json-schema: ^0.4.0 karma: ^6.1.1 karma-chrome-launcher: ^3.1.0 karma-jasmine: ^3.3.1