From 46d038f1b3bcee416f324490e9fe3ef9d86254f4 Mon Sep 17 00:00:00 2001 From: Luke Karrys Date: Wed, 19 Oct 2022 10:04:29 -0700 Subject: [PATCH] feat: output json formatted errors on stdout (#5716) This also adds a new output method `outputBuffer()` which will buffer all output until it is flushed in the exit handler. This allows the exit handler to catch any errors and append them to the output when in json mode. This was necessary to not introduce a regression in the case of npm/cli#2150. BREAKING CHANGE: `npm` now outputs some json errors on stdout. Previously `npm` would output all json formatted errors on stderr, making it difficult to parse as the stderr stream usually has logs already written to it. In the future, `npm` will differentiate between errors and crashes. Errors, such as `E404` and `ERESOLVE`, will be handled and will continue to be output on stdout. In the case of a crash, `npm` will log the error as usual but will not attempt to display it as json, even in `--json` mode. Moving a case from the category of an error to a crash will not be considered a breaking change. For more information see npm/rfcs#482. Closes #2740 Closes https://github.com/npm/statusboard/issues/589 --- lib/commands/ls.js | 2 +- lib/npm.js | 32 +++++++++++++++++++++++ lib/utils/exit-handler.js | 8 ++++-- test/fixtures/mock-npm.js | 9 +++++++ test/lib/utils/exit-handler.js | 48 ++++++++++++++++++++++++++++++++-- 5 files changed, 94 insertions(+), 5 deletions(-) diff --git a/lib/commands/ls.js b/lib/commands/ls.js index 6812c3923787e..7eebdf691683f 100644 --- a/lib/commands/ls.js +++ b/lib/commands/ls.js @@ -177,7 +177,7 @@ class LS extends ArboristWorkspaceCmd { const [rootError] = tree.errors.filter(e => e.code === 'EJSONPARSE' && e.path === resolve(path, 'package.json')) - this.npm.output( + this.npm.outputBuffer( json ? jsonOutput({ path, problems, result, rootError, seenItems }) : parseable diff --git a/lib/npm.js b/lib/npm.js index 763c9b7a384d0..9fbda90734921 100644 --- a/lib/npm.js +++ b/lib/npm.js @@ -40,6 +40,7 @@ class Npm extends EventEmitter { #argvClean = [] #chalk = null + #outputBuffer = [] #logFile = new LogFile() #display = new Display() #timers = new Timers({ @@ -466,6 +467,37 @@ class Npm extends EventEmitter { log.showProgress() } + outputBuffer (item) { + this.#outputBuffer.push(item) + } + + flushOutput (jsonError) { + if (!jsonError && !this.#outputBuffer.length) { + return + } + + if (this.config.get('json')) { + const jsonOutput = this.#outputBuffer.reduce((acc, item) => { + if (typeof item === 'string') { + // try to parse it as json in case its a string + try { + item = JSON.parse(item) + } catch { + return acc + } + } + return { ...acc, ...item } + }, {}) + this.output(JSON.stringify({ ...jsonOutput, ...jsonError }, null, 2)) + } else { + for (const item of this.#outputBuffer) { + this.output(item) + } + } + + this.#outputBuffer.length = 0 + } + outputError (...msg) { log.clearProgress() // eslint-disable-next-line no-console diff --git a/lib/utils/exit-handler.js b/lib/utils/exit-handler.js index 2574a83fc559e..a9e061de7a4a5 100644 --- a/lib/utils/exit-handler.js +++ b/lib/utils/exit-handler.js @@ -136,6 +136,7 @@ const exitHandler = err => { let exitCode let noLogMessage + let jsonError if (err) { exitCode = 1 @@ -198,14 +199,13 @@ const exitHandler = err => { } if (hasLoadedNpm && npm.config.get('json')) { - const error = { + jsonError = { error: { code: err.code, summary: messageText(summary), detail: messageText(detail), }, } - npm.outputError(JSON.stringify(error, null, 2)) } if (typeof err.errno === 'number') { @@ -216,6 +216,10 @@ const exitHandler = err => { } } + if (hasLoadedNpm) { + npm.flushOutput(jsonError) + } + log.verbose('exit', exitCode || 0) showLogFileError = (hasLoadedNpm && npm.silent) || noLogMessage diff --git a/test/fixtures/mock-npm.js b/test/fixtures/mock-npm.js index 0318eadebafe6..8a744cd559eaf 100644 --- a/test/fixtures/mock-npm.js +++ b/test/fixtures/mock-npm.js @@ -238,6 +238,15 @@ class MockNpm { } this._mockOutputs.push(msg) } + + // with the older fake mock npm there is no + // difference between output and outputBuffer + // since it just collects the output and never + // calls the exit handler, so we just mock the + // method the same as output. + outputBuffer (...msg) { + this.output(...msg) + } } const FakeMockNpm = (base = {}, t) => { diff --git a/test/lib/utils/exit-handler.js b/test/lib/utils/exit-handler.js index 7e3137013ecab..d22ec4dd141a8 100644 --- a/test/lib/utils/exit-handler.js +++ b/test/lib/utils/exit-handler.js @@ -208,7 +208,7 @@ t.test('exit handler called - no npm with error without stack', async (t) => { }) t.test('console.log output using --json', async (t) => { - const { exitHandler, outputErrors } = await mockExitHandler(t, { + const { exitHandler, outputs } = await mockExitHandler(t, { config: { json: true }, }) @@ -216,7 +216,7 @@ t.test('console.log output using --json', async (t) => { t.equal(process.exitCode, 1) t.same( - JSON.parse(outputErrors[0]), + JSON.parse(outputs[0]), { error: { code: 'EBADTHING', // should default error code to E[A-Z]+ @@ -228,6 +228,50 @@ t.test('console.log output using --json', async (t) => { ) }) +t.test('merges output buffers errors with --json', async (t) => { + const { exitHandler, outputs, npm } = await mockExitHandler(t, { + config: { json: true }, + }) + + npm.outputBuffer({ output_data: 1 }) + npm.outputBuffer(JSON.stringify({ more_data: 2 })) + npm.outputBuffer('not json, will be ignored') + + await exitHandler(err('Error: EBADTHING Something happened')) + + t.equal(process.exitCode, 1) + t.same( + JSON.parse(outputs[0]), + { + output_data: 1, + more_data: 2, + error: { + code: 'EBADTHING', // should default error code to E[A-Z]+ + summary: 'Error: EBADTHING Something happened', + detail: 'Error: EBADTHING Something happened', + }, + }, + 'should output expected json output' + ) +}) + +t.test('output buffer without json', async (t) => { + const { exitHandler, outputs, npm, logs } = await mockExitHandler(t) + + npm.outputBuffer('output_data') + npm.outputBuffer('more_data') + + await exitHandler(err('Error: EBADTHING Something happened')) + + t.equal(process.exitCode, 1) + t.same( + outputs, + [['output_data'], ['more_data']], + 'should output expected output' + ) + t.match(logs.error, [['code', 'EBADTHING']]) +}) + t.test('throw a non-error obj', async (t) => { const { exitHandler, logs } = await mockExitHandler(t)