From cf57f20eb73efed13994adfcf592eb824ee809f3 Mon Sep 17 00:00:00 2001 From: Jesus David Garcia Gomez Date: Wed, 13 Mar 2019 16:51:50 -0700 Subject: [PATCH] New: Add parameter to output formatter output to a file Fix #2056 --- packages/formatter-codeframe/package.json | 7 +- packages/formatter-codeframe/src/formatter.ts | 67 ++++-- packages/formatter-codeframe/tests/tests.ts | 169 ++++++++++++-- packages/formatter-excel/src/formatter.ts | 12 +- packages/formatter-excel/tests/tests.ts | 24 ++ packages/formatter-html/src/formatter.ts | 5 +- packages/formatter-html/tests/tests.ts | 30 ++- packages/formatter-json/src/formatter.ts | 33 ++- .../tests/fixtures/list-of-problems.ts | 50 ++++ packages/formatter-json/tests/tests.ts | 220 ++++++++++++++---- packages/formatter-stylish/package.json | 7 +- packages/formatter-stylish/src/formatter.ts | 35 ++- packages/formatter-stylish/tests/tests.ts | 125 ++++++++-- packages/formatter-summary/src/formatter.ts | 27 ++- packages/formatter-summary/tests/tests.ts | 75 ++++-- packages/hint/src/lib/cli/analyze.ts | 1 + packages/hint/src/lib/cli/options.ts | 6 + packages/hint/src/lib/types.ts | 6 +- packages/hint/src/lib/types/formatters.ts | 1 + yarn.lock | 12 + 20 files changed, 753 insertions(+), 159 deletions(-) diff --git a/packages/formatter-codeframe/package.json b/packages/formatter-codeframe/package.json index 27f4f460012..31de9272b9b 100644 --- a/packages/formatter-codeframe/package.json +++ b/packages/formatter-codeframe/package.json @@ -15,12 +15,13 @@ "devDependencies": { "@types/log-symbols": "^2.0.0", "@types/sinon": "^7.0.10", + "@typescript-eslint/eslint-plugin": "^1.4.2", + "@typescript-eslint/parser": "1.4.2", "ava": "^1.3.1", "cpx": "^1.5.0", "eslint": "^5.15.1", "eslint-plugin-import": "^2.16.0", "eslint-plugin-markdown": "^1.0.0", - "@typescript-eslint/eslint-plugin": "^1.4.2", "hint": "^4.5.0", "npm-link-check": "^3.0.0", "npm-run-all": "^4.1.5", @@ -28,8 +29,8 @@ "proxyquire": "2.0.0", "rimraf": "^2.6.3", "sinon": "^7.2.7", - "typescript": "^3.3.3333", - "@typescript-eslint/parser": "1.4.2" + "strip-ansi": "^5.1.0", + "typescript": "^3.3.3333" }, "engines": { "node": ">=8.0.0" diff --git a/packages/formatter-codeframe/src/formatter.ts b/packages/formatter-codeframe/src/formatter.ts index a1b210b431b..1e9845ea593 100644 --- a/packages/formatter-codeframe/src/formatter.ts +++ b/packages/formatter-codeframe/src/formatter.ts @@ -11,22 +11,27 @@ * ------------------------------------------------------------------------------ */ +import * as path from 'path'; + import chalk from 'chalk'; import { - forEach, groupBy, + reduce, sortBy } from 'lodash'; import * as logSymbols from 'log-symbols'; +const stripAnsi = require('strip-ansi'); import cutString from 'hint/dist/src/lib/utils/misc/cut-string'; +import cwd from 'hint/dist/src/lib/utils/fs/cwd'; import { debug as d } from 'hint/dist/src/lib/utils/debug'; -import { IFormatter, Problem, ProblemLocation, Severity } from 'hint/dist/src/lib/types'; +import { IFormatter, Problem, ProblemLocation, Severity, FormatterOptions } from 'hint/dist/src/lib/types'; import * as logger from 'hint/dist/src/lib/utils/logging'; +import writeFileAsync from 'hint/dist/src/lib/utils/fs/write-file-async'; const _ = { - forEach, groupBy, + reduce, sortBy }; const debug = d(__filename); @@ -46,7 +51,7 @@ const safeTrim = (txt: string, charsToRemove: number): boolean => { return (/^\s+$/).test(txt.substr(0, charsToRemove)); }; -const codeFrame = (code: string, location: ProblemLocation) => { +const codeFrame = (code: string, location: ProblemLocation): string => { /* istanbul ignore next */ const line: number = typeof location.elementLine === 'number' ? location.elementLine : -1; /* istanbul ignore next */ @@ -57,21 +62,22 @@ const codeFrame = (code: string, location: ProblemLocation) => { const extraLinesToShow: number = 2; const firstLine: number = line - extraLinesToShow > 0 ? line - extraLinesToShow : 0; const lastLine: number = line + (extraLinesToShow + 1) < codeInLines.length ? line + (extraLinesToShow + 1) : codeInLines.length; + let result = ''; if (firstLine !== 0) { - logger.log('…'); + result += '…\n'; } for (let i: number = firstLine; i < lastLine; i++) { - let result = ''; + let partialResult = ''; let mark = ''; const canTrim: boolean = safeTrim(codeInLines[i], whiteSpacesToRemove); if (i === 1 || !canTrim) { - result = codeInLines[i]; + partialResult = codeInLines[i]; } else { // The first line doesn't have spaces but the other elements keep the original format - result = codeInLines[i].substr(whiteSpacesToRemove); + partialResult = codeInLines[i].substr(whiteSpacesToRemove); } if (i === line) { @@ -94,26 +100,28 @@ const codeFrame = (code: string, location: ProblemLocation) => { if (cutPosition > 50) { markPosition = 50 + 3; - result = `… ${result.substr(column - 50)}`; + partialResult = `… ${partialResult.substr(column - 50)}`; } - if (result.length > cutPosition + 50) { - result = `${result.substr(0, cutPosition + 50)} …`; + if (partialResult.length > cutPosition + 50) { + partialResult = `${partialResult.substr(0, cutPosition + 50)} …`; } mark = `${new Array(markPosition).join(' ')}^`; } - logger.log(result); + result += `${partialResult}\n`; if (mark) { - logger.log(mark); + result += `${mark}\n`; } } if (lastLine !== codeInLines.length) { - logger.log('…'); + result += '…\n'; } + + return result; }; /* @@ -127,7 +135,7 @@ export default class CodeframeFormatter implements IFormatter { * Format the problems grouped by `resource` name and sorted by line and column number, * indicating where in the element there is an error. */ - public format(messages: Problem[]) { + public async format(messages: Problem[], target: string | undefined, options: FormatterOptions = {}) { debug('Formatting results'); if (messages.length === 0) { @@ -138,11 +146,12 @@ export default class CodeframeFormatter implements IFormatter { let totalErrors: number = 0; let totalWarnings: number = 0; - _.forEach(resources, (msgs: Problem[], resource: string) => { + let result = _.reduce(resources, (total: string, msgs: Problem[], resource: string) => { const sortedMessages: Problem[] = _.sortBy(msgs, ['location.line', 'location.column']); const resourceString = chalk.cyan(`${cutString(resource, 80)}`); - _.forEach(sortedMessages, (msg: Problem) => { + const partialResult = _.reduce(sortedMessages, (subtotal: string, msg: Problem) => { + let partial: string; const severity = Severity.error === msg.severity ? chalk.red('Error') : chalk.yellow('Warning'); const location = msg.location; @@ -152,18 +161,30 @@ export default class CodeframeFormatter implements IFormatter { totalWarnings++; } - logger.log(`${severity}: ${msg.message} (${msg.hintId}) at ${resourceString}${msg.sourceCode ? `:${location.line}:${location.column}` : ''}`); + partial = `${severity}: ${msg.message} (${msg.hintId}) at ${resourceString}${msg.sourceCode ? `:${location.line}:${location.column}` : ''}\n`; if (msg.sourceCode) { - codeFrame(msg.sourceCode, location); + partial += codeFrame(msg.sourceCode, location); } - logger.log(''); - }); - }); + partial += '\n'; + + return subtotal + partial; + }, ''); + + return total + partialResult; + }, ''); const color: typeof chalk = totalErrors > 0 ? chalk.red : chalk.yellow; - logger.log(color.bold(`${logSymbols.error} Found a total of ${totalErrors} ${totalErrors === 1 ? 'error' : 'errors'} and ${totalWarnings} ${totalWarnings === 1 ? 'warning' : 'warnings'}`)); + result += color.bold(`${logSymbols.error} Found a total of ${totalErrors} ${totalErrors === 1 ? 'error' : 'errors'} and ${totalWarnings} ${totalWarnings === 1 ? 'warning' : 'warnings'}`); + + if (!options.output) { + logger.log(result); + + return; + } + + await writeFileAsync(path.resolve(cwd(), options.output), stripAnsi(result)); } } diff --git a/packages/formatter-codeframe/tests/tests.ts b/packages/formatter-codeframe/tests/tests.ts index 4f4354d1427..09b80b6ad23 100644 --- a/packages/formatter-codeframe/tests/tests.ts +++ b/packages/formatter-codeframe/tests/tests.ts @@ -1,7 +1,11 @@ +import * as path from 'path'; + import anyTest, { TestInterface, ExecutionContext } from 'ava'; import chalk from 'chalk'; import * as sinon from 'sinon'; import * as proxyquire from 'proxyquire'; +import * as logSymbols from 'log-symbols'; +const stripAnsi = require('strip-ansi'); import * as problems from './fixtures/list-of-problems'; @@ -9,9 +13,15 @@ type Logging = { log: () => void; }; +type WriteFileAsync = { + default: () => void; +}; + type CodeframeContext = { logging: Logging; loggingLogSpy: sinon.SinonSpy; + writeFileAsync: WriteFileAsync; + writeFileAsyncDefaultStub: sinon.SinonStub; }; const test = anyTest as TestInterface; @@ -19,10 +29,15 @@ const test = anyTest as TestInterface; const initContext = (t: ExecutionContext) => { t.context.logging = { log() { } }; t.context.loggingLogSpy = sinon.spy(t.context.logging, 'log'); + t.context.writeFileAsync = { default() { } }; + t.context.writeFileAsyncDefaultStub = sinon.stub(t.context.writeFileAsync, 'default').returns(); }; const loadScript = (context: CodeframeContext) => { - const script = proxyquire('../src/formatter', { 'hint/dist/src/lib/utils/logging': context.logging }); + const script = proxyquire('../src/formatter', { + 'hint/dist/src/lib/utils/fs/write-file-async': context.writeFileAsync, + 'hint/dist/src/lib/utils/logging': context.logging + }); return script.default; }; @@ -42,38 +57,158 @@ test(`Codeframe formatter doesn't print anything if no values`, (t) => { t.is(t.context.loggingLogSpy.callCount, 0); }); -test(`Codeframe formatter prints a table and a summary for each resource`, (t) => { - const CodeframeFormatter = loadScript(t.context); - const formatter = new CodeframeFormatter(); +const generateExpectedLogResult = () => { + let problem = problems.codeframeproblems[0]; - formatter.format(problems.codeframeproblems); + let expectedLogResult = `${chalk.yellow('Warning')}: ${problem.message} (${problem.hintId}) at ${chalk.cyan(problem.resource)}`; - const log = t.context.loggingLogSpy; + problem = problems.codeframeproblems[1]; + let sourceCode = problem.sourceCode.split('\n'); + + expectedLogResult += ` + +${chalk.yellow('Warning')}: ${problem.message} (${problem.hintId}) at ${chalk.cyan(problem.resource)}:${problem.location.line}:${problem.location.column} + +${sourceCode[0]} +^ +${sourceCode[1]} +${sourceCode[2]} +…`; + + problem = problems.codeframeproblems[2]; + sourceCode = problem.sourceCode.split('\n'); + + expectedLogResult += ` + +${chalk.yellow('Warning')}: ${problem.message} (${problem.hintId}) at ${chalk.cyan(problem.resource)}:${problem.location.line}:${problem.location.column} + +${sourceCode[0]} +^ +${sourceCode[1]} +${sourceCode[2]} +…`; + + problem = problems.codeframeproblems[3]; + sourceCode = problem.sourceCode.split('\n'); + + expectedLogResult += ` + +${chalk.yellow('Warning')}: ${problem.message} (${problem.hintId}) at ${chalk.cyan(problem.resource)}:${problem.location.line}:${problem.location.column} + +${sourceCode[0]} +${sourceCode[1].substr(8)} + ^ +${sourceCode[2].substr(8)}`; + + problem = problems.codeframeproblems[4]; + sourceCode = problem.sourceCode.split('\n'); + + expectedLogResult += ` + +${chalk.red('Error')}: ${problem.message} (${problem.hintId}) at ${chalk.cyan(problem.resource)}:${problem.location.line}:${problem.location.column} + +${sourceCode[0]} +${sourceCode[1]} + ^ +${sourceCode[2].substr(8)} +${sourceCode[3]} +… + +${chalk.red.bold(`${logSymbols.error} Found a total of 1 error and 4 warnings`)}`; + + return expectedLogResult; +}; + +const generateExpectedOutputResult = () => { let problem = problems.codeframeproblems[0]; - t.is(log.args[0][0], `${chalk.yellow('Warning')}: ${problem.message} (${problem.hintId}) at ${chalk.cyan(problem.resource)}`); + let expectedLogResult = `Warning: ${problem.message} (${problem.hintId}) at ${problem.resource}`; problem = problems.codeframeproblems[1]; let sourceCode = problem.sourceCode.split('\n'); - t.is(log.args[2][0], `${chalk.yellow('Warning')}: ${problem.message} (${problem.hintId}) at ${chalk.cyan(problem.resource)}:${problem.location.line}:${problem.location.column}`); - t.is(log.args[4][0], sourceCode[0]); - t.is(log.args[5][0], '^'); - t.is(log.args[8][0], '…'); + expectedLogResult += ` + +Warning: ${problem.message} (${problem.hintId}) at ${problem.resource}:${problem.location.line}:${problem.location.column} + +${sourceCode[0]} +^ +${sourceCode[1]} +${sourceCode[2]} +…`; problem = problems.codeframeproblems[2]; sourceCode = problem.sourceCode.split('\n'); - t.is(log.args[14][0], sourceCode[1]); + expectedLogResult += ` + +Warning: ${problem.message} (${problem.hintId}) at ${problem.resource}:${problem.location.line}:${problem.location.column} + +${sourceCode[0]} +^ +${sourceCode[1]} +${sourceCode[2]} +…`; problem = problems.codeframeproblems[3]; sourceCode = problem.sourceCode.split('\n'); - t.is(log.args[22][0], ` ^`); + + expectedLogResult += ` + +Warning: ${problem.message} (${problem.hintId}) at ${problem.resource}:${problem.location.line}:${problem.location.column} + +${sourceCode[0]} +${sourceCode[1].substr(8)} + ^ +${sourceCode[2].substr(8)}`; problem = problems.codeframeproblems[4]; sourceCode = problem.sourceCode.split('\n'); - t.is(log.args[29][0], ` ^`); - t.is(log.args[30][0], sourceCode[2].trim()); - t.is(log.args[31][0], sourceCode[3]); - t.is(log.args[32][0], '…'); + + expectedLogResult += ` + +Error: ${problem.message} (${problem.hintId}) at ${problem.resource}:${problem.location.line}:${problem.location.column} + +${sourceCode[0]} +${sourceCode[1]} + ^ +${sourceCode[2].substr(8)} +${sourceCode[3]} +… + +${stripAnsi(logSymbols.error)} Found a total of 1 error and 4 warnings`; + + return expectedLogResult; +}; + +test(`Codeframe formatter prints a table and a summary for each resource`, (t) => { + const CodeframeFormatter = loadScript(t.context); + const formatter = new CodeframeFormatter(); + + formatter.format(problems.codeframeproblems); + + const log = t.context.loggingLogSpy; + const writeFileStub = t.context.writeFileAsyncDefaultStub; + const expectedLogResult = generateExpectedLogResult(); + + t.is(log.args[0][0], expectedLogResult); + t.false(writeFileStub.called); +}); + +test(`Codeframe formatter called with the output option should write the result in the output file`, (t) => { + const CodeframeFormatter = loadScript(t.context); + const formatter = new CodeframeFormatter(); + const outputFile = 'output.json'; + const expectedOutputFile = path.resolve(process.cwd(), outputFile); + + formatter.format(problems.codeframeproblems, null, { output: outputFile }); + + const log = t.context.loggingLogSpy; + const writeFileStub = t.context.writeFileAsyncDefaultStub; + const expectedOutputResult = generateExpectedOutputResult(); + + t.false(log.called); + t.true(writeFileStub.calledOnce); + t.is(writeFileStub.args[0][0], expectedOutputFile); + t.is(writeFileStub.args[0][1], expectedOutputResult); }); diff --git a/packages/formatter-excel/src/formatter.ts b/packages/formatter-excel/src/formatter.ts index a23b33736e2..5fcf582f4e2 100644 --- a/packages/formatter-excel/src/formatter.ts +++ b/packages/formatter-excel/src/formatter.ts @@ -8,6 +8,9 @@ * Requirements * ------------------------------------------------------------------------------ */ + +import * as path from 'path'; + import * as Excel from 'exceljs'; import { forEach, @@ -15,8 +18,9 @@ import { sortBy } from 'lodash'; +import cwd from 'hint/dist/src/lib/utils/fs/cwd'; import { debug as d } from 'hint/dist/src/lib/utils/debug'; -import { IFormatter, Problem } from 'hint/dist/src/lib/types'; +import { IFormatter, Problem, FormatterOptions } from 'hint/dist/src/lib/types'; import * as logger from 'hint/dist/src/lib/utils/logging'; const _ = { @@ -34,7 +38,7 @@ const startRow = 5; */ export default class ExcelFormatter implements IFormatter { - public async format(messages: Problem[], /* istanbul ignore next */ target = '') { + public async format(messages: Problem[], /* istanbul ignore next */ target = '', options: FormatterOptions = {}) { if (messages.length === 0) { return; } @@ -198,7 +202,9 @@ export default class ExcelFormatter implements IFormatter { .replace(/\//g, '-') .replace(/-$/, ''); - await workbook.xlsx.writeFile(`${process.cwd()}/${name}.xlsx`); + const fileName = path.resolve(cwd(), options.output || `${name}.xlsx`); + + await workbook.xlsx.writeFile(fileName); } catch (e) { /* istanbul ignore next */ { // eslint-disable-line diff --git a/packages/formatter-excel/tests/tests.ts b/packages/formatter-excel/tests/tests.ts index eeb3069b9ed..aed819d31ec 100644 --- a/packages/formatter-excel/tests/tests.ts +++ b/packages/formatter-excel/tests/tests.ts @@ -57,3 +57,27 @@ test(`Excel formatter generates the right number of sheets with the good content await fs.remove(filePath); }); + +test(`Excel formatter generates the right number of sheets with the good content in the right file`, async (t) => { + const formatter = new t.context.ExcelFormatter(); + + await formatter.format(problems.multipleproblems, undefined, { output: 'test.xlsx' }); + + const workbook = new Excel.Workbook(); + const filePath = path.join(process.cwd(), 'test.xlsx'); + + await workbook.xlsx.readFile(filePath); + + const summary = workbook.getWorksheet(1); + const report = workbook.getWorksheet(2); + + t.is(summary.name, 'summary', 'Title is not summary'); + t.is(summary.actualColumnCount, 2, `summary.actualColumnCount isn't 2`); + t.is(summary.actualRowCount, 3, `summary.actualRowCount isn't 3`); + + t.true(report.name.startsWith('resource-'), `Title doesn't start with resource-`); + t.is(report.actualColumnCount, 2, `report.actualColumnCount isn't 2`); + t.is(report.actualRowCount, 6, `report.actualRowCount isn't 3`); + + await fs.remove(filePath); +}); diff --git a/packages/formatter-html/src/formatter.ts b/packages/formatter-html/src/formatter.ts index 8d34cdfaa17..00177f06800 100644 --- a/packages/formatter-html/src/formatter.ts +++ b/packages/formatter-html/src/formatter.ts @@ -13,6 +13,7 @@ import * as path from 'path'; import * as ejs from 'ejs'; import * as fs from 'fs-extra'; +import cwd from 'hint/dist/src/lib/utils/fs/cwd'; import { debug as d } from 'hint/dist/src/lib/utils/debug'; import { IFormatter, Problem, FormatterOptions, HintResources } from 'hint/dist/src/lib/types'; import { Category } from 'hint/dist/src/lib/enums/category'; @@ -134,7 +135,7 @@ export default class HTMLFormatter implements IFormatter { .replace(/\//g, '-') .replace(/[?=]/g, '-query-') .replace(/-$/, ''); - const destDir = path.join(process.cwd(), 'hint-report', name); + const destDir = path.resolve(cwd(), options.output || `hint-report/${name}`); const currentDir = path.join(__dirname); const configDir = path.join(destDir, 'config'); @@ -164,7 +165,7 @@ export default class HTMLFormatter implements IFormatter { await parseCssfile(path.join(destDir, 'styles', 'anchor-top.css'), '../'); if (options.config) { - await fs.outputFile(path.join(configDir, result.id), JSON.stringify(options.config)); + await fs.outputFile(path.join(configDir, result.id), JSON.stringify(options.config), { encoding: 'utf-8'}); } const destination = path.join(destDir, 'index.html'); diff --git a/packages/formatter-html/tests/tests.ts b/packages/formatter-html/tests/tests.ts index f94f473a715..b41e62f6e35 100644 --- a/packages/formatter-html/tests/tests.ts +++ b/packages/formatter-html/tests/tests.ts @@ -1,3 +1,5 @@ +import * as path from 'path'; + import anyTest, { TestInterface, ExecutionContext } from 'ava'; import * as proxyquire from 'proxyquire'; import * as sinon from 'sinon'; @@ -10,7 +12,7 @@ const utils = require('../src/utils'); type FsExtra = { copy: () => void; mkdirp: () => void; - outputFile: () => void; + outputFile: (path: string) => void; readFile: () => string; remove: () => void; }; @@ -25,7 +27,7 @@ const initContext = (t: ExecutionContext) => { t.context.fsExtra = { copy() { }, mkdirp() { }, - outputFile() { }, + outputFile(path: string) { }, readFile() { return ''; }, @@ -168,6 +170,30 @@ test(`HTML formatter create copy and generate the right files`, async (t) => { sandbox.restore(); }); +test(`HTML formatter create copy and generate the right files if an output is provided`, async (t) => { + const sandbox = sinon.createSandbox(); + + const fsExtraCopySpy = sandbox.spy(t.context.fsExtra, 'copy'); + const fsExtraRemoveSpy = sandbox.spy(t.context.fsExtra, 'remove'); + const fsExtraMkDirpSpy = sandbox.spy(t.context.fsExtra, 'mkdirp'); + const fsExtraOutputFileSpy = sandbox.spy(t.context.fsExtra, 'outputFile'); + + const HTMLFormatter = loadScript(t.context); + const formatter = new HTMLFormatter(); + const outputFolder = 'outputfolder'; + const fullFolder = path.join(process.cwd(), outputFolder); + + await formatter.format(problems.noproblems, 'http://example.com', { config: {}, output: outputFolder }); + + t.true(fsExtraCopySpy.calledOnce); + t.true(fsExtraRemoveSpy.calledOnce); + t.true(fsExtraMkDirpSpy.calledOnce); + t.is(fsExtraOutputFileSpy.callCount, 4); + t.true(fsExtraOutputFileSpy.args[0][0].includes(fullFolder)); + + sandbox.restore(); +}); + test(`HTML formatter shoudn't copy and generate any file if option noGenerateFiles is passed`, async (t) => { const sandbox = sinon.createSandbox(); diff --git a/packages/formatter-json/src/formatter.ts b/packages/formatter-json/src/formatter.ts index a4171cbb1f4..50debfac2e9 100644 --- a/packages/formatter-json/src/formatter.ts +++ b/packages/formatter-json/src/formatter.ts @@ -9,19 +9,23 @@ * ------------------------------------------------------------------------------ */ +import * as path from 'path'; + import { - forEach, groupBy, + reduce, sortBy } from 'lodash'; +import cwd from 'hint/dist/src/lib/utils/fs/cwd'; import { debug as d } from 'hint/dist/src/lib/utils/debug'; -import { IFormatter, Problem } from 'hint/dist/src/lib/types'; +import { IFormatter, Problem, FormatterOptions } from 'hint/dist/src/lib/types'; import * as logger from 'hint/dist/src/lib/utils/logging'; +import writeFileAsync from 'hint/dist/src/lib/utils/fs/write-file-async'; const _ = { - forEach, groupBy, + reduce, sortBy }; const debug = d(__filename); @@ -34,17 +38,30 @@ const debug = d(__filename); export default class JSONFormatter implements IFormatter { /** Format the problems grouped by `resource` name and sorted by line and column number */ - public format(messages: Problem[]) { + public async format(messages: Problem[], target: string | undefined, options: FormatterOptions = {}) { debug('Formatting results'); + if (messages.length === 0) { + return; + } + const resources: _.Dictionary = _.groupBy(messages, 'resource'); - _.forEach(resources, (msgs: Problem[], resource: string) => { + const result = _.reduce(resources, (total: string, msgs: Problem[], resource: string) => { const sortedMessages: Problem[] = _.sortBy(msgs, ['location.line', 'location.column']); + const result = `${total ? '\n\n' : ''}${resource}: ${msgs.length} issues +${JSON.stringify(sortedMessages, null, 2)}`; + + return total + result; + }, ''); + + if (!options.output) { + logger.log(result); + + return; + } - logger.log(`${resource}: ${msgs.length} issues`); - logger.log(JSON.stringify(sortedMessages, null, 2)); - }); + await writeFileAsync(path.resolve(cwd(), options.output), result); } } diff --git a/packages/formatter-json/tests/fixtures/list-of-problems.ts b/packages/formatter-json/tests/fixtures/list-of-problems.ts index 6bb9a7cabbe..aa2c15c8f27 100644 --- a/packages/formatter-json/tests/fixtures/list-of-problems.ts +++ b/packages/formatter-json/tests/fixtures/list-of-problems.ts @@ -50,9 +50,59 @@ const multipleproblems: Problem[] = [{ sourceCode: '' }]; +const multipleproblemsandresoruces: Problem[] = [{ + category: Category.other, + hintId: 'random-hint', + location: { + column: 10, + line: 1 + }, + message: 'This is a problem in line 1 column 10', + resource: 'http://myresource2.com/', + severity: Severity.warning, + sourceCode: '' +}, +{ + category: Category.other, + hintId: 'random-hint', + location: { + column: 1, + line: 10 + }, + message: 'This is a problem in line 10', + resource: 'http://myresource2.com/', + severity: Severity.warning, + sourceCode: '' +}, +{ + category: Category.other, + hintId: 'random-hint', + location: { + column: 1, + line: 5 + }, + message: 'This is a problem in line 5', + resource: 'http://myresource.com/', + severity: Severity.warning, + sourceCode: '' +}, +{ + category: Category.other, + hintId: 'random-hint', + location: { + column: 1, + line: 1 + }, + message: 'This is a problem in line 1 column 1', + resource: 'http://myresource.com/', + severity: Severity.warning, + sourceCode: '' +}]; + const noproblems: Problem[] = []; export { multipleproblems, + multipleproblemsandresoruces, noproblems }; diff --git a/packages/formatter-json/tests/tests.ts b/packages/formatter-json/tests/tests.ts index 669ce5b982c..5f9cbd80771 100644 --- a/packages/formatter-json/tests/tests.ts +++ b/packages/formatter-json/tests/tests.ts @@ -1,3 +1,5 @@ +import * as path from 'path'; + import anyTest, { TestInterface, ExecutionContext } from 'ava'; import * as sinon from 'sinon'; import * as proxyquire from 'proxyquire'; @@ -10,9 +12,15 @@ type Logging = { log: () => void; }; +type WriteFileAsync = { + default: () => void; +}; + type JSONContext = { logging: Logging; loggingLogSpy: sinon.SinonSpy; + writeFileAsync: WriteFileAsync; + writeFileAsyncDefaultStub: sinon.SinonStub; }; const test = anyTest as TestInterface; @@ -20,45 +28,81 @@ const test = anyTest as TestInterface; const initContext = (t: ExecutionContext) => { t.context.logging = { log() { } }; t.context.loggingLogSpy = sinon.spy(t.context.logging, 'log'); + t.context.writeFileAsync = { default() { } }; + t.context.writeFileAsyncDefaultStub = sinon.stub(t.context.writeFileAsync, 'default').returns(); }; const loadScript = (context: JSONContext) => { - const script = proxyquire('../src/formatter', { 'hint/dist/src/lib/utils/logging': context.logging }); + const script = proxyquire('../src/formatter', { + 'hint/dist/src/lib/utils/fs/write-file-async': context.writeFileAsync, + 'hint/dist/src/lib/utils/logging': context.logging + }); return script.default; }; -test.beforeEach(initContext); - -test.afterEach.always((t) => { - t.context.loggingLogSpy.restore(); -}); - -test(`JSON formatter doesn't print anything if no values`, (t) => { - const JsonFormatter = loadScript(t.context); - const formatter = new JsonFormatter(); - - formatter.format(problems.noproblems); - - t.is(t.context.loggingLogSpy.callCount, 0); -}); - -test(`JSON formatter is called twice per resource with problems and with sorted problems`, (t) => { - const JsonFormatter = loadScript(t.context); - const formatter = new JsonFormatter(); - - formatter.format(problems.multipleproblems); - - const sortedMessages = [ +const sortedMessages = [ + { + category: 'other', + hintId: 'random-hint', + location: { + column: 1, + line: 1 + }, + message: 'This is a problem in line 1 column 1', + resource: 'http://myresource.com/', + severity: Severity.warning, + sourceCode: '' + }, + { + category: 'other', + hintId: 'random-hint', + location: { + column: 10, + line: 1 + }, + message: 'This is a problem in line 1 column 10', + resource: 'http://myresource.com/', + severity: Severity.warning, + sourceCode: '' + }, + { + category: 'other', + hintId: 'random-hint', + location: { + column: 1, + line: 5 + }, + message: 'This is a problem in line 5', + resource: 'http://myresource.com/', + severity: Severity.warning, + sourceCode: '' + }, + { + category: 'other', + hintId: 'random-hint', + location: { + column: 1, + line: 10 + }, + message: 'This is a problem in line 10', + resource: 'http://myresource.com/', + severity: Severity.warning, + sourceCode: '' + } +]; + +const sortedMessagesByResource = [ + [ { category: 'other', hintId: 'random-hint', location: { - column: 1, + column: 10, line: 1 }, - message: 'This is a problem in line 1 column 1', - resource: 'http://myresource.com/', + message: 'This is a problem in line 1 column 10', + resource: 'http://myresource2.com/', severity: Severity.warning, sourceCode: '' }, @@ -66,22 +110,23 @@ test(`JSON formatter is called twice per resource with problems and with sorted category: 'other', hintId: 'random-hint', location: { - column: 10, - line: 1 + column: 1, + line: 10 }, - message: 'This is a problem in line 1 column 10', - resource: 'http://myresource.com/', + message: 'This is a problem in line 10', + resource: 'http://myresource2.com/', severity: Severity.warning, sourceCode: '' - }, + } + ], [ { category: 'other', hintId: 'random-hint', location: { column: 1, - line: 5 + line: 1 }, - message: 'This is a problem in line 5', + message: 'This is a problem in line 1 column 1', resource: 'http://myresource.com/', severity: Severity.warning, sourceCode: '' @@ -91,20 +136,111 @@ test(`JSON formatter is called twice per resource with problems and with sorted hintId: 'random-hint', location: { column: 1, - line: 10 + line: 5 }, - message: 'This is a problem in line 10', + message: 'This is a problem in line 5', resource: 'http://myresource.com/', severity: Severity.warning, sourceCode: '' } - ]; + ] +]; + + +test.beforeEach(initContext); + +test.afterEach.always((t) => { + t.context.loggingLogSpy.restore(); +}); + +test(`JSON formatter doesn't print anything if no values`, (t) => { + const JsonFormatter = loadScript(t.context); + const formatter = new JsonFormatter(); + + formatter.format(problems.noproblems); + + t.false(t.context.loggingLogSpy.called); + t.false(t.context.writeFileAsyncDefaultStub.called); +}); + +test(`JSON formatter print the result in the console`, (t) => { + const JsonFormatter = loadScript(t.context); + const formatter = new JsonFormatter(); + + formatter.format(problems.multipleproblems); + + const loggingLogSpy = t.context.loggingLogSpy; + const writeFileAsyncDefaultStub = t.context.writeFileAsyncDefaultStub; + const firstCall = loggingLogSpy.firstCall; + const expectedResult = `http://myresource.com/: 4 issues +${JSON.stringify(sortedMessages, null, 2)}`; + + t.true(loggingLogSpy.calledOnce); + t.false(writeFileAsyncDefaultStub.called); + t.is(firstCall.args[0], expectedResult); + t.false(t.context.writeFileAsyncDefaultStub.called); +}); + +test('JSON formatter only print once the result even if there is multiple resoruces', (t) => { + const JsonFormatter = loadScript(t.context); + const formatter = new JsonFormatter(); + + formatter.format(problems.multipleproblemsandresoruces); + + const loggingLogSpy = t.context.loggingLogSpy; + const writeFileAsyncDefaultStub = t.context.writeFileAsyncDefaultStub; + const firstCall = loggingLogSpy.firstCall; + const expectedResult = `http://myresource2.com/: 2 issues +${JSON.stringify(sortedMessagesByResource[0], null, 2)} + +http://myresource.com/: 2 issues +${JSON.stringify(sortedMessagesByResource[1], null, 2)}`; + + t.true(loggingLogSpy.calledOnce); + t.false(writeFileAsyncDefaultStub.called); + t.is(firstCall.args[0], expectedResult); + t.false(t.context.writeFileAsyncDefaultStub.called); +}); + +test(`JSON formatter called with the output option should write the result in the output file`, (t) => { + const JsonFormatter = loadScript(t.context); + const formatter = new JsonFormatter(); + const outputFile = 'output.json'; + + formatter.format(problems.multipleproblems, null, { output: outputFile }); + + const loggingLogSpy = t.context.loggingLogSpy; + const writeFileAsyncDefaultStub = t.context.writeFileAsyncDefaultStub; + const firstCall = writeFileAsyncDefaultStub.firstCall; + const expectedResult = `http://myresource.com/: 4 issues +${JSON.stringify(sortedMessages, null, 2)}`; + const expectedOutputFile = path.resolve(process.cwd(), outputFile); + + t.false(loggingLogSpy.called); + t.true(writeFileAsyncDefaultStub.calledOnce); + t.is(firstCall.args[0], expectedOutputFile); + t.is(firstCall.args[1], expectedResult); +}); + +test('JSON formatter only save one file with the result even if there is multiple resoruces', (t) => { + const JsonFormatter = loadScript(t.context); + const formatter = new JsonFormatter(); + const outputFile = path.join(process.cwd(), '..', '..', 'output.json'); + + formatter.format(problems.multipleproblemsandresoruces, null, { output: outputFile }); + + const loggingLogSpy = t.context.loggingLogSpy; + const writeFileAsyncDefaultStub = t.context.writeFileAsyncDefaultStub; + const firstCall = writeFileAsyncDefaultStub.firstCall; + const expectedResult = `http://myresource2.com/: 2 issues +${JSON.stringify(sortedMessagesByResource[0], null, 2)} - const log = t.context.loggingLogSpy; - const firstCall = log.firstCall; - const secondCall = log.secondCall; +http://myresource.com/: 2 issues +${JSON.stringify(sortedMessagesByResource[1], null, 2)}`; + const expectedOutputFile = outputFile; - t.is(log.callCount, 2); - t.is(firstCall.args[0], 'http://myresource.com/: 4 issues'); - t.deepEqual(secondCall.args[0], JSON.stringify(sortedMessages, null, 2)); + t.false(loggingLogSpy.called); + t.true(writeFileAsyncDefaultStub.calledOnce); + t.is(firstCall.args[0], expectedOutputFile); + t.is(firstCall.args[1], expectedResult); }); diff --git a/packages/formatter-stylish/package.json b/packages/formatter-stylish/package.json index 5da84f09f43..2db944abded 100644 --- a/packages/formatter-stylish/package.json +++ b/packages/formatter-stylish/package.json @@ -17,12 +17,13 @@ "@types/log-symbols": "^2.0.0", "@types/sinon": "^7.0.10", "@types/text-table": "^0.2.0", + "@typescript-eslint/eslint-plugin": "^1.4.2", + "@typescript-eslint/parser": "1.4.2", "ava": "^1.3.1", "cpx": "^1.5.0", "eslint": "^5.15.1", "eslint-plugin-import": "^2.16.0", "eslint-plugin-markdown": "^1.0.0", - "@typescript-eslint/eslint-plugin": "^1.4.2", "hint": "^4.5.0", "npm-link-check": "^3.0.0", "npm-run-all": "^4.1.5", @@ -30,8 +31,8 @@ "proxyquire": "2.0.0", "rimraf": "^2.6.3", "sinon": "^7.2.7", - "typescript": "^3.3.3333", - "@typescript-eslint/parser": "1.4.2" + "strip-ansi": "^5.1.0", + "typescript": "^3.3.3333" }, "engines": { "node": ">=8.0.0" diff --git a/packages/formatter-stylish/src/formatter.ts b/packages/formatter-stylish/src/formatter.ts index 22bd19d7df3..3d801a7f8b3 100644 --- a/packages/formatter-stylish/src/formatter.ts +++ b/packages/formatter-stylish/src/formatter.ts @@ -10,23 +10,30 @@ * ------------------------------------------------------------------------------ */ +import * as path from 'path'; + import chalk from 'chalk'; import { forEach, groupBy, + reduce, sortBy } from 'lodash'; import * as logSymbols from 'log-symbols'; import * as table from 'text-table'; +const stripAnsi = require('strip-ansi'); import cutString from 'hint/dist/src/lib/utils/misc/cut-string'; +import cwd from 'hint/dist/src/lib/utils/fs/cwd'; import { debug as d } from 'hint/dist/src/lib/utils/debug'; -import { IFormatter, Problem, Severity } from 'hint/dist/src/lib/types'; +import { IFormatter, Problem, Severity, FormatterOptions } from 'hint/dist/src/lib/types'; import * as logger from 'hint/dist/src/lib/utils/logging'; +import writeFileAsync from 'hint/dist/src/lib/utils/fs/write-file-async'; const _ = { forEach, groupBy, + reduce, sortBy }; const debug = d(__filename); @@ -47,7 +54,7 @@ const printPosition = (position: number, text: string) => { export default class StylishFormatter implements IFormatter { /** Format the problems grouped by `resource` name and sorted by line and column number */ - public format(messages: Problem[]) { + public async format(messages: Problem[], target: string | undefined, options: FormatterOptions = {}) { debug('Formatting results'); @@ -59,14 +66,14 @@ export default class StylishFormatter implements IFormatter { let totalErrors: number = 0; let totalWarnings: number = 0; - _.forEach(resources, (msgs: Problem[], resource: string) => { + let result = _.reduce(resources, (total: string, msgs: Problem[], resource: string) => { let warnings: number = 0; let errors: number = 0; const sortedMessages: Problem[] = _.sortBy(msgs, ['location.line', 'location.column']); const tableData: string[][] = []; let hasPosition: boolean = false; - logger.log(chalk.cyan(`${cutString(resource, 80)}`)); + let partialResult = `${chalk.cyan(cutString(resource, 80))}\n`; _.forEach(sortedMessages, (msg: Problem) => { const severity: string = Severity.error === msg.severity ? chalk.red('Error') : chalk.yellow('Warning'); @@ -97,19 +104,29 @@ export default class StylishFormatter implements IFormatter { }); } - logger.log(table(tableData)); + partialResult += `${table(tableData)}\n`; const color: typeof chalk = errors > 0 ? chalk.red : chalk.yellow; totalErrors += errors; totalWarnings += warnings; - logger.log(color.bold(`${logSymbols.error} Found ${errors} ${errors === 1 ? 'error' : 'errors'} and ${warnings} ${warnings === 1 ? 'warning' : 'warnings'}`)); - logger.log(''); - }); + partialResult += color.bold(`${logSymbols.error} Found ${errors} ${errors === 1 ? 'error' : 'errors'} and ${warnings} ${warnings === 1 ? 'warning' : 'warnings'}`); + partialResult += '\n\n'; + + return total + partialResult; + }, ''); const color: typeof chalk = totalErrors > 0 ? chalk.red : /* istanbul ignore next */ chalk.yellow; - logger.log(color.bold(`${logSymbols.error} Found a total of ${totalErrors} ${totalErrors === 1 ? 'error' : 'errors'} and ${totalWarnings} ${totalWarnings === 1 ? /* istanbul ignore next */ 'warning' : 'warnings'}`)); + result += color.bold(`${logSymbols.error} Found a total of ${totalErrors} ${totalErrors === 1 ? 'error' : 'errors'} and ${totalWarnings} ${totalWarnings === 1 ? /* istanbul ignore next */ 'warning' : 'warnings'}`); + + if (!options.output) { + logger.log(result); + + return; + } + + await writeFileAsync(path.resolve(cwd(), options.output), stripAnsi(result)); } } diff --git a/packages/formatter-stylish/tests/tests.ts b/packages/formatter-stylish/tests/tests.ts index dba63e79126..a5ea61c19e8 100644 --- a/packages/formatter-stylish/tests/tests.ts +++ b/packages/formatter-stylish/tests/tests.ts @@ -1,9 +1,12 @@ +import * as path from 'path'; + import anyTest, { TestInterface, ExecutionContext } from 'ava'; import chalk from 'chalk'; import * as logSymbols from 'log-symbols'; import * as proxyquire from 'proxyquire'; import * as sinon from 'sinon'; import * as table from 'text-table'; +const stripAnsi = require('strip-ansi'); import * as problems from './fixtures/list-of-problems'; @@ -11,9 +14,15 @@ type Logging = { log: () => void; }; +type WriteFileAsync = { + default: () => void; +}; + type StylishContext = { logging: Logging; loggingLogSpy: sinon.SinonSpy; + writeFileAsync: WriteFileAsync; + writeFileAsyncDefaultStub: sinon.SinonStub; }; const test = anyTest as TestInterface; @@ -21,14 +30,87 @@ const test = anyTest as TestInterface; const initContext = (t: ExecutionContext) => { t.context.logging = { log() { } }; t.context.loggingLogSpy = sinon.spy(t.context.logging, 'log'); + t.context.writeFileAsync = { default() { } }; + t.context.writeFileAsyncDefaultStub = sinon.stub(t.context.writeFileAsync, 'default').returns(); }; const loadScript = (context: StylishContext) => { - const script = proxyquire('../src/formatter', { 'hint/dist/src/lib/utils/logging': context.logging }); + const script = proxyquire('../src/formatter', { + 'hint/dist/src/lib/utils/fs/write-file-async': context.writeFileAsync, + 'hint/dist/src/lib/utils/logging': context.logging + }); return script.default; }; +const getExpectedLogResult = () => { + let problem = problems.multipleproblemsandresources[1]; + let tableData = []; + + tableData.push(['', '', chalk.yellow('Warning'), problem.message, problem.hintId]); + problem = problems.multipleproblemsandresources[0]; + tableData.push([`line ${problem.location.line}`, `col ${problem.location.column}`, chalk.yellow('Warning'), problem.message, problem.hintId]); + problem = problems.multipleproblemsandresources[4]; + tableData.push([`line ${problem.location.line}`, `col ${problem.location.column}`, chalk.yellow('Warning'), problem.message, problem.hintId]); + + let tableString = table(tableData); + + let expectedLogResult = `${chalk.cyan('http://myresource.com/')} +${tableString} +${chalk.yellow.bold(`${logSymbols.error} Found 0 errors and 3 warnings`)} + +${chalk.cyan('http://myresource2.com/this/resource/i … /resources/image/imagewithalongname.jpg')}`; + + tableData = []; + problem = problems.multipleproblemsandresources[2]; + tableData.push([chalk.red('Error'), problem.message, problem.hintId]); + problem = problems.multipleproblemsandresources[3]; + tableData.push([chalk.yellow('Warning'), problem.message, problem.hintId]); + tableString = table(tableData); + + expectedLogResult += ` +${tableString} +${chalk.red.bold(`${logSymbols.error} Found 1 error and 1 warning`)} + +${chalk.red.bold(`${logSymbols.error} Found a total of 1 error and 4 warnings`)}`; + + return expectedLogResult; +}; + +const getExpectedOutputResult = () => { + let problem = problems.multipleproblemsandresources[1]; + let tableData = []; + + tableData.push(['', '', 'Warning', problem.message, problem.hintId]); + problem = problems.multipleproblemsandresources[0]; + tableData.push([`line ${problem.location.line}`, `col ${problem.location.column}`, 'Warning', problem.message, problem.hintId]); + problem = problems.multipleproblemsandresources[4]; + tableData.push([`line ${problem.location.line}`, `col ${problem.location.column}`, 'Warning', problem.message, problem.hintId]); + + let tableString = table(tableData); + + let expectedLogResult = `http://myresource.com/ +${tableString} +${stripAnsi(logSymbols.error)} Found 0 errors and 3 warnings + +http://myresource2.com/this/resource/i … /resources/image/imagewithalongname.jpg`; + + tableData = []; + problem = problems.multipleproblemsandresources[2]; + tableData.push(['Error', problem.message, problem.hintId]); + problem = problems.multipleproblemsandresources[3]; + tableData.push(['Warning', problem.message, problem.hintId]); + tableString = table(tableData); + + expectedLogResult += ` +${tableString} +${stripAnsi(logSymbols.error)} Found 1 error and 1 warning + +${stripAnsi(logSymbols.error)} Found a total of 1 error and 4 warnings`; + + return expectedLogResult; +}; + test.beforeEach(initContext); test.afterEach.always((t) => { @@ -51,31 +133,28 @@ test(`Stylish formatter prints a table and a summary for each resource`, (t) => formatter.format(problems.multipleproblemsandresources); const log = t.context.loggingLogSpy; - let problem = problems.multipleproblemsandresources[1]; - let tableData = []; + const writeFileStub = t.context.writeFileAsyncDefaultStub; + const expectedLogResult = getExpectedLogResult(); - tableData.push(['', '', chalk.yellow('Warning'), problem.message, problem.hintId]); - problem = problems.multipleproblemsandresources[0]; - tableData.push([`line ${problem.location.line}`, `col ${problem.location.column}`, chalk.yellow('Warning'), problem.message, problem.hintId]); - problem = problems.multipleproblemsandresources[4]; - tableData.push([`line ${problem.location.line}`, `col ${problem.location.column}`, chalk.yellow('Warning'), problem.message, problem.hintId]); + t.is(log.args[0][0], expectedLogResult); + t.is(log.callCount, 1); + t.false(writeFileStub.called); +}); - let tableString = table(tableData); +test(`Stylish formatter called with the output option should write the result in the output file`, (t) => { + const StylishFormatter = loadScript(t.context); + const formatter = new StylishFormatter(); + const outputFile = 'test.txt'; + const expectedOutputFile = path.join(process.cwd(), outputFile); - t.is(log.args[0][0], chalk.cyan('http://myresource.com/')); - t.is(log.args[1][0], tableString); - t.is(log.args[2][0], chalk.yellow.bold(`${logSymbols.error} Found 0 errors and 3 warnings`)); - t.is(log.args[3][0], ''); - t.is(log.args[4][0], chalk.cyan('http://myresource2.com/this/resource/i … /resources/image/imagewithalongname.jpg')); + formatter.format(problems.multipleproblemsandresources, undefined, { output: outputFile }); - tableData = []; - problem = problems.multipleproblemsandresources[2]; - tableData.push([chalk.red('Error'), problem.message, problem.hintId]); - problem = problems.multipleproblemsandresources[3]; - tableData.push([chalk.yellow('Warning'), problem.message, problem.hintId]); - tableString = table(tableData); + const log = t.context.loggingLogSpy; + const writeFileStub = t.context.writeFileAsyncDefaultStub; + const expectedOutputResult = getExpectedOutputResult(); - t.is(log.args[5][0], tableString); - t.is(log.args[6][0], chalk.red.bold(`${logSymbols.error} Found 1 error and 1 warning`)); - t.is(log.args[7][0], ''); + t.false(log.called); + t.true(writeFileStub.calledOnce); + t.is(writeFileStub.args[0][0], expectedOutputFile); + t.is(writeFileStub.args[0][1], expectedOutputResult); }); diff --git a/packages/formatter-summary/src/formatter.ts b/packages/formatter-summary/src/formatter.ts index c052f36d064..cfe32d67c0a 100644 --- a/packages/formatter-summary/src/formatter.ts +++ b/packages/formatter-summary/src/formatter.ts @@ -8,20 +8,24 @@ * ------------------------------------------------------------------------------ */ +import * as path from 'path'; + import chalk from 'chalk'; import { - defaultTo, forEach, groupBy } from 'lodash'; import * as table from 'text-table'; import * as logSymbols from 'log-symbols'; +const stripAnsi = require('strip-ansi'); + +import cwd from 'hint/dist/src/lib/utils/fs/cwd'; import { debug as d } from 'hint/dist/src/lib/utils/debug'; -import { IFormatter, Problem, Severity } from 'hint/dist/src/lib/types'; +import { IFormatter, Problem, Severity, FormatterOptions } from 'hint/dist/src/lib/types'; import * as logger from 'hint/dist/src/lib/utils/logging'; +import writeFileAsync from 'hint/dist/src/lib/utils/fs/write-file-async'; const _ = { - defaultTo, forEach, groupBy }; @@ -35,10 +39,10 @@ const debug = d(__filename); export default class SummaryFormatter implements IFormatter { /** Format the problems grouped by `resource` name and sorted by line and column number */ - public format(messages: Problem[]) { + public async format(messages: Problem[], target: string | undefined, options: FormatterOptions = {}) { debug('Formatting results'); - if (_.defaultTo(messages.length, 0) === 0) { + if (messages.length === 0) { return; } @@ -87,10 +91,17 @@ export default class SummaryFormatter implements IFormatter { totalWarnings += warnings; }); - logger.log(table(tableData)); - const color: typeof chalk = totalErrors > 0 ? chalk.red : chalk.yellow; - logger.log(color.bold(`${logSymbols.error} Found a total of ${totalErrors} ${totalErrors === 1 ? 'error' : 'errors'} and ${totalWarnings} ${totalWarnings === 1 ? 'warning' : 'warnings'}`)); + const result = `${table(tableData)} +${color.bold(`${logSymbols.error} Found a total of ${totalErrors} ${totalErrors === 1 ? 'error' : 'errors'} and ${totalWarnings} ${totalWarnings === 1 ? 'warning' : 'warnings'}`)}`; + + if (!options.output) { + logger.log(result); + + return; + } + + await writeFileAsync(path.resolve(cwd(), options.output), stripAnsi(result)); } } diff --git a/packages/formatter-summary/tests/tests.ts b/packages/formatter-summary/tests/tests.ts index 2aae2b2188b..8386c21175d 100644 --- a/packages/formatter-summary/tests/tests.ts +++ b/packages/formatter-summary/tests/tests.ts @@ -1,9 +1,12 @@ +import * as path from 'path'; + import anyTest, { TestInterface, ExecutionContext } from 'ava'; import chalk from 'chalk'; import * as logSymbols from 'log-symbols'; import * as proxyquire from 'proxyquire'; import * as sinon from 'sinon'; import * as table from 'text-table'; +const stripAnsi = require('strip-ansi'); import * as problems from './fixtures/list-of-problems'; @@ -11,9 +14,15 @@ type Logging = { log: () => void; }; +type WriteFileAsync = { + default: () => void; +}; + type SummaryContext = { logging: Logging; loggingLogSpy: sinon.SinonSpy; + writeFileAsync: WriteFileAsync; + writeFileAsyncDefaultStub: sinon.SinonStub; }; const test = anyTest as TestInterface; @@ -21,10 +30,15 @@ const test = anyTest as TestInterface; const initContext = (t: ExecutionContext) => { t.context.logging = { log() { } }; t.context.loggingLogSpy = sinon.spy(t.context.logging, 'log'); + t.context.writeFileAsync = { default() { } }; + t.context.writeFileAsyncDefaultStub = sinon.stub(t.context.writeFileAsync, 'default').returns(); }; const loadScript = (context: SummaryContext) => { - const script = proxyquire('../src/formatter', { 'hint/dist/src/lib/utils/logging': context.logging }); + const script = proxyquire('../src/formatter', { + 'hint/dist/src/lib/utils/fs/write-file-async': context.writeFileAsync, + 'hint/dist/src/lib/utils/logging': context.logging + }); return script.default; }; @@ -46,6 +60,7 @@ test(`Summary formatter doesn't print anything if no values`, (t) => { test(`Summary formatter prints in yellow if only warnings found`, (t) => { const log = t.context.loggingLogSpy; + const writeFileStub = t.context.writeFileAsyncDefaultStub; const tableData = []; const SummaryFormatter = loadScript(t.context); @@ -55,14 +70,17 @@ test(`Summary formatter prints in yellow if only warnings found`, (t) => { tableData.push([chalk.cyan('random-hint'), chalk.yellow(`2 warnings`)]); - const tableString = table(tableData); + const expectedResult = `${table(tableData)} +${chalk.yellow.bold(`${logSymbols.error.trim()} Found a total of 0 errors and 2 warnings`)}`; - t.is(log.args[0][0], tableString); - t.is(log.args[1][0], chalk.yellow.bold(`${logSymbols.error.trim()} Found a total of 0 errors and 2 warnings`)); + t.true(log.calledOnce); + t.false(writeFileStub.calledOnce); + t.is(log.args[0][0], expectedResult); }); test(`Summary formatter prints a table and a summary for all resources combined`, (t) => { const log = t.context.loggingLogSpy; + const writeFileStub = t.context.writeFileAsyncDefaultStub; const tableData = []; const SummaryFormatter = loadScript(t.context); @@ -73,14 +91,17 @@ test(`Summary formatter prints a table and a summary for all resources combined` tableData.push([chalk.cyan('random-hint2'), chalk.red(`1 error`)]); tableData.push([chalk.cyan('random-hint'), chalk.yellow(`4 warnings`)]); - const tableString = table(tableData); + const expectedResult = `${table(tableData)} +${chalk.red.bold(`${logSymbols.error.trim()} Found a total of 1 error and 4 warnings`)}`; - t.is(log.args[0][0], tableString); - t.is(log.args[1][0], chalk.red.bold(`${logSymbols.error.trim()} Found a total of 1 error and 4 warnings`)); + t.true(log.calledOnce); + t.false(writeFileStub.calledOnce); + t.is(log.args[0][0], expectedResult); }); test(`Summary formatter sorts by name if same number of errors`, (t) => { const log = t.context.loggingLogSpy; + const writeFileStub = t.context.writeFileAsyncDefaultStub; const tableData = []; const SummaryFormatter = loadScript(t.context); @@ -91,14 +112,17 @@ test(`Summary formatter sorts by name if same number of errors`, (t) => { tableData.push([chalk.cyan('random-hint'), chalk.red(`1 error`)]); tableData.push([chalk.cyan('random-hint2'), chalk.red(`1 error`)]); - const tableString = table(tableData); + const expectedResult = `${table(tableData)} +${chalk.red.bold(`${logSymbols.error.trim()} Found a total of 2 errors and 0 warnings`)}`; - t.is(log.args[0][0], tableString); - t.is(log.args[1][0], chalk.red.bold(`${logSymbols.error.trim()} Found a total of 2 errors and 0 warnings`)); + t.true(log.calledOnce); + t.false(writeFileStub.calledOnce); + t.is(log.args[0][0], expectedResult); }); test(`Summary formatter prints errors and warnings for a hint that reports both`, (t) => { const log = t.context.loggingLogSpy; + const writeFileStub = t.context.writeFileAsyncDefaultStub; const tableData = []; const SummaryFormatter = loadScript(t.context); @@ -108,8 +132,33 @@ test(`Summary formatter prints errors and warnings for a hint that reports both` tableData.push([chalk.cyan('random-hint'), chalk.red(`1 error`), chalk.yellow(`1 warning`)]); - const tableString = table(tableData); + const expectedResult = `${table(tableData)} +${chalk.red.bold(`${logSymbols.error.trim()} Found a total of 1 error and 1 warning`)}`; + + t.true(log.calledOnce); + t.false(writeFileStub.calledOnce); + t.is(log.args[0][0], expectedResult); +}); + +test(`Summary formatter called with the output option should write the result in the output file`, (t) => { + const log = t.context.loggingLogSpy; + const writeFileStub = t.context.writeFileAsyncDefaultStub; + const tableData = []; + const outputFile = 'output.json'; + const expectedOutputFile = path.resolve(process.cwd(), outputFile); + + const SummaryFormatter = loadScript(t.context); + const formatter = new SummaryFormatter(); + + formatter.format(problems.summaryErrorWarnings, undefined, { output: outputFile }); + + tableData.push(['random-hint', '1 error', '1 warning']); + + const expectedResult = `${table(tableData)} +${stripAnsi(logSymbols.error.trim())} Found a total of 1 error and 1 warning`; - t.is(log.args[0][0], tableString); - t.is(log.args[1][0], chalk.red.bold(`${logSymbols.error.trim()} Found a total of 1 error and 1 warning`)); + t.false(log.calledOnce); + t.true(writeFileStub.calledOnce); + t.is(writeFileStub.args[0][0], expectedOutputFile); + t.is(writeFileStub.args[0][1], expectedResult); }); diff --git a/packages/hint/src/lib/cli/analyze.ts b/packages/hint/src/lib/cli/analyze.ts index 3694db52f7e..36e08d32ee9 100644 --- a/packages/hint/src/lib/cli/analyze.ts +++ b/packages/hint/src/lib/cli/analyze.ts @@ -361,6 +361,7 @@ export default async (actions: CLIOptions): Promise => { const formatterOptions: FormatterOptions = { config: userConfig || undefined, date, + output: actions.output, resources, scanTime, version: loadHintPackage().version diff --git a/packages/hint/src/lib/cli/options.ts b/packages/hint/src/lib/cli/options.ts index 86451fd2388..9930f99a63d 100644 --- a/packages/hint/src/lib/cli/options.ts +++ b/packages/hint/src/lib/cli/options.ts @@ -78,6 +78,12 @@ export const options = optionator({ enum: ['on', 'off'], option: 'tracking', type: 'String' + }, + { + alias: 'o', + description: `Save the formatter output to a file, in case of 'html' or 'excel' formatter, save the result with the name specified`, + option: 'output', + type: 'String' } ], prepend: 'hint [options] https://url.com' diff --git a/packages/hint/src/lib/types.ts b/packages/hint/src/lib/types.ts index 6c4a61c0dd6..36ef9c7a06f 100644 --- a/packages/hint/src/lib/types.ts +++ b/packages/hint/src/lib/types.ts @@ -78,12 +78,12 @@ export type Resource = IConnectorConstructor | IFormatterConstructor | IHintCons export type CLIOptions = { _: string[]; + 'analytics-debug': boolean; config: string; debug: boolean; - format: string; help: boolean; - init: boolean; - ['output-file']: string; + output: string; + tracking: string; // 'on' or 'off' version: boolean; watch: boolean; diff --git a/packages/hint/src/lib/types/formatters.ts b/packages/hint/src/lib/types/formatters.ts index 0c0a143ac45..7887b529929 100644 --- a/packages/hint/src/lib/types/formatters.ts +++ b/packages/hint/src/lib/types/formatters.ts @@ -5,6 +5,7 @@ export type FormatterOptions = { config?: UserConfig; isScanner?: boolean; noGenerateFiles?: boolean; + output?: string; resources?: HintResources; scanTime?: number; status?: string; diff --git a/yarn.lock b/yarn.lock index 2ae9dca9020..033004b4687 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1247,6 +1247,11 @@ ansi-regex@^4.0.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.0.0.tgz#70de791edf021404c3fd615aa89118ae0432e5a9" integrity sha512-iB5Dda8t/UqpPI/IjsejXu5jOGDrzn41wJyljwPH65VCIbk6+1BzFIMJGFwTNrYXT1CrD+B4l19U7awiQ8rk7w== +ansi-regex@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" + integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== + ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" @@ -9183,6 +9188,13 @@ strip-ansi@^5.0.0: dependencies: ansi-regex "^4.0.0" +strip-ansi@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.1.0.tgz#55aaa54e33b4c0649a7338a43437b1887d153ec4" + integrity sha512-TjxrkPONqO2Z8QDCpeE2j6n0M6EwxzyDgzEeGp+FbdvaJAt//ClYi6W5my+3ROlC/hZX2KACUwDfK49Ka5eDvg== + dependencies: + ansi-regex "^4.1.0" + strip-bom-buf@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/strip-bom-buf/-/strip-bom-buf-1.0.0.tgz#1cb45aaf57530f4caf86c7f75179d2c9a51dd572"