From b194368164d92dce31b7ceba84ccc94fbe51f979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Thu, 3 Mar 2022 21:21:29 +0100 Subject: [PATCH] feat(cli): improve error logging (#2071) --- .eslintrc | 6 +- __karma__/process.js | 1 - __mocks__/nanoid/non-secure.ts | 7 - __mocks__/process.js | 15 ++ karma.conf.ts | 2 +- packages/cli/package.json | 7 +- .../cli/src/commands/__tests__/lint.test.ts | 81 ++++++++--- packages/cli/src/commands/lint.ts | 135 ++++++++++++------ packages/cli/src/errors/index.ts | 1 + .../cli/src/services/__tests__/linter.test.ts | 24 ++-- .../cli/src/services/__tests__/output.test.ts | 6 +- packages/cli/src/services/linter/linter.ts | 3 +- .../src/services/linter/utils/getResolver.ts | 3 +- .../src/services/linter/utils/getRuleset.ts | 8 +- packages/core/package.json | 1 + packages/core/src/runner/lintNode.ts | 21 ++- .../src/__tests__/__helpers__/tester.ts | 31 ++-- .../scenarios/runtime-errors.scenario | 68 +++++++++ tsconfig.json | 2 +- yarn.lock | 116 +++++++++++++-- 20 files changed, 409 insertions(+), 129 deletions(-) delete mode 100644 __karma__/process.js delete mode 100644 __mocks__/nanoid/non-secure.ts create mode 100644 __mocks__/process.js create mode 100644 packages/cli/src/errors/index.ts create mode 100644 test-harness/scenarios/runtime-errors.scenario diff --git a/.eslintrc b/.eslintrc index d0c1760d6..95efccda1 100644 --- a/.eslintrc +++ b/.eslintrc @@ -84,10 +84,12 @@ { "files": [ - "src/**/__tests__/**/*.jest.test.{ts,js}" + "src/**/__tests__/**/*.jest.test.{ts,js}", + "__mocks__/**/*.{ts,js}" ], "env": { - "jest": true + "jest": true, + "node": true } }, diff --git a/__karma__/process.js b/__karma__/process.js deleted file mode 100644 index 98594c821..000000000 --- a/__karma__/process.js +++ /dev/null @@ -1 +0,0 @@ -export const on = Function(); diff --git a/__mocks__/nanoid/non-secure.ts b/__mocks__/nanoid/non-secure.ts deleted file mode 100644 index 3c280b83b..000000000 --- a/__mocks__/nanoid/non-secure.ts +++ /dev/null @@ -1,7 +0,0 @@ -let seed = 0; - -beforeEach(() => { - seed = 0; -}); - -module.exports = jest.fn(() => `random-id-${seed++}`); diff --git a/__mocks__/process.js b/__mocks__/process.js new file mode 100644 index 000000000..85548b6bc --- /dev/null +++ b/__mocks__/process.js @@ -0,0 +1,15 @@ +module.exports.exit = jest.fn(); +module.exports.cwd = jest.fn(); +module.exports.on = jest.fn(); + +module.exports.stdin = { + fd: 0, + isTTY: true, +}; +module.exports.stdout = { + write: jest.fn(), +}; + +module.exports.stderr = { + write: jest.fn(), +}; diff --git a/karma.conf.ts b/karma.conf.ts index 2581b4bb6..bc18c36ec 100644 --- a/karma.conf.ts +++ b/karma.conf.ts @@ -40,7 +40,7 @@ module.exports = (config: Config): void => { 'nimma/legacy': require.resolve('./node_modules/nimma/dist/legacy/cjs/index.js'), 'node-fetch': require.resolve('./__karma__/fetch'), fs: require.resolve('./__karma__/fs'), - process: require.resolve('./__karma__/process'), + process: require.resolve('./__mocks__/process'), perf_hooks: require.resolve('./__karma__/perf_hooks'), fsevents: require.resolve('./__karma__/fsevents'), }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 5ea1046ed..7e3a7d929 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -51,15 +51,18 @@ "lodash": "~4.17.21", "pony-cause": "^1.0.0", "proxy-agent": "5.0.0", + "stacktracey": "^2.1.7", "strip-ansi": "6.0", "text-table": "0.2", "tslib": "^2.3.0", "yargs": "17.3.1" }, "devDependencies": { + "@types/es-aggregate-error": "^1.0.2", "@types/xml2js": "^0.4.9", "@types/yargs": "^17.0.8", "copyfiles": "^2.4.1", + "es-aggregate-error": "^1.0.7", "jest-when": "^3.4.2", "nock": "^13.1.3", "node-html-parser": "^4.1.5", @@ -73,7 +76,9 @@ ], "assets": [ "./dist/**/*.json", - "./dist/**/*.html" + "./dist/**/*.html", + "../*/dist/**/*.js.map", + "../*/src/**/*.ts" ] } } diff --git a/packages/cli/src/commands/__tests__/lint.test.ts b/packages/cli/src/commands/__tests__/lint.test.ts index 40f1aac6d..eb98e7050 100644 --- a/packages/cli/src/commands/__tests__/lint.test.ts +++ b/packages/cli/src/commands/__tests__/lint.test.ts @@ -1,12 +1,16 @@ import * as yargs from 'yargs'; -import { noop } from 'lodash'; import { DiagnosticSeverity } from '@stoplight/types'; import { IRuleResult } from '@stoplight/spectral-core'; +import * as process from 'process'; +import { ErrorWithCause } from 'pony-cause'; +import AggregateError from 'es-aggregate-error'; import { lint } from '../../services/linter'; import { formatOutput, writeOutput } from '../../services/output'; import lintCommand from '../lint'; +import chalk from 'chalk'; +jest.mock('process'); jest.mock('../../services/output'); jest.mock('../../services/linter'); @@ -24,9 +28,6 @@ function run(command: string) { } describe('lint', () => { - let errorSpy: jest.SpyInstance; - const { isTTY } = process.stdin; - const results: IRuleResult[] = [ { code: 'parser', @@ -47,29 +48,26 @@ describe('lint', () => { ]; beforeEach(() => { - (lint as jest.Mock).mockClear(); (lint as jest.Mock).mockResolvedValueOnce(results); - - (formatOutput as jest.Mock).mockClear(); (formatOutput as jest.Mock).mockReturnValueOnce(''); - - (writeOutput as jest.Mock).mockClear(); (writeOutput as jest.Mock).mockResolvedValueOnce(undefined); - - errorSpy = jest.spyOn(console, 'error').mockImplementation(noop); }); afterEach(() => { - errorSpy.mockRestore(); - process.stdin.isTTY = isTTY; + process.stdin.isTTY = true; + jest.clearAllMocks(); + jest.resetAllMocks(); }); it('shows help when no document and no STDIN are present', () => { - process.stdin.isTTY = true; return expect(run('lint')).rejects.toContain('documents Location of JSON/YAML documents'); }); describe('when STDIN is present', () => { + beforeEach(() => { + process.stdin.isTTY = false; + }); + it('does not show help when documents are missing', async () => { const output = await run('lint'); expect(output).not.toContain('documents Location of JSON/YAML documents'); @@ -150,35 +148,29 @@ describe('lint', () => { it.each(['json', 'stylish'])('calls formatOutput with %s format', async format => { await run(`lint -f ${format} ./__fixtures__/empty-oas2-document.json`); - await new Promise(resolve => void process.nextTick(resolve)); expect(formatOutput).toBeCalledWith(results, format, { failSeverity: DiagnosticSeverity.Error }); }); it('writes formatted output to a file', async () => { await run(`lint -o foo.json ./__fixtures__/empty-oas2-document.json`); - await new Promise(resolve => void process.nextTick(resolve)); expect(writeOutput).toBeCalledWith('', 'foo.json'); }); it('writes formatted output to multiple files when using format and output flags', async () => { - (formatOutput as jest.Mock).mockClear(); (formatOutput as jest.Mock).mockReturnValue(''); await run( `lint --format html --format json --output.json foo.json --output.html foo.html ./__fixtures__/empty-oas2-document.json`, ); - await new Promise(resolve => void process.nextTick(resolve)); expect(writeOutput).toBeCalledTimes(2); expect(writeOutput).nthCalledWith(1, '', 'foo.html'); expect(writeOutput).nthCalledWith(2, '', 'foo.json'); }); it('writes formatted output to multiple files and stdout when using format and output flags', async () => { - (formatOutput as jest.Mock).mockClear(); (formatOutput as jest.Mock).mockReturnValue(''); await run(`lint --format html --format json --output.json foo.json ./__fixtures__/empty-oas2-document.json`); - await new Promise(resolve => void process.nextTick(resolve)); expect(writeOutput).toBeCalledTimes(2); expect(writeOutput).nthCalledWith(1, '', ''); expect(writeOutput).nthCalledWith(2, '', 'foo.json'); @@ -216,8 +208,51 @@ describe('lint', () => { const error = new Error('Failure'); (lint as jest.Mock).mockReset(); (lint as jest.Mock).mockRejectedValueOnce(error); - await run(`lint -o foo.json ./__fixtures__/empty-oas2-document.json`); - await new Promise(resolve => void process.nextTick(resolve)); - expect(errorSpy).toBeCalledWith('Failure'); + await run(`lint ./__fixtures__/empty-oas2-document.json`); + expect(process.stderr.write).nthCalledWith(1, chalk.red('Error running Spectral!\n')); + expect(process.stderr.write).nthCalledWith(2, chalk.red('Use --verbose flag to print the error stack.\n')); + expect(process.stderr.write).nthCalledWith(3, `Error #1: ${chalk.red('Failure')}\n`); + }); + + it('prints each error separately', async () => { + (lint as jest.Mock).mockReset(); + (lint as jest.Mock).mockRejectedValueOnce( + new AggregateError([ + new Error('some unhandled exception'), + new TypeError('another one'), + new ErrorWithCause('some error with cause', { cause: 'original exception' }), + ]), + ); + await run(`lint ./__fixtures__/empty-oas2-document.json`); + expect(process.stderr.write).nthCalledWith(3, `Error #1: ${chalk.red('some unhandled exception')}\n`); + expect(process.stderr.write).nthCalledWith(4, `Error #2: ${chalk.red('another one')}\n`); + expect(process.stderr.write).nthCalledWith(5, `Error #3: ${chalk.red('original exception')}\n`); + }); + + it('given verbose flag, prints each error together with their stacks', async () => { + (lint as jest.Mock).mockReset(); + (lint as jest.Mock).mockRejectedValueOnce( + new AggregateError([ + new Error('some unhandled exception'), + new TypeError('another one'), + new ErrorWithCause('some error with cause', { cause: 'original exception' }), + ]), + ); + + await run(`lint --verbose ./__fixtures__/empty-oas2-document.json`); + + expect(process.stderr.write).nthCalledWith(2, `Error #1: ${chalk.red('some unhandled exception')}\n`); + expect(process.stderr.write).nthCalledWith( + 3, + expect.stringContaining(`packages/cli/src/commands/__tests__/lint.test.ts:236`), + ); + + expect(process.stderr.write).nthCalledWith(4, `Error #2: ${chalk.red('another one')}\n`); + expect(process.stderr.write).nthCalledWith( + 5, + expect.stringContaining(`packages/cli/src/commands/__tests__/lint.test.ts:237`), + ); + + expect(process.stderr.write).nthCalledWith(6, `Error #3: ${chalk.red('original exception')}\n`); }); }); diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 33f2cb532..094991c17 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -1,14 +1,20 @@ import { Dictionary } from '@stoplight/types'; import { isPlainObject } from '@stoplight/json'; -import { difference, noop, pick } from 'lodash'; -import { ReadStream } from 'tty'; -import type { CommandModule } from 'yargs'; import { getDiagnosticSeverity, IRuleResult } from '@stoplight/spectral-core'; +import { isError, difference, pick } from 'lodash'; +import type { ReadStream } from 'tty'; +import type { CommandModule } from 'yargs'; +import * as process from 'process'; +import chalk from 'chalk'; +import type { ErrorWithCause } from 'pony-cause'; +import StackTracey from 'stacktracey'; import { lint } from '../services/linter'; import { formatOutput, writeOutput } from '../services/output'; import { FailSeverity, ILintConfig, OutputFormat } from '../services/config'; +import { CLIError } from '../errors'; + const formatOptions = Object.values(OutputFormat); const lintCommand: CommandModule = { @@ -55,7 +61,7 @@ const lintCommand: CommandModule = { }) .check((argv: Dictionary) => { if (!Array.isArray(argv.documents) || argv.documents.length === 0) { - throw new TypeError('No documents provided.'); + throw new CLIError('No documents provided.'); } const format = argv.format as string[] & { 0: string }; @@ -66,21 +72,21 @@ const lintCommand: CommandModule = { return true; } - throw new TypeError('Output must be either string or unspecified when a single format is specified'); + throw new CLIError('Output must be either string or unspecified when a single format is specified'); } if (!isPlainObject(output)) { - throw new TypeError('Multiple outputs have to be provided when more than a single format is specified'); + throw new CLIError('Multiple outputs have to be provided when more than a single format is specified'); } const keys = Object.keys(output); if (format.length !== keys.length) { - throw new TypeError('The number of outputs must match the number of formats'); + throw new CLIError('The number of outputs must match the number of formats'); } const diff = difference(format, keys); if (diff.length !== 0) { - throw new TypeError(`Missing outputs for the following formats: ${diff.join(', ')}`); + throw new CLIError(`Missing outputs for the following formats: ${diff.join(', ')}`); } return true; @@ -156,7 +162,7 @@ const lintCommand: CommandModule = { }, }), - handler: args => { + async handler(args) { const { documents, failSeverity, @@ -175,45 +181,90 @@ const lintCommand: CommandModule = { displayOnlyFailures: boolean; }; - return lint(documents, { - format, - output, - encoding, - ignoreUnknownFormat, - failOnUnmatchedGlobs, - ruleset, - stdinFilepath, - ...pick, keyof ILintConfig>(config, ['verbose', 'quiet', 'resolver']), - }) - .then(results => { - if (displayOnlyFailures) { - return filterResultsBySeverity(results, failSeverity); - } - return results; - }) - .then(results => { - if (results.length > 0) { - process.exitCode = severeEnoughToFail(results, failSeverity) ? 1 : 0; - } else if (config.quiet !== true) { - console.log(`No results with a severity of '${failSeverity}' or higher found!`); - } + try { + let results = await lint(documents, { + format, + output, + encoding, + ignoreUnknownFormat, + failOnUnmatchedGlobs, + ruleset, + stdinFilepath, + ...pick, keyof ILintConfig>(config, ['verbose', 'quiet', 'resolver']), + }); - return Promise.all( - format.map(f => { - const formattedOutput = formatOutput(results, f, { failSeverity: getDiagnosticSeverity(failSeverity) }); - return writeOutput(formattedOutput, output?.[f] ?? ''); - }), - ).then(noop); - }) - .catch(fail); + if (displayOnlyFailures) { + results = filterResultsBySeverity(results, failSeverity); + } + + await Promise.all( + format.map(f => { + const formattedOutput = formatOutput(results, f, { failSeverity: getDiagnosticSeverity(failSeverity) }); + return writeOutput(formattedOutput, output?.[f] ?? ''); + }), + ); + + if (results.length > 0) { + process.exit(severeEnoughToFail(results, failSeverity) ? 1 : 0); + } else if (config.quiet !== true) { + process.stdout.write(`No results with a severity of '${failSeverity}' or higher found!`); + } + } catch (ex) { + fail(isError(ex) ? ex : new Error(String(ex)), config.verbose === true); + } }, }; -const fail = ({ message }: Error): void => { - console.error(message); - process.exitCode = 2; +const fail = (error: Error | ErrorWithCause | AggregateError, verbose: boolean): void => { + if (error instanceof CLIError) { + process.stderr.write(chalk.red(error.message)); + process.exit(2); + } + + const errors: unknown[] = 'errors' in error ? error.errors : [error]; + + process.stderr.write(chalk.red('Error running Spectral!\n')); + + if (!verbose) { + process.stderr.write(chalk.red('Use --verbose flag to print the error stack.\n')); + } + + for (const [i, error] of errors.entries()) { + const actualError: unknown = isError(error) && 'cause' in error ? (error as ErrorWithCause).cause : error; + const message = isError(actualError) ? actualError.message : String(actualError); + + const info = `Error #${i + 1}: `; + + process.stderr.write(`${info}${chalk.red(message)}\n`); + + if (verbose && isError(actualError)) { + process.stderr.write(`${chalk.red(printErrorStacks(actualError, info.length))}\n`); + } + } + + process.exit(2); }; +function getWidth(ratio: number): number { + return Math.min(20, Math.floor(ratio * process.stderr.columns)); +} + +function printErrorStacks(error: Error, padding: number): string { + return new StackTracey(error) + .slice(0, 5) + .withSources() + .asTable({ + maxColumnWidths: { + callee: getWidth(0.2), + file: getWidth(0.4), + sourceLine: getWidth(0.4), + }, + }) + .split('\n') + .map(error => `${' '.repeat(padding)}${error}`) + .join('\n'); +} + const filterResultsBySeverity = (results: IRuleResult[], failSeverity: FailSeverity): IRuleResult[] => { const diagnosticSeverity = getDiagnosticSeverity(failSeverity); return results.filter(r => r.severity <= diagnosticSeverity); diff --git a/packages/cli/src/errors/index.ts b/packages/cli/src/errors/index.ts new file mode 100644 index 000000000..a993194ef --- /dev/null +++ b/packages/cli/src/errors/index.ts @@ -0,0 +1 @@ +export class CLIError extends Error {} diff --git a/packages/cli/src/services/__tests__/linter.test.ts b/packages/cli/src/services/__tests__/linter.test.ts index 4ab846dea..84ed44522 100644 --- a/packages/cli/src/services/__tests__/linter.test.ts +++ b/packages/cli/src/services/__tests__/linter.test.ts @@ -1,11 +1,14 @@ import { join, resolve } from '@stoplight/path'; import nock from 'nock'; import * as yargs from 'yargs'; -import lintCommand from '../../commands/lint'; -import { lint } from '../linter'; import { DiagnosticSeverity } from '@stoplight/types'; import { RulesetValidationError } from '@stoplight/spectral-core'; +import * as process from 'process'; +import lintCommand from '../../commands/lint'; +import { lint } from '../linter'; + +jest.mock('process'); jest.mock('../output'); const validCustomOas3SpecPath = resolve(__dirname, '__fixtures__/openapi-3.0-valid-custom.yaml'); @@ -29,25 +32,16 @@ async function run(command: string) { } describe('Linter service', () => { - let consoleLogSpy: jest.SpyInstance; - let consoleErrorSpy: jest.SpyInstance; let processCwdSpy: jest.SpyInstance; beforeEach(() => { - const noop = () => { - /* no-op */ - }; - consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(noop); - consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(noop); - - processCwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(join(__dirname, '__fixtures__')); + (process.cwd as jest.Mock).mockReturnValue(join(__dirname, '__fixtures__')); + processCwdSpy = jest.spyOn(globalThis.process, 'cwd').mockImplementation(process.cwd); }); afterEach(() => { - consoleLogSpy.mockRestore(); - consoleErrorSpy.mockRestore(); processCwdSpy.mockRestore(); - + jest.clearAllMocks(); nock.cleanAll(); }); @@ -93,7 +87,7 @@ describe('Linter service', () => { }); it('demands some ruleset to be present', () => { - processCwdSpy.mockReturnValue(join(__dirname, '__fixtures__/resolver')); + (process.cwd as jest.Mock).mockReturnValue(join(__dirname, '__fixtures__/resolver')); return expect(run(`lint stoplight-info-document.json`)).rejects.toThrow( 'No ruleset has been found. Please provide a ruleset using the --ruleset CLI argument, or make sure your ruleset file matches .?spectral.(js|ya?ml|json)', ); diff --git a/packages/cli/src/services/__tests__/output.test.ts b/packages/cli/src/services/__tests__/output.test.ts index d04e6a5cb..c9f08e305 100644 --- a/packages/cli/src/services/__tests__/output.test.ts +++ b/packages/cli/src/services/__tests__/output.test.ts @@ -12,11 +12,7 @@ jest.mock('fs', () => ({ writeFile: jest.fn().mockResolvedValue(void 0), }, })); -jest.mock('process', () => ({ - stdout: { - write: jest.fn(), - }, -})); +jest.mock('process'); describe('Output service', () => { describe('formatOutput', () => { diff --git a/packages/cli/src/services/linter/linter.ts b/packages/cli/src/services/linter/linter.ts index c02e4ce46..7796b81b9 100644 --- a/packages/cli/src/services/linter/linter.ts +++ b/packages/cli/src/services/linter/linter.ts @@ -5,6 +5,7 @@ import * as Parsers from '@stoplight/spectral-parsers'; import { getRuleset, listFiles, segregateEntriesPerKind, readFileDescriptor } from './utils'; import { getResolver } from './utils/getResolver'; import { ILintConfig } from '../config'; +import { CLIError } from '../../errors'; export async function lint(documents: Array, flags: ILintConfig): Promise { const spectral = new Spectral({ @@ -25,7 +26,7 @@ export async function lint(documents: Array, flags: ILintConfig if (unmatchedPatterns.length > 0) { if (flags.failOnUnmatchedGlobs) { - throw new Error(`Unmatched glob patterns: \`${unmatchedPatterns.join(',')}\``); + throw new CLIError(`Unmatched glob patterns: \`${unmatchedPatterns.join(',')}\``); } for (const unmatchedPattern of unmatchedPatterns) { diff --git a/packages/cli/src/services/linter/utils/getResolver.ts b/packages/cli/src/services/linter/utils/getResolver.ts index dee98fd39..db63d5a9e 100644 --- a/packages/cli/src/services/linter/utils/getResolver.ts +++ b/packages/cli/src/services/linter/utils/getResolver.ts @@ -2,6 +2,7 @@ import { isAbsolute, join } from '@stoplight/path'; import { Optional } from '@stoplight/types'; import { createHttpAndFileResolver, Resolver } from '@stoplight/spectral-ref-resolver'; import { isError } from 'lodash'; +import { CLIError } from '../../../errors'; export const getResolver = (resolver: Optional, proxy: Optional): Resolver => { if (resolver !== void 0) { @@ -9,7 +10,7 @@ export const getResolver = (resolver: Optional, proxy: Optional) // eslint-disable-next-line @typescript-eslint/no-unsafe-return return require(isAbsolute(resolver) ? resolver : join(process.cwd(), resolver)); } catch (ex) { - throw new Error(isError(ex) ? formatMessage(ex.message) : String(ex)); + throw new CLIError(isError(ex) ? formatMessage(ex.message) : String(ex)); } } diff --git a/packages/cli/src/services/linter/utils/getRuleset.ts b/packages/cli/src/services/linter/utils/getRuleset.ts index c16ea733e..f2af0b7c9 100644 --- a/packages/cli/src/services/linter/utils/getRuleset.ts +++ b/packages/cli/src/services/linter/utils/getRuleset.ts @@ -12,7 +12,7 @@ import { stdin } from '@stoplight/spectral-ruleset-bundler/plugins/stdin'; import { builtins } from '@stoplight/spectral-ruleset-bundler/plugins/builtins'; import { isError, isObject } from 'lodash'; import commonjs from '@rollup/plugin-commonjs'; -import { ErrorWithCause } from 'pony-cause'; +import { CLIError } from '../../../errors'; async function getDefaultRulesetFile(): Promise> { const cwd = process.cwd(); @@ -41,7 +41,7 @@ export async function getRuleset(rulesetFile: Optional): Promise): Promise 0 ? [...givenPath, ...target.path] : givenPath; - const targetResults = then.function(target.value, then.functionOptions ?? null, { - ...fnContext, - path, - }); + let targetResults; + try { + targetResults = then.function(target.value, then.functionOptions ?? null, { + ...fnContext, + path, + }); + } catch (e) { + throw new ErrorWithCause( + `Function "${then.function.name}" threw an exception${isError(e) ? `: ${e.message}` : ''}`, + { + cause: e, + }, + ); + } if (targetResults === void 0) continue; diff --git a/packages/functions/src/__tests__/__helpers__/tester.ts b/packages/functions/src/__tests__/__helpers__/tester.ts index 0dc0c0a9b..21a1c03ea 100644 --- a/packages/functions/src/__tests__/__helpers__/tester.ts +++ b/packages/functions/src/__tests__/__helpers__/tester.ts @@ -38,18 +38,27 @@ export default async function ( path: error.path, message: error.message, })); - } catch (e: unknown) { - if ( - e instanceof Error && - Array.isArray((e as Error & { errors?: unknown }).errors) && - (e as Error & { errors: unknown[] }).errors.length === 1 - ) { - const actualError = (e as Error & { errors: [unknown] }).errors[0]; - throw actualError instanceof Error && 'cause' in (actualError as Error & { cause?: unknown }) - ? (actualError as Error & { cause: unknown }).cause - : actualError; + } 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 e; + throw error; } } } + +function getCause(error: unknown): unknown { + if (error instanceof Error && 'cause' in (error as Error & { cause?: unknown })) { + return getCause((error as Error & { cause?: unknown }).cause); + } + + return error; +} diff --git a/test-harness/scenarios/runtime-errors.scenario b/test-harness/scenarios/runtime-errors.scenario new file mode 100644 index 000000000..c675c986c --- /dev/null +++ b/test-harness/scenarios/runtime-errors.scenario @@ -0,0 +1,68 @@ +====test==== +Logs all runtime errors thrown during the linting process +====document==== +schemas: + user: + type: object + properties: + name: + type: number + age: + type: number + occupation: + type: boolean + addresses: + - +====command==== +{bin} lint {document} -r "{asset:ruleset.js}" +====asset:ruleset.js==== +const { truthy } = require("@stoplight/spectral-functions"); + +function validAddress(input) { + throw new TypeError(`Cannot read properties of null (reading 'test')`); +} + +function upperCase() { + return String(input).toLowerCase() === String(input).toUpperCase(); +} + +module.exports = { + "rules": { + "valid-user-properties": { + "severity": "error", + "given": [ + "$.schemas.user.properties.name", + "$.schemas.user.properties.occupation" + ], + "then": { + "field": "type", + "function": upperCase, + } + }, + "valid-address": { + "given": [ + "$..addresses[*]", + ], + "then": { + "function": validAddress + } + }, + "require-user-and-address": { + "severity": "error", + "given": [ + "$.schemas.user", + ], + "then": { + "function": truthy + } + } + } +} +====status==== +2 +====stderr==== +Error running Spectral! +Use --verbose flag to print the error stack. +Error #1: Function "upperCase" threw an exception: input is not defined +Error #2: Function "upperCase" threw an exception: input is not defined +Error #3: Function "validAddress" threw an exception: Cannot read properties of null (reading 'test') diff --git a/tsconfig.json b/tsconfig.json index f57c5fa8c..b260097c4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,7 +22,7 @@ "target": "ES2019", "module": "CommonJS", "esModuleInterop": true, - "lib": ["es2020"], + "lib": ["es2021"], "strict": true, "pretty": true, "experimentalDecorators": true, diff --git a/yarn.lock b/yarn.lock index 815b0186a..c809499b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2349,12 +2349,14 @@ __metadata: "@stoplight/spectral-rulesets": ">=1" "@stoplight/spectral-runtime": ^1.1.0 "@stoplight/types": 12.3.0 + "@types/es-aggregate-error": ^1.0.2 "@types/xml2js": ^0.4.9 "@types/yargs": ^17.0.8 chalk: 4.1.2 cliui: 7.0.4 copyfiles: ^2.4.1 eol: 0.9.1 + es-aggregate-error: ^1.0.7 fast-glob: 3.2.7 jest-when: ^3.4.2 lodash: ~4.17.21 @@ -2363,6 +2365,7 @@ __metadata: pkg: ^5.4.1 pony-cause: ^1.0.0 proxy-agent: 5.0.0 + stacktracey: ^2.1.7 strip-ansi: 6.0 text-table: 0.2 tslib: ^2.3.0 @@ -2402,6 +2405,7 @@ __metadata: minimatch: 3.0.4 nimma: 0.1.8 nock: ^13.1.0 + pony-cause: ^1.0.0 simple-eval: 1.0.0 treeify: ^1.1.0 tslib: ^2.3.0 @@ -2688,6 +2692,15 @@ __metadata: languageName: node linkType: hard +"@types/es-aggregate-error@npm:^1.0.2": + version: 1.0.2 + resolution: "@types/es-aggregate-error@npm:1.0.2" + dependencies: + "@types/node": "*" + checksum: 076fd59b595f33c8c7e7eb68ec55bd43cf8b2cf7bbc6778e25d7ae1a5fa0538a0a56f149015f403d7bbcefe59f1d8182351685b59c1fe719fd46d0dd8a9737fa + languageName: node + linkType: hard + "@types/estree@npm:*": version: 0.0.50 resolution: "@types/estree@npm:0.0.50" @@ -3118,7 +3131,7 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1": +"acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1, acorn-walk@npm:^8.2.0": version: 8.2.0 resolution: "acorn-walk@npm:8.2.0" checksum: 1715e76c01dd7b2d4ca472f9c58968516a4899378a63ad5b6c2d668bba8da21a71976c14ec5f5b75f887b6317c4ae0b897ab141c831d741dc76024d8745f1ad1 @@ -3143,6 +3156,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^8.7.0": + version: 8.7.0 + resolution: "acorn@npm:8.7.0" + bin: + acorn: bin/acorn + checksum: e0f79409d68923fbf1aa6d4166f3eedc47955320d25c89a20cc822e6ba7c48c5963d5bc657bc242d68f7a4ac9faf96eef033e8f73656da6c640d4219935fdfd0 + languageName: node + linkType: hard + "agent-base@npm:6, agent-base@npm:^6.0.0, agent-base@npm:^6.0.2": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -3434,6 +3456,15 @@ __metadata: languageName: node linkType: hard +"as-table@npm:^1.0.36": + version: 1.0.55 + resolution: "as-table@npm:1.0.55" + dependencies: + printable-characters: ^1.0.42 + checksum: 341c99d9e99a702c315b3f0744d49b4764b26ef7ddd32bafb9e1706626560c0e599100521fc1b17f640e804bd0503ce70b2ba519c023da6edf06bdd9086dccd9 + languageName: node + linkType: hard + "asap@npm:^2.0.0": version: 2.0.6 resolution: "asap@npm:2.0.6" @@ -4726,6 +4757,13 @@ __metadata: languageName: node linkType: hard +"data-uri-to-buffer@npm:^2.0.0": + version: 2.0.2 + resolution: "data-uri-to-buffer@npm:2.0.2" + checksum: 152bec5e77513ee253a7c686700a1723246f582dad8b614e8eaaaba7fa45a15c8671ae4b8f4843f4f3a002dae1d3e7a20f852f7d7bdc8b4c15cfe7adfdfb07f8 + languageName: node + linkType: hard + "data-urls@npm:^2.0.0": version: 2.0.0 resolution: "data-urls@npm:2.0.0" @@ -4875,14 +4913,14 @@ __metadata: linkType: hard "degenerator@npm:^3.0.1": - version: 3.0.1 - resolution: "degenerator@npm:3.0.1" + version: 3.0.2 + resolution: "degenerator@npm:3.0.2" dependencies: ast-types: ^0.13.2 escodegen: ^1.8.1 esprima: ^4.0.0 - vm2: ^3.9.3 - checksum: 110d5fa772933d21484995e518feeb2ea54e5804421edf8546900973a227dcdf621a0cbac0a5d0a13273424ea3763aba815246dfffa386483f5480d60f50bed1 + vm2: ^3.9.8 + checksum: 6a8fffe1ddde692931a1d74c0636d9e6963f2aa16748d4b95f4833cdcbe8df571e5c127e4f1d625a4c340cc60f5a969ac9e5aa14baecfb6f69b85638e180cd97 languageName: node linkType: hard @@ -5307,6 +5345,20 @@ __metadata: languageName: node linkType: hard +"es-aggregate-error@npm:^1.0.7": + version: 1.0.7 + resolution: "es-aggregate-error@npm:1.0.7" + dependencies: + define-properties: ^1.1.3 + es-abstract: ^1.19.0 + function-bind: ^1.1.1 + functions-have-names: ^1.2.2 + get-intrinsic: ^1.1.1 + globalthis: ^1.0.2 + checksum: 16b89fefdf56c0478cd21577249156cf83e44c2220c057cbfddd99c01e15e03d6d90a85ce73dece4728a5bfcb022dc160e04a66b1f83a620f140842c6f8325f9 + languageName: node + linkType: hard + "es-to-primitive@npm:^1.2.1": version: 1.2.1 resolution: "es-to-primitive@npm:1.2.1" @@ -6211,6 +6263,13 @@ __metadata: languageName: node linkType: hard +"functions-have-names@npm:^1.2.2": + version: 1.2.2 + resolution: "functions-have-names@npm:1.2.2" + checksum: 25f44b6d1c41ac86ffdf41f25d1de81c0a5b4a3fcf4307a33cdfb23b9d4bd5d0d8bf312eaef5ad368c6500c8a9e19f692b8ce9f96aaab99db9dd936554165558 + languageName: node + linkType: hard + "gauge@npm:^4.0.0": version: 4.0.0 resolution: "gauge@npm:4.0.0" @@ -6276,6 +6335,16 @@ __metadata: languageName: node linkType: hard +"get-source@npm:^2.0.12": + version: 2.0.12 + resolution: "get-source@npm:2.0.12" + dependencies: + data-uri-to-buffer: ^2.0.0 + source-map: ^0.6.1 + checksum: c73368fee709594ba38682ec1a96872aac6f7d766396019611d3d2358b68186a7847765a773ea0db088c42781126cc6bc09e4b87f263951c74dae5dcea50ad42 + languageName: node + linkType: hard + "get-stdin@npm:^8.0.0": version: 8.0.0 resolution: "get-stdin@npm:8.0.0" @@ -6423,6 +6492,15 @@ __metadata: languageName: node linkType: hard +"globalthis@npm:^1.0.2": + version: 1.0.2 + resolution: "globalthis@npm:1.0.2" + dependencies: + define-properties: ^1.1.3 + checksum: 5a5f3c7ab94708260a98106b35946b74bb57f6b2013e39668dc9e8770b80a3418103b63a2b4aa01c31af15fdf6a2940398ffc0a408573c34c2304f928895adff + languageName: node + linkType: hard + "globby@npm:^11.0.0, globby@npm:^11.0.1, globby@npm:^11.0.3": version: 11.1.0 resolution: "globby@npm:11.1.0" @@ -10420,6 +10498,13 @@ __metadata: languageName: node linkType: hard +"printable-characters@npm:^1.0.42": + version: 1.0.42 + resolution: "printable-characters@npm:1.0.42" + checksum: 2724aa02919d7085933af0f8f904bd0de67a6b53834f2e5b98fc7aa3650e20755c805e8c85bcf96c09f678cb16a58b55640dd3a2da843195fce06b1ccb0c8ce4 + languageName: node + linkType: hard + "proc-log@npm:^1.0.0": version: 1.0.0 resolution: "proc-log@npm:1.0.0" @@ -11712,6 +11797,16 @@ __metadata: languageName: node linkType: hard +"stacktracey@npm:^2.1.7": + version: 2.1.8 + resolution: "stacktracey@npm:2.1.8" + dependencies: + as-table: ^1.0.36 + get-source: ^2.0.12 + checksum: abd8316b4e120884108f5a47b2f61abdcaeaa118afd95f3c48317cb057fff43d697450ba00de3f9fe7fee61ee72644ccda4db990a8e4553706644f7c17522eab + languageName: node + linkType: hard + "statuses@npm:>= 1.5.0 < 2, statuses@npm:~1.5.0": version: 1.5.0 resolution: "statuses@npm:1.5.0" @@ -12796,12 +12891,15 @@ __metadata: languageName: node linkType: hard -"vm2@npm:^3.9.3": - version: 3.9.5 - resolution: "vm2@npm:3.9.5" +"vm2@npm:^3.9.8": + version: 3.9.8 + resolution: "vm2@npm:3.9.8" + dependencies: + acorn: ^8.7.0 + acorn-walk: ^8.2.0 bin: vm2: bin/vm2 - checksum: d83dbe929ca4d1c9fca71cda34a5aee9a6b4bdc1de1ddb11777c4f6e1e48a471764195258dbf608f962df1a1c3d6ae917c9755f11a8f37b9e0bbf691313a725c + checksum: 1e665a45ce76612922368462a8b98876698e866c1f201393b0b646f07a00449dc4170e987152cf1443af664ca8f2b82bb52a0760456a71912fa63f22980de7b4 languageName: node linkType: hard