From 6b1c114d6fd3774e77278f41304ddb5da69e84a8 Mon Sep 17 00:00:00 2001 From: Aaron Dill <117116764+aarondill@users.noreply.github.com> Date: Wed, 1 Feb 2023 06:26:44 -0600 Subject: [PATCH] feat: better Error handling (#284) * feat(cli): add --quiet / -q option Stops any successful output. Errors are still outputted. * refactor(cli): create output functions This commit creates stdout and stderr functions for outputting with respect to isQuiet and adds support for differing (easier to parse) messages if not connected to a TTY (eg. a pipe or process substitution) Additionally changes the 'No matching files.' message to output on stderr rather than on stdout as it was, which makes sense because this output represents erroneous usage. * fix(cli): change stderr to not respect --quiet * fix(cli): change stdout to stderr in isTerminal * fix(tests): add STD(OUT/ERR)_IS_TTY env variables these variables can be set to force output on the specified chanel as if it were connected to a terminal. macro.testCLI can now take an argument of the following form: isTerminal: { stderr: false, stdout: false }, * fix(cli): improve output of CLI when not a tty * test: added tests for --verson and --help * test: add tests for --quiet * test: include isTerminal in snapshot * test: add tests for TTY detection and integration * test: typo, stderr --> stdout * fix(cli): exit code while sort is number of failed The exit code is now the number of files which failed when sorting. On a successful run, this will be 0 and exit w/ success, but if any errors occur, this will be incremented. In check mode, this is fails + unsorted. * fix: wrap file operation in try catch Wraps fs calls in trycatch to not throw and to count failures. Also better messages and script usage * docs: document changes to tty-based output * fix(test): compatability w/ node v14 * fix: compatability with node 12 * test: add tests for improper usage * test: support for node 12&14 in error test * refactor: remove extra changes to improve diff * revert: remove all tty detection * refactor: update to meet upstream * fix: fixes error reporting with quiet * fix: bad merge * style: prettier * fix: fixes permissions on cli.js * typo * refactor: improve exit code handling, and set 2 on error * feat: added summary at end of tool run * fix: better show that output on error, is an error * refactor: cleaner logic * refactor: pass `files` to `constructor` * refactor: save `matchedFilesCount` to status * fix: remove `0 files`, use `1 file` instead of `1 files` * refactor: extract `Reporter` --------- Co-authored-by: Keith Cirkel Co-authored-by: fisker Cheung --- cli.js | 61 ++++++---------- package.json | 3 +- reporter.js | 120 ++++++++++++++++++++++++++++++ tests/cli.js | 52 ++++++++++++- tests/snapshots/cli.js.md | 141 ++++++++++++++++++++++++++++++++++-- tests/snapshots/cli.js.snap | Bin 3466 -> 3820 bytes 6 files changed, 330 insertions(+), 47 deletions(-) create mode 100644 reporter.js diff --git a/cli.js b/cli.js index f5f0afce..e1cb7bd9 100755 --- a/cli.js +++ b/cli.js @@ -2,6 +2,7 @@ import { globbySync } from 'globby' import fs from 'node:fs' import sortPackageJson from './index.js' +import Reporter from './reporter.js' function showVersion() { const { name, version } = JSON.parse( @@ -26,52 +27,34 @@ If file/glob is omitted, './package.json' file will be processed. ) } -function sortPackageJsonFiles(patterns, { isCheck, shouldBeQuiet }) { - const files = globbySync(patterns) - const printToStdout = shouldBeQuiet ? () => {} : console.log - - if (files.length === 0) { - console.error('No matching files.') - process.exitCode = 2 - return +function sortPackageJsonFile(file, reporter, isCheck) { + const original = fs.readFileSync(file, 'utf8') + const sorted = sortPackageJson(original) + if (sorted === original) { + return reporter.reportNotChanged(file) } - let notSortedFiles = 0 - for (const file of files) { - const packageJson = fs.readFileSync(file, 'utf8') - const sorted = sortPackageJson(packageJson) - - if (sorted !== packageJson) { - if (isCheck) { - notSortedFiles++ - printToStdout(file) - process.exitCode = 1 - } else { - fs.writeFileSync(file, sorted) - - printToStdout(`${file} is sorted!`) - } - } + if (!isCheck) { + fs.writeFileSync(file, sorted) } - if (isCheck) { - // Print a empty line - printToStdout() + reporter.reportChanged(file) +} + +function sortPackageJsonFiles(patterns, options) { + const files = globbySync(patterns) + const reporter = new Reporter(files, options) + const { isCheck } = options - if (notSortedFiles) { - printToStdout( - notSortedFiles === 1 - ? `${notSortedFiles} of ${files.length} matched file is not sorted.` - : `${notSortedFiles} of ${files.length} matched files are not sorted.`, - ) - } else { - printToStdout( - files.length === 1 - ? `${files.length} matched file is sorted.` - : `${files.length} matched files are sorted.`, - ) + for (const file of files) { + try { + sortPackageJsonFile(file, reporter, isCheck) + } catch (error) { + reporter.reportFailed(file, error) } } + + reporter.printSummary() } function run() { diff --git a/package.json b/package.json index 26ff348d..f0c37a0f 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "files": [ "index.js", "index.d.ts", - "cli.js" + "cli.js", + "reporter.js" ], "scripts": { "lint": "eslint .", diff --git a/reporter.js b/reporter.js new file mode 100644 index 00000000..d61e1eb2 --- /dev/null +++ b/reporter.js @@ -0,0 +1,120 @@ +const getFilesCountText = (count) => (count === 1 ? '1 file' : `${count} files`) + +class Reporter { + #hasPrinted = false + #options + #status + #logger + + constructor(files, options) { + this.#options = options + this.#status = { + matchedFilesCount: files.length, + failedFilesCount: 0, + wellSortedFilesCount: 0, + changedFilesCount: 0, + } + + this.#logger = options.shouldBeQuiet + ? { log() {}, error() {} } + : { + log: (...args) => { + this.#hasPrinted = true + console.log(...args) + }, + error: (...args) => { + this.#hasPrinted = true + console.error(...args) + }, + } + } + + // The file is well-sorted + reportNotChanged(/* file */) { + this.#status.wellSortedFilesCount++ + } + + reportChanged(file) { + this.#status.changedFilesCount++ + this.#logger.log(this.#options.isCheck ? `${file}` : `${file} is sorted!`) + } + + reportFailed(file, error) { + this.#status.failedFilesCount++ + + console.error('Error on: ' + file) + this.#logger.error(error.message) + } + + printSummary() { + const { + matchedFilesCount, + failedFilesCount, + changedFilesCount, + wellSortedFilesCount, + } = this.#status + + if (matchedFilesCount === 0) { + console.error('No matching files.') + process.exitCode = 2 + return + } + + const { isCheck, isQuiet } = this.#options + + if (isCheck && changedFilesCount) { + process.exitCode = 1 + } + + if (failedFilesCount) { + process.exitCode = 2 + } + + if (isQuiet) { + return + } + + const { log } = this.#logger + + // Print an empty line. + if (this.#hasPrinted) { + log() + } + + // Matched files + log('Found %s.', getFilesCountText(matchedFilesCount)) + + // Failed files + if (failedFilesCount) { + log( + '%s could not be %s.', + getFilesCountText(failedFilesCount), + isCheck ? 'checked' : 'sorted', + ) + } + + // Changed files + if (changedFilesCount) { + if (isCheck) { + log( + '%s %s not sorted.', + getFilesCountText(changedFilesCount), + changedFilesCount === 1 ? 'was' : 'were', + ) + } else { + log('%s successfully sorted.', getFilesCountText(changedFilesCount)) + } + } + + // Well-sorted files + if (wellSortedFilesCount) { + log( + '%s %s already sorted.', + getFilesCountText(wellSortedFilesCount), + wellSortedFilesCount === 1 ? 'was' : 'were', + ) + } + } +} + +export default Reporter diff --git a/tests/cli.js b/tests/cli.js index 9f628246..30388d26 100644 --- a/tests/cli.js +++ b/tests/cli.js @@ -1,5 +1,5 @@ -import fs from 'node:fs' import test from 'ava' +import fs from 'node:fs' import { cliScript, macro } from './_helpers.js' const badJson = { @@ -438,3 +438,53 @@ test('run `cli --check --quiet` on duplicate patterns', macro.testCLI, { ], message: 'Should not count `bad-1/package.json` more than once. Exit code 1', }) + +const badFormat = '' + +test('run `cli --check` on 1 non-json file', macro.testCLI, { + fixtures: [ + { + file: 'notJson/package.json', + content: badFormat, + expect: badFormat, + }, + ], + args: ['*/package.json', '--check'], + message: 'Should fail to check, but not end execution.', +}) + +test('run `cli --check --quiet` on 1 non-json file', macro.testCLI, { + fixtures: [ + { + file: 'notJson/package.json', + content: badFormat, + expect: badFormat, + }, + ], + args: ['*/package.json', '--check', '--quiet'], + message: 'Should output error message, but not count.', +}) + +test('run `cli` on 1 non-json file', macro.testCLI, { + fixtures: [ + { + file: 'notJson/package.json', + content: badFormat, + expect: badFormat, + }, + ], + args: ['*/package.json'], + message: 'Should fail to check, but not end execution.', +}) + +test('run `cli --quiet` on 1 non-json file', macro.testCLI, { + fixtures: [ + { + file: 'notJson/package.json', + content: badFormat, + expect: badFormat, + }, + ], + args: ['*/package.json', '--quiet'], + message: 'Should output error message', +}) diff --git a/tests/snapshots/cli.js.md b/tests/snapshots/cli.js.md index 57af69bc..b939c937 100644 --- a/tests/snapshots/cli.js.md +++ b/tests/snapshots/cli.js.md @@ -230,6 +230,9 @@ Generated by [AVA](https://avajs.dev). errorCode: null, stderr: '', stdout: `package.json is sorted!␊ + ␊ + Found 1 file.␊ + 1 file successfully sorted.␊ `, }, } @@ -316,7 +319,8 @@ Generated by [AVA](https://avajs.dev). stderr: '', stdout: `package.json␊ ␊ - 1 of 1 matched file is not sorted.␊ + Found 1 file.␊ + 1 file was not sorted.␊ `, }, } @@ -376,7 +380,8 @@ Generated by [AVA](https://avajs.dev). stderr: '', stdout: `package.json␊ ␊ - 1 of 1 matched file is not sorted.␊ + Found 1 file.␊ + 1 file was not sorted.␊ `, }, } @@ -435,6 +440,9 @@ Generated by [AVA](https://avajs.dev). errorCode: null, stderr: '', stdout: `bad/package.json is sorted!␊ + ␊ + Found 1 file.␊ + 1 file successfully sorted.␊ `, }, } @@ -495,7 +503,8 @@ Generated by [AVA](https://avajs.dev). stderr: '', stdout: `bad/package.json␊ ␊ - 1 of 1 matched file is not sorted.␊ + Found 1 file.␊ + 1 file was not sorted.␊ `, }, } @@ -567,6 +576,9 @@ Generated by [AVA](https://avajs.dev). stderr: '', stdout: `bad-1/package.json is sorted!␊ bad-2/package.json is sorted!␊ + ␊ + Found 2 files.␊ + 2 files successfully sorted.␊ `, }, } @@ -610,7 +622,8 @@ Generated by [AVA](https://avajs.dev). stdout: `bad-1/package.json␊ bad-2/package.json␊ ␊ - 2 of 2 matched files are not sorted.␊ + Found 2 files.␊ + 2 files were not sorted.␊ `, }, } @@ -755,6 +768,10 @@ Generated by [AVA](https://avajs.dev). stderr: '', stdout: `bad-1/package.json is sorted!␊ bad-2/package.json is sorted!␊ + ␊ + Found 4 files.␊ + 2 files successfully sorted.␊ + 2 files were already sorted.␊ `, }, } @@ -882,7 +899,9 @@ Generated by [AVA](https://avajs.dev). stdout: `bad-1/package.json␊ bad-2/package.json␊ ␊ - 2 of 4 matched files are not sorted.␊ + Found 4 files.␊ + 2 files were not sorted.␊ + 2 files were already sorted.␊ `, }, } @@ -1120,6 +1139,10 @@ Generated by [AVA](https://avajs.dev). errorCode: null, stderr: '', stdout: `bad-1/package.json is sorted!␊ + ␊ + Found 3 files.␊ + 1 file successfully sorted.␊ + 2 files were already sorted.␊ `, }, } @@ -1176,7 +1199,9 @@ Generated by [AVA](https://avajs.dev). stderr: '', stdout: `bad-1/package.json␊ ␊ - 1 of 3 matched file is not sorted.␊ + Found 3 files.␊ + 1 file was not sorted.␊ + 2 files were already sorted.␊ `, }, } @@ -1235,3 +1260,107 @@ Generated by [AVA](https://avajs.dev). stdout: '', }, } + +## run `cli --check` on 1 non-json file + +> Should fail to check, but not end execution. + + { + args: [ + '*/package.json', + '--check', + ], + fixtures: [ + { + expect: '', + file: 'notJson/package.json', + original: '', + }, + ], + result: { + errorCode: 2, + stderr: `Error on: notJson/package.json␊ + Unexpected end of JSON input␊ + `, + stdout: `␊ + Found 1 file.␊ + 1 file could not be checked.␊ + `, + }, + } + +## run `cli --check --quiet` on 1 non-json file + +> Should output error message, but not count. + + { + args: [ + '*/package.json', + '--check', + '--quiet', + ], + fixtures: [ + { + expect: '', + file: 'notJson/package.json', + original: '', + }, + ], + result: { + errorCode: 2, + stderr: `Error on: notJson/package.json␊ + `, + stdout: '', + }, + } + +## run `cli` on 1 non-json file + +> Should fail to check, but not end execution. + + { + args: [ + '*/package.json', + ], + fixtures: [ + { + expect: '', + file: 'notJson/package.json', + original: '', + }, + ], + result: { + errorCode: 2, + stderr: `Error on: notJson/package.json␊ + Unexpected end of JSON input␊ + `, + stdout: `␊ + Found 1 file.␊ + 1 file could not be sorted.␊ + `, + }, + } + +## run `cli --quiet` on 1 non-json file + +> Should output error message + + { + args: [ + '*/package.json', + '--quiet', + ], + fixtures: [ + { + expect: '', + file: 'notJson/package.json', + original: '', + }, + ], + result: { + errorCode: 2, + stderr: `Error on: notJson/package.json␊ + `, + stdout: '', + }, + } diff --git a/tests/snapshots/cli.js.snap b/tests/snapshots/cli.js.snap index 35fbb27f716c2b99a2b176f6b3282ee6e37c89ea..daa915276216656c03897e93e5303c166be0e7d7 100644 GIT binary patch literal 3820 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