diff --git a/packages/snyk-fix/src/index.ts b/packages/snyk-fix/src/index.ts index c3181ed1a8..138f989aea 100644 --- a/packages/snyk-fix/src/index.ts +++ b/packages/snyk-fix/src/index.ts @@ -2,8 +2,9 @@ import * as debugLib from 'debug'; import * as pMap from 'p-map'; import * as ora from 'ora'; import * as chalk from 'chalk'; +import stripAnsi = require('strip-ansi'); -import { showResultsSummary } from './lib/output-formatters/show-results-summary'; +import * as outputFormatter from './lib/output-formatters/show-results-summary'; import { loadPlugin } from './plugins/load-plugin'; import { FixHandlerResultByPlugin } from './plugins/types'; @@ -17,12 +18,15 @@ export async function fix( options: FixOptions = { dryRun: false, quiet: false, + stripAnsi: false, }, ): Promise<{ - resultsByPlugin: FixHandlerResultByPlugin; - exceptionsByScanType: ErrorsByEcoSystem; + results: FixHandlerResultByPlugin; + exceptions: ErrorsByEcoSystem; + meta: { fixed: number; failed: number }; + fixSummary: string; }> { - const spinner = ora({ isSilent: options.quiet }); + const spinner = ora({ isSilent: options.quiet, stream: process.stdout }); let resultsByPlugin: FixHandlerResultByPlugin = {}; const entitiesPerType = groupEntitiesPerScanType(entities); const exceptionsByScanType: ErrorsByEcoSystem = {}; @@ -45,15 +49,26 @@ export async function fix( concurrency: 3, }, ); - const fixSummary = await showResultsSummary( + const fixSummary = await outputFormatter.showResultsSummary( resultsByPlugin, exceptionsByScanType, ); + const failed = outputFormatter.calculateFailed( + resultsByPlugin, + exceptionsByScanType, + ); + const fixed = outputFormatter.calculateFixed(resultsByPlugin); + spinner.start(); spinner.stopAndPersist({ text: 'Done', symbol: chalk.green('✔') }); spinner.stopAndPersist({ text: `\n${fixSummary}` }); - return { resultsByPlugin, exceptionsByScanType }; + return { + results: resultsByPlugin, + exceptions: exceptionsByScanType, + fixSummary: options.stripAnsi ? stripAnsi(fixSummary) : fixSummary, + meta: { fixed, failed }, + }; } export function groupEntitiesPerScanType( diff --git a/packages/snyk-fix/src/lib/output-formatters/show-results-summary.ts b/packages/snyk-fix/src/lib/output-formatters/show-results-summary.ts index e3205fce3b..07d4431f5a 100644 --- a/packages/snyk-fix/src/lib/output-formatters/show-results-summary.ts +++ b/packages/snyk-fix/src/lib/output-formatters/show-results-summary.ts @@ -22,7 +22,9 @@ export async function showResultsSummary( resultsByPlugin, exceptionsByScanType, ); - return `${successfulFixesSummary}\n\n${unresolvedSummary}\n\n${overallSummary}`; + return `${successfulFixesSummary}${ + unresolvedSummary ? `\n\n${unresolvedSummary}` : '' + }\n\n${overallSummary}`; } export function generateSuccessfulFixesSummary( @@ -99,27 +101,41 @@ export function generateFixedAndFailedSummary( ): string { const sectionTitle = 'Summary:'; const formattedTitleHeader = `${chalk.bold(sectionTitle)}`; - let fixedItems = 0; - let failedItems = 0; + const fixedItems = calculateFixed(resultsByPlugin); + const failedItems = calculateFailed(resultsByPlugin, exceptionsByScanType); + + return `${formattedTitleHeader}\n\n${PADDING_SPACE}${chalk.bold.red( + failedItems, + )} items were not fixed\n${PADDING_SPACE}${chalk.green.bold( + fixedItems, + )} items were successfully fixed`; +} + +export function calculateFixed( + resultsByPlugin: FixHandlerResultByPlugin, +): number { + let fixed = 0; for (const plugin of Object.keys(resultsByPlugin)) { - fixedItems += resultsByPlugin[plugin].succeeded.length; + fixed += resultsByPlugin[plugin].succeeded.length; } + return fixed; +} +export function calculateFailed( + resultsByPlugin: FixHandlerResultByPlugin, + exceptionsByScanType: ErrorsByEcoSystem, +): number { + let failed = 0; for (const plugin of Object.keys(resultsByPlugin)) { const results = resultsByPlugin[plugin]; - failedItems += results.failed.length + results.skipped.length; + failed += results.failed.length + results.skipped.length; } if (Object.keys(exceptionsByScanType).length) { for (const ecosystem of Object.keys(exceptionsByScanType)) { const unresolved = exceptionsByScanType[ecosystem]; - failedItems += unresolved.originals.length; + failed += unresolved.originals.length; } } - - return `${formattedTitleHeader}\n\n${PADDING_SPACE}${chalk.bold.red( - failedItems, - )} items were not fixed\n${PADDING_SPACE}${chalk.green.bold( - fixedItems, - )} items were successfully fixed`; + return failed; } diff --git a/packages/snyk-fix/src/plugins/python/index.ts b/packages/snyk-fix/src/plugins/python/index.ts index 9bcb79e651..9a808a44bd 100644 --- a/packages/snyk-fix/src/plugins/python/index.ts +++ b/packages/snyk-fix/src/plugins/python/index.ts @@ -14,7 +14,7 @@ export async function pythonFix( entities: EntityToFix[], options: FixOptions, ): Promise { - const spinner = ora({ isSilent: options.quiet }); + const spinner = ora({ isSilent: options.quiet, stream: process.stdout }); spinner.text = 'Looking for supported Python items'; spinner.start(); const pluginId = 'python'; diff --git a/packages/snyk-fix/src/types.ts b/packages/snyk-fix/src/types.ts index 118935471b..49548eb712 100644 --- a/packages/snyk-fix/src/types.ts +++ b/packages/snyk-fix/src/types.ts @@ -221,4 +221,5 @@ export interface ErrorsByEcoSystem { export interface FixOptions { dryRun?: boolean; quiet?: boolean; + stripAnsi?: boolean; } diff --git a/packages/snyk-fix/test/acceptance/plugins/python/update-dependencies/update-dependencies.spec.ts b/packages/snyk-fix/test/acceptance/plugins/python/update-dependencies/update-dependencies.spec.ts index 838f5fe64f..04fe080ad0 100644 --- a/packages/snyk-fix/test/acceptance/plugins/python/update-dependencies/update-dependencies.spec.ts +++ b/packages/snyk-fix/test/acceptance/plugins/python/update-dependencies/update-dependencies.spec.ts @@ -62,11 +62,14 @@ describe('fix *req*.txt / *.txt Python projects', () => { }; // Act - const result = await snykFix.fix([entityToFix], { quiet: true }); + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + }); // Assert expect(result).toMatchObject({ - exceptionsByScanType: {}, - resultsByPlugin: { + exceptions: {}, + results: { python: { failed: [], skipped: [ @@ -136,7 +139,10 @@ describe('fix *req*.txt / *.txt Python projects', () => { }; // Act - const result = await snykFix.fix([entityToFix], { quiet: true }); + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + }); // Assert const expectedManifest = @@ -145,8 +151,8 @@ describe('fix *req*.txt / *.txt Python projects', () => { const fixedFileContent = fs.readFileSync(fixedFilePath, 'utf-8'); expect(fixedFileContent).toEqual(expectedManifest); expect(result).toMatchObject({ - exceptionsByScanType: {}, - resultsByPlugin: { + exceptions: {}, + results: { python: { failed: [], skipped: [], @@ -224,7 +230,10 @@ describe('fix *req*.txt / *.txt Python projects', () => { }; // Act - const result = await snykFix.fix([entityToFix], { quiet: true }); + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + }); // Assert const expectedManifest = @@ -233,8 +242,8 @@ describe('fix *req*.txt / *.txt Python projects', () => { const fixedFileContent = fs.readFileSync(fixedFilePath, 'utf-8'); expect(fixedFileContent).toEqual(expectedManifest); expect(result).toMatchObject({ - exceptionsByScanType: {}, - resultsByPlugin: { + exceptions: {}, + results: { python: { failed: [], skipped: [], @@ -312,7 +321,10 @@ describe('fix *req*.txt / *.txt Python projects', () => { }; // Act - const result = await snykFix.fix([entityToFix], { quiet: true }); + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + }); // Assert const expectedManifest = @@ -322,8 +334,8 @@ describe('fix *req*.txt / *.txt Python projects', () => { const fixedFileContent = fs.readFileSync(fixedFilePath, 'utf-8'); expect(fixedFileContent).toEqual(expectedManifest); expect(result).toMatchObject({ - exceptionsByScanType: {}, - resultsByPlugin: { + exceptions: {}, + results: { python: { failed: [], skipped: [], @@ -396,7 +408,10 @@ describe('fix *req*.txt / *.txt Python projects', () => { }; // Act - const result = await snykFix.fix([entityToFix], { quiet: true }); + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + }); // Assert const expectedManifest = 'django==2.0.1\n'; @@ -405,8 +420,8 @@ describe('fix *req*.txt / *.txt Python projects', () => { const fixedFileContent = fs.readFileSync(fixedFilePath, 'utf-8'); expect(fixedFileContent).toEqual(expectedManifest); expect(result).toMatchObject({ - exceptionsByScanType: {}, - resultsByPlugin: { + exceptions: {}, + results: { python: { failed: [], skipped: [], @@ -476,7 +491,10 @@ describe('fix *req*.txt / *.txt Python projects', () => { }; // Act - const result = await snykFix.fix([entityToFix], { quiet: true }); + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + }); // Assert const expectedManifest = 'Django==2.0.1'; @@ -485,8 +503,8 @@ describe('fix *req*.txt / *.txt Python projects', () => { const fixedFileContent = fs.readFileSync(fixedFilePath, 'utf-8'); expect(fixedFileContent).toEqual(expectedManifest); expect(result).toMatchObject({ - exceptionsByScanType: {}, - resultsByPlugin: { + exceptions: {}, + results: { python: { failed: [], skipped: [], @@ -555,7 +573,10 @@ describe('fix *req*.txt / *.txt Python projects', () => { }; // Act - const result = await snykFix.fix([entityToFix], { quiet: true }); + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + }); // Assert const expectedManifest = 'foo==55.66.7\n'; @@ -564,8 +585,8 @@ describe('fix *req*.txt / *.txt Python projects', () => { const fixedFileContent = fs.readFileSync(fixedFilePath, 'utf-8'); expect(fixedFileContent).toEqual(expectedManifest); expect(result).toMatchObject({ - exceptionsByScanType: {}, - resultsByPlugin: { + exceptions: {}, + results: { python: { failed: [], skipped: [], @@ -639,7 +660,10 @@ describe('fix *req*.txt / *.txt Python projects', () => { }; // Act - const result = await snykFix.fix([entityToFix], { quiet: true }); + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + }); // Assert const expectedManifest = 'django>=2.0.1\nclick>7.1\n'; @@ -648,8 +672,8 @@ describe('fix *req*.txt / *.txt Python projects', () => { const fixedFileContent = fs.readFileSync(fixedFilePath, 'utf-8'); expect(fixedFileContent).toEqual(expectedManifest); expect(result).toMatchObject({ - exceptionsByScanType: {}, - resultsByPlugin: { + exceptions: {}, + results: { python: { failed: [], skipped: [], @@ -721,14 +745,17 @@ describe('fix *req*.txt / *.txt Python projects', () => { }; // Act - const result = await snykFix.fix([entityToFix], { quiet: true }); + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + }); // Assert const fixedFileContent = fs.readFileSync(fixedFilePath, 'utf-8'); expect(fixedFileContent).toMatchSnapshot(); expect(result).toMatchObject({ - exceptionsByScanType: {}, - resultsByPlugin: { + exceptions: {}, + results: { python: { failed: [], skipped: [], diff --git a/packages/snyk-fix/test/unit/__snapshots__/fix.spec.ts.snap b/packages/snyk-fix/test/unit/__snapshots__/fix.spec.ts.snap index fbab0f2be4..5e567cfecf 100644 --- a/packages/snyk-fix/test/unit/__snapshots__/fix.spec.ts.snap +++ b/packages/snyk-fix/test/unit/__snapshots__/fix.spec.ts.snap @@ -2,7 +2,7 @@ exports[`Error handling Snyk fix returns error when called with unsupported type 1`] = ` Object { - "exceptionsByScanType": Object { + "exceptions": Object { "npm": Object { "originals": Array [ Object { @@ -57,14 +57,47 @@ Object { "userMessage": "npm is not supported.", }, }, - "resultsByPlugin": Object {}, + "fixSummary": "✖ No successful fixes + +Unresolved items: + + package.json + ✖ npm is not supported. + +Summary: + + 1 items were not fixed + 0 items were successfully fixed", + "meta": Object { + "failed": 1, + "fixed": 0, + }, + "results": Object {}, } `; exports[`Snyk fix Snyk fix returns results for supported & unsupported type 1`] = ` Object { - "exceptionsByScanType": Object {}, - "resultsByPlugin": Object { + "exceptions": Object {}, + "fixSummary": "Successful fixes: + + requirements.txt + ✔ Pinned django from 1.6.1 to 2.0.1 + +Unresolved items: + + Pipfile + ✖ Pipfile is not supported + +Summary: + + 1 items were not fixed + 1 items were successfully fixed", + "meta": Object { + "failed": 1, + "fixed": 1, + }, + "results": Object { "python": Object { "failed": Array [], "skipped": Array [ diff --git a/packages/snyk-fix/test/unit/fix.spec.ts b/packages/snyk-fix/test/unit/fix.spec.ts index 8b6aa47546..97233dce38 100644 --- a/packages/snyk-fix/test/unit/fix.spec.ts +++ b/packages/snyk-fix/test/unit/fix.spec.ts @@ -11,12 +11,15 @@ describe('Snyk fix', () => { const writeFileSpy = jest.spyOn(projectTestResult.workspace, 'writeFile'); // Act - const res = await snykFix.fix([projectTestResult], { quiet: true }); + const res = await snykFix.fix([projectTestResult], { + quiet: true, + stripAnsi: true, + }); // Assert expect(writeFileSpy).toHaveBeenCalled(); - expect(res.exceptionsByScanType).toMatchSnapshot(); - expect(res.resultsByPlugin).toMatchSnapshot(); + expect(res.exceptions).toMatchSnapshot(); + expect(res.results).toMatchSnapshot(); }); it('Snyk fix returns results for supported type in dryRun mode (no write)', async () => { @@ -53,7 +56,7 @@ describe('Snyk fix', () => { // Act const res = await snykFix.fix( [projectTestResult, pipfileProjectTestResult], - { quiet: true }, + { quiet: true, stripAnsi: true }, ); // Assert @@ -85,30 +88,28 @@ describe('Snyk fix', () => { txtProdProjectTestResult, pipfileProjectTestResult, ], - { quiet: true }, + { quiet: true, stripAnsi: true }, ); // Assert - expect(res.exceptionsByScanType).toEqual({}); - expect(Object.keys(res.resultsByPlugin)).toHaveLength(1); - expect(Object.keys(res.resultsByPlugin)[0]).toEqual('python'); + expect(res.exceptions).toEqual({}); + expect(Object.keys(res.results)).toHaveLength(1); + expect(Object.keys(res.results)[0]).toEqual('python'); // skipped unsupported - expect(res.resultsByPlugin.python.skipped).toHaveLength(1); - expect(res.resultsByPlugin.python.skipped[0]).toEqual({ + expect(res.results.python.skipped).toHaveLength(1); + expect(res.results.python.skipped[0]).toEqual({ original: pipfileProjectTestResult, userMessage: 'Pipfile is not supported', }); // first *.txt throws because of the mock above - expect(res.resultsByPlugin.python.failed).toHaveLength(0); - expect(res.resultsByPlugin.python.succeeded).toHaveLength(2); + expect(res.results.python.failed).toHaveLength(0); + expect(res.results.python.succeeded).toHaveLength(2); expect( - res.resultsByPlugin.python.succeeded[0].original.scanResult.identity - .targetFile, + res.results.python.succeeded[0].original.scanResult.identity.targetFile, ).toEqual('dev.txt'); expect( - res.resultsByPlugin.python.succeeded[1].original.scanResult.identity - .targetFile, + res.results.python.succeeded[1].original.scanResult.identity.targetFile, ).toEqual('prod.txt'); }); it('Snyk fix returns results as expected when 1 fails to fix', async () => { @@ -141,35 +142,33 @@ describe('Snyk fix', () => { txtProdProjectTestResult, pipfileProjectTestResult, ], - { quiet: true }, + { quiet: true, stripAnsi: true }, ); // Assert - expect(res.exceptionsByScanType).toEqual({}); - expect(Object.keys(res.resultsByPlugin)).toHaveLength(1); - expect(Object.keys(res.resultsByPlugin)[0]).toEqual('python'); + expect(res.exceptions).toEqual({}); + expect(Object.keys(res.results)).toHaveLength(1); + expect(Object.keys(res.results)[0]).toEqual('python'); // skipped unsupported - expect(res.resultsByPlugin.python.skipped).toHaveLength(1); - expect(res.resultsByPlugin.python.skipped[0]).toEqual({ + expect(res.results.python.skipped).toHaveLength(1); + expect(res.results.python.skipped[0]).toEqual({ userMessage: 'Pipfile is not supported', original: pipfileProjectTestResult, }); // first *.txt throws because of the mock above - expect(res.resultsByPlugin.python.failed).toHaveLength(1); + expect(res.results.python.failed).toHaveLength(1); expect( - res.resultsByPlugin.python.failed[0].original.scanResult.identity - .targetFile, + res.results.python.failed[0].original.scanResult.identity.targetFile, ).toEqual('dev.txt'); - expect(res.resultsByPlugin.python.failed[0].error.message).toEqual( + expect(res.results.python.failed[0].error.message).toEqual( 'Invalid encoding', ); - expect(res.resultsByPlugin.python.succeeded).toHaveLength(1); + expect(res.results.python.succeeded).toHaveLength(1); expect( - res.resultsByPlugin.python.succeeded[0].original.scanResult.identity - .targetFile, + res.results.python.succeeded[0].original.scanResult.identity.targetFile, ).toEqual('prod.txt'); }); @@ -184,20 +183,22 @@ describe('Snyk fix', () => { delete txtProdProjectTestResult.testResult.remediation; // Act - const res = await snykFix.fix([txtProdProjectTestResult], { quiet: true }); + const res = await snykFix.fix([txtProdProjectTestResult], { + quiet: true, + stripAnsi: true, + }); // Assert - expect(res.exceptionsByScanType).toEqual({}); - expect(Object.keys(res.resultsByPlugin)).toHaveLength(1); - expect(Object.keys(res.resultsByPlugin)[0]).toEqual('python'); + expect(res.exceptions).toEqual({}); + expect(Object.keys(res.results)).toHaveLength(1); + expect(Object.keys(res.results)[0]).toEqual('python'); // first *.txt throws because remediation is empty - expect(res.resultsByPlugin.python.failed).toHaveLength(0); - expect(res.resultsByPlugin.python.skipped).toHaveLength(1); + expect(res.results.python.failed).toHaveLength(0); + expect(res.results.python.skipped).toHaveLength(1); expect( - res.resultsByPlugin.python.skipped[0].original.scanResult.identity - .targetFile, + res.results.python.skipped[0].original.scanResult.identity.targetFile, ).toEqual('prod.txt'); - expect(res.resultsByPlugin.python.skipped[0].userMessage).toEqual( + expect(res.results.python.skipped[0].userMessage).toEqual( 'No remediation data available', ); }); @@ -307,7 +308,10 @@ describe('Error handling', () => { JSON.stringify({}), ); // Act - const res = await snykFix.fix([projectTestResult], { quiet: true }); + const res = await snykFix.fix([projectTestResult], { + quiet: true, + stripAnsi: true, + }); // Assert expect(res).toMatchSnapshot(); });