diff --git a/package-lock.json b/package-lock.json index 6221e6ad0..f9004c903 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@code-pushup/cli-source", - "version": "0.23.4", + "version": "0.26.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@code-pushup/cli-source", - "version": "0.23.4", + "version": "0.26.1", "license": "MIT", "dependencies": { "@code-pushup/portal-client": "^0.6.1", "@isaacs/cliui": "^8.0.2", - "@poppinss/cliui": "^6.3.0", + "@poppinss/cliui": "^6.4.0", "@swc/helpers": "0.5.3", "bundle-require": "^4.0.1", "chalk": "^5.3.0", @@ -5421,9 +5421,9 @@ "dev": true }, "node_modules/@poppinss/cliui": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@poppinss/cliui/-/cliui-6.3.0.tgz", - "integrity": "sha512-GEu/IsJ9SanzAGa9NaHsHneumwlScLfhBJHU8uYcB6GyaTvQQg38OuiGnn5U95Wk3a/roUOSsrEVU1bnVvYtoQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@poppinss/cliui/-/cliui-6.4.0.tgz", + "integrity": "sha512-8exOqpXxjQfJeHORAkbqzsQL2ybjKh22PD2UUq2poBemhxCH5KEe6qqu5nKc4KM3lhXc8QizlPMDxOIP+Pyt/w==", "dependencies": { "@poppinss/colors": "^4.1.2", "cli-boxes": "^3.0.0", @@ -5431,7 +5431,7 @@ "cli-truncate": "^4.0.0", "log-update": "^6.0.0", "pretty-hrtime": "^1.0.3", - "string-width": "^7.0.0", + "string-width": "^7.1.0", "supports-color": "^9.4.0", "terminal-size": "^4.0.0", "wordwrap": "^1.0.0" diff --git a/package.json b/package.json index 2c0c15ff4..55fadcd96 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "dependencies": { "@code-pushup/portal-client": "^0.6.1", "@isaacs/cliui": "^8.0.2", - "@poppinss/cliui": "^6.3.0", + "@poppinss/cliui": "^6.4.0", "@swc/helpers": "0.5.3", "bundle-require": "^4.0.1", "chalk": "^5.3.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 057d23dd1..5e5ff99f8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,7 +10,6 @@ "@code-pushup/core": "*", "yargs": "^17.7.2", "chalk": "^5.3.0", - "@code-pushup/utils": "*", - "@poppinss/cliui": "^6.3.0" + "@code-pushup/utils": "*" } } diff --git a/packages/cli/src/lib/autorun/autorun-command.ts b/packages/cli/src/lib/autorun/autorun-command.ts index 48ca5772c..36db089fc 100644 --- a/packages/cli/src/lib/autorun/autorun-command.ts +++ b/packages/cli/src/lib/autorun/autorun-command.ts @@ -6,12 +6,12 @@ import { collectAndPersistReports, upload, } from '@code-pushup/core'; +import { ui } from '@code-pushup/utils'; import { CLI_NAME } from '../constants'; import { collectSuccessfulLog, renderConfigureCategoriesHint, renderIntegratePortalHint, - ui, uploadSuccessfulLog, } from '../implementation/logging'; diff --git a/packages/cli/src/lib/collect/collect-command.ts b/packages/cli/src/lib/collect/collect-command.ts index ded804ae6..102681d71 100644 --- a/packages/cli/src/lib/collect/collect-command.ts +++ b/packages/cli/src/lib/collect/collect-command.ts @@ -4,12 +4,11 @@ import { CollectAndPersistReportsOptions, collectAndPersistReports, } from '@code-pushup/core'; -import { link } from '@code-pushup/utils'; +import { link, ui } from '@code-pushup/utils'; import { CLI_NAME } from '../constants'; import { collectSuccessfulLog, renderConfigureCategoriesHint, - ui, } from '../implementation/logging'; export function yargsCollectCommandObject(): CommandModule { diff --git a/packages/cli/src/lib/compare/compare-command.ts b/packages/cli/src/lib/compare/compare-command.ts index 7f342f940..ef48b3c85 100644 --- a/packages/cli/src/lib/compare/compare-command.ts +++ b/packages/cli/src/lib/compare/compare-command.ts @@ -3,10 +3,10 @@ import { join } from 'node:path'; import { CommandModule } from 'yargs'; import { compareReportFiles } from '@code-pushup/core'; import { PersistConfig } from '@code-pushup/models'; +import { ui } from '@code-pushup/utils'; import { CLI_NAME } from '../constants'; import type { CompareOptions } from '../implementation/compare.model'; import { yargsCompareOptionsDefinition } from '../implementation/compare.options'; -import { ui } from '../implementation/logging'; export function yargsCompareCommandObject() { const command = 'compare'; diff --git a/packages/cli/src/lib/implementation/logging.ts b/packages/cli/src/lib/implementation/logging.ts index 273483ec0..ca958b0d9 100644 --- a/packages/cli/src/lib/implementation/logging.ts +++ b/packages/cli/src/lib/implementation/logging.ts @@ -1,17 +1,5 @@ -import { cliui } from '@poppinss/cliui'; import chalk from 'chalk'; -import { link } from '@code-pushup/utils'; - -export type CliUi = ReturnType; - -// eslint-disable-next-line import/no-mutable-exports,functional/no-let -export let singletonUiInstance: CliUi | undefined; -export function ui(): CliUi { - if (singletonUiInstance === undefined) { - singletonUiInstance = cliui(); - } - return singletonUiInstance; -} +import { link, ui } from '@code-pushup/utils'; export function renderConfigureCategoriesHint(): void { ui().logger.info( @@ -26,6 +14,7 @@ export function uploadSuccessfulLog(url: string): void { ui().logger.success('Upload successful!'); ui().logger.success(link(url)); } + export function collectSuccessfulLog(): void { ui().logger.success('Collecting report successful!'); } diff --git a/packages/cli/src/lib/implementation/logging.unit.test.ts b/packages/cli/src/lib/implementation/logging.unit.test.ts deleted file mode 100644 index be09addfc..000000000 --- a/packages/cli/src/lib/implementation/logging.unit.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { expect } from 'vitest'; -import { ui } from './logging'; - -describe('ui', () => { - it('should return singleton', () => { - expect(ui()).toBe(ui()); - }); -}); diff --git a/packages/cli/src/lib/implementation/only-plugins.utils.ts b/packages/cli/src/lib/implementation/only-plugins.utils.ts index c37cebee9..75b9c3abd 100644 --- a/packages/cli/src/lib/implementation/only-plugins.utils.ts +++ b/packages/cli/src/lib/implementation/only-plugins.utils.ts @@ -1,5 +1,6 @@ import chalk from 'chalk'; import type { CategoryConfig, CoreConfig } from '@code-pushup/models'; +import { ui } from '@code-pushup/utils'; export function filterPluginsBySlug( plugins: CoreConfig['plugins'], @@ -32,7 +33,7 @@ export function filterCategoryByPluginSlug( const isNotSkipped = onlyPlugins.includes(ref.plugin); if (!isNotSkipped && verbose) { - console.info( + ui().logger.info( `${chalk.yellow('⚠')} Category "${ category.title }" is ignored because it references audits from skipped plugin "${ @@ -58,7 +59,7 @@ export function validateOnlyPluginsOption( : []; if (missingPlugins.length > 0 && verbose) { - console.warn( + ui().logger.warning( `${chalk.yellow( '⚠', )} The --onlyPlugin argument references plugins with "${missingPlugins.join( diff --git a/packages/cli/src/lib/implementation/only-plugins.utils.unit.test.ts b/packages/cli/src/lib/implementation/only-plugins.utils.unit.test.ts index 2441d8a2a..cbf4101b0 100644 --- a/packages/cli/src/lib/implementation/only-plugins.utils.unit.test.ts +++ b/packages/cli/src/lib/implementation/only-plugins.utils.unit.test.ts @@ -1,5 +1,7 @@ import { describe, expect } from 'vitest'; import { CategoryConfig, CoreConfig } from '@code-pushup/models'; +import { getLogMessages } from '@code-pushup/test-utils'; +import { ui } from '@code-pushup/utils'; import { filterCategoryByPluginSlug, filterPluginsBySlug, @@ -86,11 +88,9 @@ describe('filterCategoryByPluginSlug', () => { verbose: true, }, ); - expect(console.info).toHaveBeenCalledWith( - expect.stringContaining('"category1" is ignored'), - ); - expect(console.info).toHaveBeenCalledWith( - expect.stringContaining('skipped plugin "plugin2"'), + const logs = getLogMessages(ui().logger); + expect(logs[0]).toMatch( + /Category "category1" is ignored .* skipped plugin "plugin2"/, ); }); @@ -110,10 +110,9 @@ describe('validateOnlyPluginsOption', () => { verbose: true, }, ); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'plugins with "plugin3", "plugin4" slugs, but no such plugins are present', - ), + const logs = getLogMessages(ui().logger); + expect(logs[0]).toContain( + 'The --onlyPlugin argument references plugins with "plugin3", "plugin4" slugs', ); }); @@ -125,6 +124,6 @@ describe('validateOnlyPluginsOption', () => { verbose: true, }, ); - expect(console.warn).not.toHaveBeenCalled(); + expect(getLogMessages(ui().logger)).toHaveLength(0); }); }); diff --git a/packages/cli/src/lib/print-config/print-config-command.ts b/packages/cli/src/lib/print-config/print-config-command.ts index c33cddce9..206acf2f3 100644 --- a/packages/cli/src/lib/print-config/print-config-command.ts +++ b/packages/cli/src/lib/print-config/print-config-command.ts @@ -1,6 +1,6 @@ import { CommandModule } from 'yargs'; +import { ui } from '@code-pushup/utils'; import { filterKebabCaseKeys } from '../implementation/global.utils'; -import { ui } from '../implementation/logging'; export function yargsConfigCommandObject() { const command = 'print-config'; diff --git a/packages/cli/src/lib/print-config/print-config-command.unit.test.ts b/packages/cli/src/lib/print-config/print-config-command.unit.test.ts index eb5ddf1c8..403b36a92 100644 --- a/packages/cli/src/lib/print-config/print-config-command.unit.test.ts +++ b/packages/cli/src/lib/print-config/print-config-command.unit.test.ts @@ -1,6 +1,7 @@ -import { beforeAll, describe, expect } from 'vitest'; +import { describe, expect, vi } from 'vitest'; +import { getLogMessages } from '@code-pushup/test-utils'; +import { ui } from '@code-pushup/utils'; import { DEFAULT_CLI_CONFIGURATION } from '../../../mocks/constants'; -import { ui } from '../implementation/logging'; import { yargsCli } from '../yargs-cli'; import { yargsConfigCommandObject } from './print-config-command'; @@ -15,11 +16,6 @@ vi.mock('@code-pushup/core', async () => { }); describe('print-config-command', () => { - beforeAll(() => { - // initialize it in raw mode - ui().switchMode('raw'); - }); - it('should filter out meta arguments and kebab duplicates', async () => { await yargsCli( [ @@ -31,30 +27,11 @@ describe('print-config-command', () => { { ...DEFAULT_CLI_CONFIGURATION, commands: [yargsConfigCommandObject()] }, ).parseAsync(); - const log = ui().logger.getLogs()[0]; - - expect(log).toEqual( - expect.objectContaining({ - message: expect.not.stringContaining('"$0":'), - }), - ); - - expect(log).toEqual( - expect.objectContaining({ - message: expect.not.stringContaining('"_":'), - }), - ); - - expect(log).toEqual( - expect.objectContaining({ - message: expect.stringContaining('"outputDir": "destinationDir"'), - }), - ); + const log = getLogMessages(ui().logger)[0]; + expect(log).not.toContain('"$0":'); + expect(log).not.toContain('"_":'); - expect(log).toEqual( - expect.objectContaining({ - message: expect.not.stringContaining('"output-dir":'), - }), - ); + expect(log).toContain('"outputDir": "destinationDir"'); + expect(log).not.toContain('"output-dir":'); }); }); diff --git a/packages/cli/src/lib/upload/upload-command.ts b/packages/cli/src/lib/upload/upload-command.ts index 79d73b431..cfacaae65 100644 --- a/packages/cli/src/lib/upload/upload-command.ts +++ b/packages/cli/src/lib/upload/upload-command.ts @@ -1,10 +1,10 @@ import chalk from 'chalk'; import { ArgumentsCamelCase, CommandModule } from 'yargs'; import { UploadOptions, upload } from '@code-pushup/core'; +import { ui } from '@code-pushup/utils'; import { CLI_NAME } from '../constants'; import { renderIntegratePortalHint, - ui, uploadSuccessfulLog, } from '../implementation/logging'; diff --git a/packages/cli/vite.config.unit.ts b/packages/cli/vite.config.unit.ts index 9b0b7f0b3..451dec359 100644 --- a/packages/cli/vite.config.unit.ts +++ b/packages/cli/vite.config.unit.ts @@ -21,6 +21,7 @@ export default defineConfig({ include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], globalSetup: ['../../global-setup.ts'], setupFiles: [ + '../../testing/test-setup/src/lib/cliui.mock.ts', '../../testing/test-setup/src/lib/fs.mock.ts', '../../testing/test-setup/src/lib/git.mock.ts', '../../testing/test-setup/src/lib/console.mock.ts', diff --git a/packages/core/src/lib/history.ts b/packages/core/src/lib/history.ts index 4e2a51904..fc4fb0202 100644 --- a/packages/core/src/lib/history.ts +++ b/packages/core/src/lib/history.ts @@ -1,6 +1,6 @@ import { LogOptions, LogResult, simpleGit } from 'simple-git'; import { CoreConfig, PersistConfig, UploadConfig } from '@code-pushup/models'; -import { getCurrentBranchOrTag, safeCheckout } from '@code-pushup/utils'; +import { getCurrentBranchOrTag, safeCheckout, ui } from '@code-pushup/utils'; import { collectAndPersistReports } from './collect-and-persist'; import { GlobalOptions } from './types'; import { upload } from './upload'; @@ -29,7 +29,7 @@ export async function history( const reports: string[] = []; // eslint-disable-next-line functional/no-loop-statements for (const commit of commits) { - console.info(`Collect ${commit}`); + ui().logger.info(`Collect ${commit}`); await safeCheckout(commit, forceCleanStatus); const currentConfig: HistoryOptions = { @@ -44,12 +44,14 @@ export async function history( await collectAndPersistReports(currentConfig); if (skipUploads) { - console.warn('Upload is skipped because skipUploads is set to true.'); + ui().logger.info('Upload is skipped because skipUploads is set to true.'); } else { if (currentConfig.upload) { await upload(currentConfig); } else { - console.warn('Upload is skipped because upload config is undefined.'); + ui().logger.info( + 'Upload is skipped because upload config is undefined.', + ); } } diff --git a/packages/core/src/lib/implementation/execute-plugin.ts b/packages/core/src/lib/implementation/execute-plugin.ts index 76a9d6295..a08b5f924 100644 --- a/packages/core/src/lib/implementation/execute-plugin.ts +++ b/packages/core/src/lib/implementation/execute-plugin.ts @@ -142,12 +142,10 @@ export async function executePlugins( progressBar?.endProgress('Done running plugins'); - const errorsCallback = ({ reason }: PromiseRejectedResult) => { - console.error(reason); - }; + const errorsTransform = ({ reason }: PromiseRejectedResult) => String(reason); const results = await Promise.allSettled(pluginsResult); - logMultipleResults(results, 'Plugins', undefined, errorsCallback); + logMultipleResults(results, 'Plugins', undefined, errorsTransform); const { fulfilled, rejected } = groupByStatus(results); if (rejected.length > 0) { diff --git a/packages/core/src/lib/implementation/execute-plugin.unit.test.ts b/packages/core/src/lib/implementation/execute-plugin.unit.test.ts index 851db01ed..f28aaa265 100644 --- a/packages/core/src/lib/implementation/execute-plugin.unit.test.ts +++ b/packages/core/src/lib/implementation/execute-plugin.unit.test.ts @@ -1,10 +1,12 @@ import { vol } from 'memfs'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { AuditOutputs, PluginConfig } from '@code-pushup/models'; import { MEMFS_VOLUME, MINIMAL_PLUGIN_CONFIG_MOCK, + getLogMessages, } from '@code-pushup/test-utils'; +import { ui } from '@code-pushup/utils'; import { PluginOutputMissingAuditError, executePlugin, @@ -126,7 +128,9 @@ describe('executePlugins', () => { it('should print invalid plugin errors and throw', async () => { const pluginConfig = { ...MINIMAL_PLUGIN_CONFIG_MOCK, - runner: vi.fn().mockRejectedValue('plugin 1 error'), + runner: vi + .fn() + .mockRejectedValue('Audit metadata not found for slug node-version'), }; const pluginConfig2 = { ...MINIMAL_PLUGIN_CONFIG_MOCK, @@ -142,10 +146,14 @@ describe('executePlugins', () => { progress: false, }), ).rejects.toThrow( - 'Plugins failed: 2 errors: plugin 1 error, plugin 3 error', + 'Plugins failed: 2 errors: Audit metadata not found for slug node-version, plugin 3 error', ); - expect(console.error).toHaveBeenCalledWith('plugin 1 error'); - expect(console.error).toHaveBeenCalledWith('plugin 3 error'); + const logs = getLogMessages(ui().logger); + expect(logs[0]).toBe('[ yellow(warn) ] Plugins failed: '); + expect(logs[1]).toBe( + '[ yellow(warn) ] Audit metadata not found for slug node-version', + ); + expect(pluginConfig.runner).toHaveBeenCalled(); expect(pluginConfig2.runner).toHaveBeenCalled(); expect(pluginConfig3.runner).toHaveBeenCalled(); diff --git a/packages/core/src/lib/implementation/persist.ts b/packages/core/src/lib/implementation/persist.ts index 0d9f46890..0a811ba11 100644 --- a/packages/core/src/lib/implementation/persist.ts +++ b/packages/core/src/lib/implementation/persist.ts @@ -5,10 +5,11 @@ import { MultipleFileResults, directoryExists, generateMdReport, - generateStdoutSummary, logMultipleFileResults, + logStdoutSummary, scoreReport, sortReport, + ui, } from '@code-pushup/utils'; export class PersistDirError extends Error { @@ -30,7 +31,8 @@ export async function persistReport( const { outputDir, filename, format } = options; const sortedScoredReport = sortReport(scoreReport(report)); - console.info(generateStdoutSummary(sortedScoredReport)); + // terminal output + logStdoutSummary(sortedScoredReport); // collect physical format outputs const results = format.map(reportType => { @@ -52,7 +54,7 @@ export async function persistReport( try { await mkdir(outputDir, { recursive: true }); } catch (error) { - console.warn(error); + ui().logger.warning((error as Error).toString()); throw new PersistDirError(outputDir); } } @@ -75,7 +77,7 @@ async function persistResult(reportPath: string, content: string) { .then(() => stat(reportPath)) .then(stats => [reportPath, stats.size] as const) .catch(error => { - console.warn(error); + ui().logger.warning((error as Error).toString()); throw new PersistError(reportPath); }) ); diff --git a/packages/core/src/lib/implementation/persist.unit.test.ts b/packages/core/src/lib/implementation/persist.unit.test.ts index f5844d226..d749a4639 100644 --- a/packages/core/src/lib/implementation/persist.unit.test.ts +++ b/packages/core/src/lib/implementation/persist.unit.test.ts @@ -7,7 +7,9 @@ import { MEMFS_VOLUME, MINIMAL_REPORT_MOCK, REPORT_MOCK, + getLogMessages, } from '@code-pushup/test-utils'; +import { ui } from '@code-pushup/utils'; import { logPersistedResults, persistReport } from './persist'; describe('persistReport', () => { @@ -21,9 +23,8 @@ describe('persistReport', () => { filename: 'report', format: [], }); - expect(console.info).toHaveBeenCalledWith( - expect.stringContaining('Made with ❤ by code-pushup.dev'), - ); + const logs = getLogMessages(ui().logger); + expect(logs.at(-2)).toContain('Made with ❤ by code-pushup.dev'); }); it('should print a summary to stdout when all formats are specified`', async () => { @@ -32,9 +33,8 @@ describe('persistReport', () => { filename: 'report', format: ['md', 'json'], }); - expect(console.info).toHaveBeenCalledWith( - expect.stringContaining('Made with ❤ by code-pushup.dev'), - ); + const logs = getLogMessages(ui().logger); + expect(logs.at(-2)).toContain('Made with ❤ by code-pushup.dev'); }); it('should create a report in json format', async () => { @@ -100,31 +100,17 @@ describe('persistReport', () => { describe('logPersistedResults', () => { it('should log report sizes correctly`', () => { logPersistedResults([{ status: 'fulfilled', value: ['out.json', 10_000] }]); - expect(console.info).toHaveBeenNthCalledWith( - 1, - 'Generated reports successfully: ', - ); - expect(console.info).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('9.77 kB'), - ); - expect(console.info).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('out.json'), - ); + const logs = getLogMessages(ui().logger); + expect(logs[0]).toBe('[ green(success) ] Generated reports successfully: '); + expect(logs[1]).toContain('9.77 kB'); + expect(logs[1]).toContain('out.json'); }); it('should log fails correctly`', () => { logPersistedResults([{ status: 'rejected', reason: 'fail' }]); - - expect(console.warn).toHaveBeenNthCalledWith( - 1, - 'Generated reports failed: ', - ); - expect(console.warn).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('fail'), - ); + const logs = getLogMessages(ui().logger); + expect(logs[0]).toBe('[ yellow(warn) ] Generated reports failed: '); + expect(logs[1]).toContain('fail'); }); it('should log report sizes and fails correctly`', () => { @@ -132,27 +118,12 @@ describe('logPersistedResults', () => { { status: 'fulfilled', value: ['out.json', 10_000] }, { status: 'rejected', reason: 'fail' }, ]); + const logs = getLogMessages(ui().logger); + expect(logs[0]).toBe('[ green(success) ] Generated reports successfully: '); + expect(logs[1]).toContain('out.json'); + expect(logs[1]).toContain('9.77 kB'); - expect(console.info).toHaveBeenNthCalledWith( - 1, - 'Generated reports successfully: ', - ); - expect(console.info).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('out.json'), - ); - expect(console.info).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('9.77 kB'), - ); - - expect(console.warn).toHaveBeenNthCalledWith( - 1, - 'Generated reports failed: ', - ); - expect(console.warn).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('fail'), - ); + expect(logs[2]).toContain('Generated reports failed: '); + expect(logs[2]).toContain('fail'); }); }); diff --git a/packages/core/vite.config.unit.ts b/packages/core/vite.config.unit.ts index 1b494190a..5bee4c406 100644 --- a/packages/core/vite.config.unit.ts +++ b/packages/core/vite.config.unit.ts @@ -21,6 +21,7 @@ export default defineConfig({ include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], globalSetup: ['../../global-setup.ts'], setupFiles: [ + '../../testing/test-setup/src/lib/cliui.mock.ts', '../../testing/test-setup/src/lib/fs.mock.ts', '../../testing/test-setup/src/lib/git.mock.ts', '../../testing/test-setup/src/lib/console.mock.ts', diff --git a/packages/nx-plugin/src/generators/configuration/generator.ts b/packages/nx-plugin/src/generators/configuration/generator.ts index 4591d10e7..ca99324ee 100644 --- a/packages/nx-plugin/src/generators/configuration/generator.ts +++ b/packages/nx-plugin/src/generators/configuration/generator.ts @@ -7,6 +7,7 @@ import { } from '@nx/devkit'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { ui } from '@code-pushup/utils'; import { AddToProjectGeneratorSchema } from './schema'; export async function addToProjectGenerator( @@ -18,7 +19,7 @@ export async function addToProjectGenerator( const { root, targets } = projectConfiguration; if (tree.exists(join(root, 'code-pushup.config.ts'))) { - console.info('Code PushUp already configured for this project'); + ui().logger.info('Code PushUp already configured for this project'); return; } diff --git a/packages/plugin-coverage/src/lib/nx/coverage-paths.ts b/packages/plugin-coverage/src/lib/nx/coverage-paths.ts index 171487eea..850cd5c08 100644 --- a/packages/plugin-coverage/src/lib/nx/coverage-paths.ts +++ b/packages/plugin-coverage/src/lib/nx/coverage-paths.ts @@ -1,6 +1,7 @@ import type { ProjectGraphProjectNode, TargetConfiguration } from '@nx/devkit'; import chalk from 'chalk'; import { join } from 'node:path'; +import { ui } from '@code-pushup/utils'; import { CoverageResult } from '../config'; /** @@ -10,7 +11,7 @@ import { CoverageResult } from '../config'; export async function getNxCoveragePaths( targets: string[] = ['test'], ): Promise { - console.info( + ui().logger.info( chalk.bold('💡 Gathering coverage from the following nx projects:'), ); const { createProjectGraphAsync } = await import('@nx/devkit'); @@ -26,7 +27,7 @@ export async function getNxCoveragePaths( const coveragePath = getCoveragePathForTarget(target, targetConfig, name); const rootToReportsDir = join(data.root, coveragePath); - console.info(`- ${name}: ${target}`); + ui().logger.info(`- ${name}: ${target}`); return { pathToProject: data.root, @@ -35,7 +36,7 @@ export async function getNxCoveragePaths( }); }); - console.info('\n'); + ui().logger.info('\n'); return coverageResults.flat(); } diff --git a/packages/plugin-coverage/src/lib/runner/index.ts b/packages/plugin-coverage/src/lib/runner/index.ts index 11fc45fa3..a88874d56 100644 --- a/packages/plugin-coverage/src/lib/runner/index.ts +++ b/packages/plugin-coverage/src/lib/runner/index.ts @@ -7,6 +7,7 @@ import { ensureDirectoryExists, executeProcess, readJsonFile, + ui, } from '@code-pushup/utils'; import { FinalCoveragePluginConfig } from '../config'; import { applyMaxScoreAboveThreshold } from '../utils'; @@ -24,10 +25,14 @@ export async function executeRunner(): Promise { await executeProcess({ command, args }); } catch (error) { if (error instanceof ProcessError) { - console.error(chalk.bold('stdout from failed coverage tool process:')); - console.error(error.stdout); - console.error(chalk.bold('stderr from failed coverage tool process:')); - console.error(error.stderr); + ui().logger.error( + chalk.bold('stdout from failed coverage tool process:'), + ); + ui().logger.error(error.stdout); + ui().logger.error( + chalk.bold('stderr from failed coverage tool process:'), + ); + ui().logger.error(error.stderr); } throw new Error( diff --git a/packages/plugin-eslint/src/lib/meta/rules.ts b/packages/plugin-eslint/src/lib/meta/rules.ts index 628d8e7dd..2a5d0d131 100644 --- a/packages/plugin-eslint/src/lib/meta/rules.ts +++ b/packages/plugin-eslint/src/lib/meta/rules.ts @@ -1,5 +1,5 @@ import type { ESLint, Linter, Rule } from 'eslint'; -import { distinct, toArray } from '@code-pushup/utils'; +import { distinct, toArray, ui } from '@code-pushup/utils'; import { jsonHash } from './hash'; export type RuleData = { @@ -38,7 +38,7 @@ export async function listRules( (acc, [ruleId, ruleEntry]) => { const meta = rulesMeta[ruleId]; if (!meta) { - console.warn(`Metadata not found for ESLint rule ${ruleId}`); + ui().logger.warning(`Metadata not found for ESLint rule ${ruleId}`); return acc; } const options = toArray(ruleEntry).slice(1); diff --git a/packages/plugin-eslint/src/lib/runner/transform.ts b/packages/plugin-eslint/src/lib/runner/transform.ts index 53dfcdc5f..eac24e31f 100644 --- a/packages/plugin-eslint/src/lib/runner/transform.ts +++ b/packages/plugin-eslint/src/lib/runner/transform.ts @@ -6,6 +6,7 @@ import { objectToEntries, pluralizeToken, truncateIssueMessage, + ui, } from '@code-pushup/utils'; import { ruleIdToSlug } from '../meta/hash'; import type { LinterOutput } from './types'; @@ -25,7 +26,7 @@ export function lintResultsToAudits({ .reduce>((acc, issue) => { const { ruleId, message, filePath } = issue; if (!ruleId) { - console.warn(`ESLint core error - ${message}`); + ui().logger.warning(`ESLint core error - ${message}`); return acc; } const options = ruleOptionsPerFile[filePath]?.[ruleId] ?? []; diff --git a/packages/plugin-lighthouse/src/lib/utils.ts b/packages/plugin-lighthouse/src/lib/utils.ts index a4cddb329..f0538c091 100644 --- a/packages/plugin-lighthouse/src/lib/utils.ts +++ b/packages/plugin-lighthouse/src/lib/utils.ts @@ -1,7 +1,12 @@ import { type CliFlags } from 'lighthouse'; import { Result } from 'lighthouse/types/lhr/audit-result'; import { Audit, AuditOutput, AuditOutputs, Group } from '@code-pushup/models'; -import { filterItemRefsBy, objectToCliArgs, toArray } from '@code-pushup/utils'; +import { + filterItemRefsBy, + objectToCliArgs, + toArray, + ui, +} from '@code-pushup/utils'; import { LIGHTHOUSE_REPORT_NAME } from './constants'; type RefinedLighthouseOption = { @@ -92,8 +97,7 @@ export function toAuditOutputs(lhrAudits: Result[]): AuditOutputs { // @TODO implement switch case for detail parsing. Related to #90 const unsupportedType = details.type; - // @TODO use cliui.logger.info Resolve TODO after PR #487 is merged. - console.info( + ui().logger.info( `Parsing details from type ${unsupportedType} is not implemented.`, ); diff --git a/packages/utils/package.json b/packages/utils/package.json index ed9d964d0..e482db0ad 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -8,6 +8,6 @@ "@isaacs/cliui": "^8.0.2", "simple-git": "^3.20.0", "multi-progress-bars": "^5.0.3", - "cli-table3": "^0.6.3" + "@poppinss/cliui": "^6.4.0" } } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 1c6ea07bb..37d234a94 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -49,8 +49,8 @@ export { isPromiseRejectedResult, } from './lib/guards'; export { logMultipleResults } from './lib/log-results'; -export { link } from './lib/logging'; export { ProgressBar, getProgressBar } from './lib/progress'; +export { logStdoutSummary } from './lib/reports/log-stdout-summary'; export { CODE_PUSHUP_DOMAIN, FOOTER_PREFIX, @@ -62,7 +62,6 @@ export { listGroupsFromAllPlugins, } from './lib/reports/flatten-plugins'; export { generateMdReport } from './lib/reports/generate-md-report'; -export { generateStdoutSummary } from './lib/reports/generate-stdout-summary'; export { scoreReport } from './lib/reports/scoring'; export { sortReport } from './lib/reports/sorting'; export { @@ -91,3 +90,4 @@ export { toUnixPath, } from './lib/transform'; export { verboseUtils } from './lib/verbose-utils'; +export { link, ui, CliUi, Column } from './lib/logging'; diff --git a/packages/utils/src/lib/file-system.ts b/packages/utils/src/lib/file-system.ts index 256768d49..9903a83ff 100644 --- a/packages/utils/src/lib/file-system.ts +++ b/packages/utils/src/lib/file-system.ts @@ -4,6 +4,7 @@ import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { formatBytes } from './formatting'; import { logMultipleResults } from './log-results'; +import { ui } from './logging'; export async function readTextFile(path: string): Promise { const buffer = await readFile(path); @@ -38,7 +39,7 @@ export async function ensureDirectoryExists(baseDir: string) { await mkdir(baseDir, { recursive: true }); return; } catch (error) { - console.error((error as { code: string; message: string }).message); + ui().logger.error((error as { code: string; message: string }).message); if ((error as { code: string }).code !== 'EEXIST') { throw error; } @@ -58,20 +59,19 @@ export function logMultipleFileResults( fileResults: MultipleFileResults, messagePrefix: string, ): void { - const succeededCallback = (result: PromiseFulfilledResult) => { + const succeededTransform = (result: PromiseFulfilledResult) => { const [fileName, size] = result.value; const formattedSize = size ? ` (${chalk.gray(formatBytes(size))})` : ''; - console.info(`- ${chalk.bold(fileName)}${formattedSize}`); - }; - const failedCallback = (result: PromiseRejectedResult) => { - console.warn(`- ${chalk.bold(result.reason)}`); + return `- ${chalk.bold(fileName)}${formattedSize}`; }; + const failedTransform = (result: PromiseRejectedResult) => + `- ${chalk.bold(result.reason)}`; logMultipleResults( fileResults, messagePrefix, - succeededCallback, - failedCallback, + succeededTransform, + failedTransform, ); } diff --git a/packages/utils/src/lib/git.ts b/packages/utils/src/lib/git.ts index 1b5e8a7ea..fc851b4e9 100644 --- a/packages/utils/src/lib/git.ts +++ b/packages/utils/src/lib/git.ts @@ -1,6 +1,7 @@ import { isAbsolute, join, relative } from 'node:path'; import { simpleGit } from 'simple-git'; import { Commit, commitSchema } from '@code-pushup/models'; +import { ui } from './logging'; import { toUnixPath } from './transform'; export async function getLatestCommit( @@ -77,8 +78,7 @@ export async function safeCheckout( if (forceCleanStatus) { await git.raw(['reset', '--hard']); await git.clean(['f', 'd']); - // @TODO replace with ui().logger.info - console.info(`git status cleaned`); + ui().logger.info(`git status cleaned`); } await guardAgainstLocalChanges(git); await git.checkout(branchOrHash); diff --git a/packages/utils/src/lib/log-results.ts b/packages/utils/src/lib/log-results.ts index 2d2466ba0..e848114ef 100644 --- a/packages/utils/src/lib/log-results.ts +++ b/packages/utils/src/lib/log-results.ts @@ -1,42 +1,49 @@ import { isPromiseFulfilledResult, isPromiseRejectedResult } from './guards'; +import { ui } from './logging'; export function logMultipleResults( results: PromiseSettledResult[], messagePrefix: string, - succeededCallback?: (result: PromiseFulfilledResult) => void, - failedCallback?: (result: PromiseRejectedResult) => void, + succeededTransform?: (result: PromiseFulfilledResult) => string, + failedTransform?: (result: PromiseRejectedResult) => string, ) { - if (succeededCallback) { + if (succeededTransform) { const succeededResults = results.filter(isPromiseFulfilledResult); logPromiseResults( succeededResults, `${messagePrefix} successfully: `, - succeededCallback, + succeededTransform, ); } - if (failedCallback) { + if (failedTransform) { const failedResults = results.filter(isPromiseRejectedResult); logPromiseResults( failedResults, `${messagePrefix} failed: `, - failedCallback, + failedTransform, ); } } export function logPromiseResults< T extends PromiseFulfilledResult | PromiseRejectedResult, ->(results: T[], logMessage: string, callback: (result: T) => void): void { +>(results: T[], logMessage: string, getMsg: (result: T) => string): void { if (results.length > 0) { - if (results[0]?.status === 'fulfilled') { - console.info(logMessage); - } else { - console.warn(logMessage); - } + const log = + results[0]?.status === 'fulfilled' + ? (m: string) => { + ui().logger.success(m); + } + : (m: string) => { + ui().logger.warning(m); + }; - results.forEach(callback); + log(logMessage); + results.forEach(result => { + log(getMsg(result)); + }); } } diff --git a/packages/utils/src/lib/log-results.unit.test.ts b/packages/utils/src/lib/log-results.unit.test.ts index 0162b0715..1a2b4acec 100644 --- a/packages/utils/src/lib/log-results.unit.test.ts +++ b/packages/utils/src/lib/log-results.unit.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; +import { getLogMessages } from '@code-pushup/test-utils'; import { FileResult } from './file-system'; import { logMultipleResults, logPromiseResults } from './log-results'; +import { ui } from './logging'; describe('logMultipleResults', () => { const succeededCallbackMock = vi.fn(); @@ -64,31 +66,21 @@ describe('logPromiseResults', () => { } as PromiseFulfilledResult, ], 'Uploaded reports successfully:', - result => { - console.info(result.value); - }, + (result): string => result.value.toString(), ); - - expect(console.info).toHaveBeenNthCalledWith( - 1, - 'Uploaded reports successfully:', - ); - expect(console.info).toHaveBeenNthCalledWith(2, ['out.json']); + const logs = getLogMessages(ui().logger); + expect(logs[0]).toBe('[ green(success) ] Uploaded reports successfully:'); + expect(logs[1]).toBe('[ green(success) ] out.json'); }); it('should log on fail', () => { logPromiseResults( [{ status: 'rejected', reason: 'fail' } as PromiseRejectedResult], 'Generated reports failed:', - result => { - console.warn(result.reason); - }, - ); - - expect(console.warn).toHaveBeenNthCalledWith( - 1, - 'Generated reports failed:', + (result: { reason: string }) => result.reason, ); - expect(console.warn).toHaveBeenNthCalledWith(2, 'fail'); + const logs = getLogMessages(ui().logger); + expect(logs[0]).toBe('[ yellow(warn) ] Generated reports failed:'); + expect(logs[1]).toBe('[ yellow(warn) ] fail'); }); }); diff --git a/packages/utils/src/lib/logging.ts b/packages/utils/src/lib/logging.ts index 8b97fd0ae..25a64d24e 100644 --- a/packages/utils/src/lib/logging.ts +++ b/packages/utils/src/lib/logging.ts @@ -1,4 +1,51 @@ +import isaacs_cliui from '@isaacs/cliui'; +import { cliui } from '@poppinss/cliui'; import chalk from 'chalk'; +import { TERMINAL_WIDTH } from './reports/constants'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ArgumentsType = T extends (...args: infer U) => any ? U : never; +export type CliUiBase = ReturnType; +type UI = ReturnType; +type CliExtension = { + row: (r: ArgumentsType) => void; +}; +export type Column = { + text: string; + width?: number; + align?: 'right' | 'left' | 'center'; + padding: number[]; + border?: boolean; +}; +export type CliUi = CliUiBase & CliExtension; + +// eslint-disable-next-line import/no-mutable-exports,functional/no-let +export let singletonUiInstance: CliUiBase | undefined; + +export function ui(): CliUi { + if (singletonUiInstance === undefined) { + singletonUiInstance = cliui(); + } + return { + ...singletonUiInstance, + row: args => { + logListItem(args); + }, + }; +} + +// eslint-disable-next-line functional/no-let +let singletonisaacUi: UI | undefined; +export function logListItem(args: ArgumentsType) { + if (singletonisaacUi === undefined) { + singletonisaacUi = isaacs_cliui({ width: TERMINAL_WIDTH }); + } + singletonisaacUi.div(...args); + const content = singletonisaacUi.toString(); + // eslint-disable-next-line functional/immutable-data + singletonisaacUi.rows = []; + singletonUiInstance?.logger.log(content); +} export function link(text: string) { return chalk.underline(chalk.blueBright(text)); diff --git a/packages/utils/src/lib/reports/__snapshots__/generate-stdout-summary.integration.test.ts.snap b/packages/utils/src/lib/reports/__snapshots__/log-stdout-summary.integration.test.ts.snap similarity index 89% rename from packages/utils/src/lib/reports/__snapshots__/generate-stdout-summary.integration.test.ts.snap rename to packages/utils/src/lib/reports/__snapshots__/log-stdout-summary.integration.test.ts.snap index 89d840321..0bd36aa53 100644 --- a/packages/utils/src/lib/reports/__snapshots__/generate-stdout-summary.integration.test.ts.snap +++ b/packages/utils/src/lib/reports/__snapshots__/log-stdout-summary.integration.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`generateStdoutSummary > should contain all sections when using the fixture report 1`] = ` +exports[`logStdoutSummary > should contain all sections when using the fixture report 1`] = ` "Code PushUp Report - @code-pushup/core@0.0.1 @@ -72,24 +72,23 @@ Lighthouse audits ● Cumulative Layout Shift 0 ● Total Blocking Time 0 ms - Categories -┌─────────────────────────────────────────────────────────────┬───────┬────────┐ -│ Category │ Score │ Audits │ -├─────────────────────────────────────────────────────────────┼───────┼────────┤ -│ Performance │ 92 │ 8 │ -├─────────────────────────────────────────────────────────────┼───────┼────────┤ -│ Bug prevention │ 68 │ 16 │ -├─────────────────────────────────────────────────────────────┼───────┼────────┤ -│ Code style │ 54 │ 13 │ -└─────────────────────────────────────────────────────────────┴───────┴────────┘ +┌─────────────────────────────────────────────────────────┬─────────┬──────────┐ +│ Category │ Score │ Audits │ +├─────────────────────────────────────────────────────────┼─────────┼──────────┤ +│ Performance │ 92 │ 8 │ +├─────────────────────────────────────────────────────────┼─────────┼──────────┤ +│ Bug prevention │ 68 │ 16 │ +├─────────────────────────────────────────────────────────┼─────────┼──────────┤ +│ Code style │ 54 │ 13 │ +└─────────────────────────────────────────────────────────┴─────────┴──────────┘ Made with ❤ by code-pushup.dev " `; -exports[`generateStdoutSummary > should not contain category section when categories are empty 1`] = ` +exports[`logStdoutSummary > should not contain category section when categories are empty 1`] = ` "Code PushUp Report - @code-pushup/core@0.0.1 @@ -161,7 +160,6 @@ Lighthouse audits ● Cumulative Layout Shift 0 ● Total Blocking Time 0 ms - Made with ❤ by code-pushup.dev " `; diff --git a/packages/utils/src/lib/reports/generate-stdout-summary.integration.test.ts b/packages/utils/src/lib/reports/generate-stdout-summary.integration.test.ts deleted file mode 100644 index b48f459f8..000000000 --- a/packages/utils/src/lib/reports/generate-stdout-summary.integration.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe } from 'vitest'; -import { reportMock } from '@code-pushup/test-utils'; -import { generateStdoutSummary } from './generate-stdout-summary'; -import { scoreReport } from './scoring'; -import { sortReport } from './sorting'; - -describe('generateStdoutSummary', () => { - it('should contain all sections when using the fixture report', () => { - const logOutput = generateStdoutSummary( - sortReport(scoreReport(reportMock())), - ); - - expect(logOutput).toContain('Categories'); - // removes all color codes from the output for snapshot readability - // eslint-disable-next-line no-control-regex - expect(logOutput.replace(/\u001B\[\d+m/g, '')).toMatchSnapshot(); - }); - - it('should not contain category section when categories are empty', () => { - const logOutput = generateStdoutSummary( - sortReport(scoreReport({ ...reportMock(), categories: [] })), - ); - - expect(logOutput).not.toContain('Categories'); - // removes all color codes from the output for snapshot readability - // eslint-disable-next-line no-control-regex - expect(logOutput.replace(/\u001B\[\d+m/g, '')).toMatchSnapshot(); - }); -}); diff --git a/packages/utils/src/lib/reports/generate-stdout-summary.ts b/packages/utils/src/lib/reports/generate-stdout-summary.ts deleted file mode 100644 index 5858693f1..000000000 --- a/packages/utils/src/lib/reports/generate-stdout-summary.ts +++ /dev/null @@ -1,115 +0,0 @@ -import cliui from '@isaacs/cliui'; -import chalk from 'chalk'; -import CliTable3 from 'cli-table3'; -import { - CODE_PUSHUP_DOMAIN, - FOOTER_PREFIX, - NEW_LINE, - SCORE_COLOR_RANGE, - TERMINAL_WIDTH, - reportHeadlineText, - reportRawOverviewTableHeaders, -} from './constants'; -import { ScoredReport } from './types'; -import { countCategoryAudits, formatReportScore } from './utils'; - -function addLine(line = ''): string { - return line + NEW_LINE; -} - -export function generateStdoutSummary(report: ScoredReport): string { - const printCategories = report.categories.length > 0; - return ( - addLine(reportToHeaderSection(report)) + - addLine() + - addLine(reportToDetailSection(report)) + - (printCategories ? addLine(reportToOverviewSection(report)) : '') + - addLine(`${FOOTER_PREFIX} ${CODE_PUSHUP_DOMAIN}`) - ); -} - -function reportToHeaderSection(report: ScoredReport): string { - const { packageName, version } = report; - return `${chalk.bold(reportHeadlineText)} - ${packageName}@${version}`; -} - -function reportToDetailSection(report: ScoredReport): string { - const { plugins } = report; - - return plugins.reduce((acc, plugin) => { - const { title, audits } = plugin; - const ui = cliui({ width: TERMINAL_WIDTH }); - - audits.forEach(audit => { - ui.div( - { - text: withColor({ score: audit.score, text: '●' }), - width: 2, - padding: [0, 1, 0, 0], - }, - { - text: audit.title, - // eslint-disable-next-line no-magic-numbers - padding: [0, 3, 0, 0], - }, - { - text: chalk.cyanBright(audit.displayValue || `${audit.value}`), - width: 10, - padding: [0, 0, 0, 0], - }, - ); - }); - return ( - acc + - addLine() + - addLine(chalk.magentaBright.bold(`${title} audits`)) + - addLine() + - addLine(ui.toString()) + - addLine() - ); - }, ''); -} - -function reportToOverviewSection({ - categories, - plugins, -}: ScoredReport): string { - const table = new CliTable3({ - // eslint-disable-next-line no-magic-numbers - colWidths: [TERMINAL_WIDTH - 7 - 8 - 4, 7, 8], - head: reportRawOverviewTableHeaders, - colAligns: ['left', 'right', 'right'], - style: { - head: ['cyan'], - }, - }); - - table.push( - ...categories.map(({ title, score, refs }) => [ - title, - withColor({ score }), - countCategoryAudits(refs, plugins), - ]), - ); - - return ( - addLine(chalk.magentaBright.bold('Categories')) + - addLine() + - addLine(table.toString()) - ); -} - -function withColor({ score, text }: { score: number; text?: string }) { - const formattedScore = text ?? formatReportScore(score); - const style = text ? chalk : chalk.bold; - - if (score >= SCORE_COLOR_RANGE.GREEN_MIN) { - return style.green(formattedScore); - } - - if (score >= SCORE_COLOR_RANGE.YELLOW_MIN) { - return style.yellow(formattedScore); - } - - return style.red(formattedScore); -} diff --git a/packages/utils/src/lib/reports/log-stdout-summary.integration.test.ts b/packages/utils/src/lib/reports/log-stdout-summary.integration.test.ts new file mode 100644 index 000000000..0e5f68278 --- /dev/null +++ b/packages/utils/src/lib/reports/log-stdout-summary.integration.test.ts @@ -0,0 +1,48 @@ +import { beforeAll, describe, expect, vi } from 'vitest'; +import { reportMock } from '@code-pushup/test-utils'; +import { ui } from '../logging'; +import { logStdoutSummary } from './log-stdout-summary'; +import { scoreReport } from './scoring'; +import { sortReport } from './sorting'; + +describe('logStdoutSummary', () => { + let logs: string[]; + beforeAll(() => { + logs = []; + // console.log is used inside the logger when in "normal" mode + vi.spyOn(console, 'log').mockImplementation(msg => { + logs = [...logs, msg]; + }); + // we want to see table and sticker logs in the final style ("raw" don't show borders etc so we use `console.log` here) + ui().switchMode('normal'); + }); + afterEach(() => { + logs = []; + }); + afterAll(() => { + ui().switchMode('raw'); + }); + + it('should contain all sections when using the fixture report', () => { + logStdoutSummary(sortReport(scoreReport(reportMock()))); + + const output = logs.join('\n'); + + expect(output).toContain('Categories'); + // removes all color codes from the output for snapshot readability + // eslint-disable-next-line no-control-regex + expect(output.replace(/\u001B\[\d+m/g, '')).toMatchSnapshot(); + }); + + it('should not contain category section when categories are empty', () => { + logStdoutSummary( + sortReport(scoreReport({ ...reportMock(), categories: [] })), + ); + const output = logs.join('\n'); + + expect(output).not.toContain('Categories'); + // removes all color codes from the output for snapshot readability + // eslint-disable-next-line no-control-regex + expect(output.replace(/\u001B\[\d+m/g, '')).toMatchSnapshot(); + }); +}); diff --git a/packages/utils/src/lib/reports/log-stdout-summary.ts b/packages/utils/src/lib/reports/log-stdout-summary.ts new file mode 100644 index 000000000..f016542c5 --- /dev/null +++ b/packages/utils/src/lib/reports/log-stdout-summary.ts @@ -0,0 +1,112 @@ +import chalk from 'chalk'; +import { AuditReport } from '@code-pushup/models'; +import { ui } from '../logging'; +import { + CODE_PUSHUP_DOMAIN, + FOOTER_PREFIX, + SCORE_COLOR_RANGE, + TERMINAL_WIDTH, + reportHeadlineText, + reportRawOverviewTableHeaders, +} from './constants'; +import { ScoredReport } from './types'; +import { countCategoryAudits, formatReportScore } from './utils'; + +function log(msg = ''): void { + ui().logger.log(msg); +} + +export function logStdoutSummary(report: ScoredReport): void { + const printCategories = report.categories.length > 0; + + log(reportToHeaderSection(report)); + log(); + logPlugins(report); + if (printCategories) { + logCategories(report); + } + log(`${FOOTER_PREFIX} ${CODE_PUSHUP_DOMAIN}`); + log(); +} + +function reportToHeaderSection(report: ScoredReport): string { + const { packageName, version } = report; + return `${chalk.bold(reportHeadlineText)} - ${packageName}@${version}`; +} + +function logPlugins(report: ScoredReport): void { + const { plugins } = report; + + plugins.forEach(plugin => { + const { title, audits } = plugin; + log(); + log(chalk.magentaBright.bold(`${title} audits`)); + log(); + audits.forEach((audit: AuditReport) => { + ui().row([ + { + text: applyScoreColor({ score: audit.score, text: '●' }), + width: 2, + padding: [0, 1, 0, 0], + }, + { + text: audit.title, + // eslint-disable-next-line no-magic-numbers + padding: [0, 3, 0, 0], + }, + { + text: chalk.cyanBright(audit.displayValue || `${audit.value}`), + width: 10, + padding: [0, 0, 0, 0], + }, + ]); + }); + log(); + }); +} + +function logCategories({ categories, plugins }: ScoredReport): void { + const hAlign = (idx: number) => (idx === 0 ? 'left' : 'right'); + const rows = categories.map(({ title, score, refs }) => [ + title, + applyScoreColor({ score }), + countCategoryAudits(refs, plugins), + ]); + const table = ui().table(); + // eslint-disable-next-line no-magic-numbers + table.columnWidths([TERMINAL_WIDTH - 9 - 10 - 4, 9, 10]); + table.head( + reportRawOverviewTableHeaders.map((heading, idx) => ({ + content: chalk.cyan(heading), + hAlign: hAlign(idx), + })), + ); + rows.forEach(row => + table.row( + row.map((content, idx) => ({ + content: content.toString(), + hAlign: hAlign(idx), + })), + ), + ); + + log(chalk.magentaBright.bold('Categories')); + log(); + table.render(); + log(); +} + +function applyScoreColor({ score, text }: { score: number; text?: string }) { + const formattedScore = text ?? formatReportScore(score); + const style = text ? chalk : chalk.bold; + + if (score >= SCORE_COLOR_RANGE.GREEN_MIN) { + return style.green(formattedScore); + } + + if (score >= SCORE_COLOR_RANGE.YELLOW_MIN) { + return style.yellow(formattedScore); + } + + return style.red(formattedScore); +} diff --git a/packages/utils/src/lib/verbose-utils.ts b/packages/utils/src/lib/verbose-utils.ts index e3055fef5..a0b42fbc3 100644 --- a/packages/utils/src/lib/verbose-utils.ts +++ b/packages/utils/src/lib/verbose-utils.ts @@ -1,7 +1,9 @@ +import { ui } from './logging'; + function getLogVerbose(verbose = false) { - return (...args: unknown[]) => { + return (msg: string) => { if (verbose) { - console.info(...args); + ui().logger.info(msg); } }; } diff --git a/packages/utils/src/lib/verbose-utils.unit.test.ts b/packages/utils/src/lib/verbose-utils.unit.test.ts index 6c74b1dad..2326f70ec 100644 --- a/packages/utils/src/lib/verbose-utils.unit.test.ts +++ b/packages/utils/src/lib/verbose-utils.unit.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { getLogMessages } from '@code-pushup/test-utils'; +import { ui } from './logging'; import { verboseUtils } from './verbose-utils'; describe('verbose-utils', () => { @@ -6,35 +8,32 @@ describe('verbose-utils', () => { const spy = vi.fn(); verboseUtils().exec(spy); expect(spy).not.toHaveBeenCalled(); - expect(console.info).not.toHaveBeenCalled(); }); it('exec should work no-verbose', () => { const spy = vi.fn(); verboseUtils(false).exec(spy); expect(spy).not.toHaveBeenCalled(); - expect(console.info).not.toHaveBeenCalled(); }); it('exec should work verbose', () => { const spy = vi.fn(); verboseUtils(true).exec(spy); expect(spy).toHaveBeenCalled(); - expect(console.info).not.toHaveBeenCalled(); }); it('logs should be off by default', () => { verboseUtils(false).log('42'); - expect(console.info).not.toHaveBeenCalled(); + expect(getLogMessages(ui().logger)).toHaveLength(0); }); - it('log should work no-verbose', () => { + it('should not print any logs when verbose is off', () => { verboseUtils(false).log('42'); - expect(console.info).not.toHaveBeenCalled(); + expect(getLogMessages(ui().logger)).toHaveLength(0); }); - it('log should work verbose', () => { + it('should log when verbose is on', () => { verboseUtils(true).log('42'); - expect(console.info).toHaveBeenCalledWith('42'); + expect(getLogMessages(ui().logger)[0]).toContain('42'); }); }); diff --git a/packages/utils/vite.config.integration.ts b/packages/utils/vite.config.integration.ts index 12142b182..4c991a6b1 100644 --- a/packages/utils/vite.config.integration.ts +++ b/packages/utils/vite.config.integration.ts @@ -21,6 +21,7 @@ export default defineConfig({ include: ['src/**/*.integration.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], globalSetup: ['../../global-setup.ts'], setupFiles: [ + '../../testing/test-setup/src/lib/cliui.mock.ts', '../../testing/test-setup/src/lib/console.mock.ts', '../../testing/test-setup/src/lib/reset.mocks.ts', ], diff --git a/packages/utils/vite.config.unit.ts b/packages/utils/vite.config.unit.ts index ebbf224fd..febef42e1 100644 --- a/packages/utils/vite.config.unit.ts +++ b/packages/utils/vite.config.unit.ts @@ -21,6 +21,7 @@ export default defineConfig({ include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], globalSetup: ['../../global-setup.ts'], setupFiles: [ + '../../testing/test-setup/src/lib/cliui.mock.ts', '../../testing/test-setup/src/lib/fs.mock.ts', '../../testing/test-setup/src/lib/console.mock.ts', '../../testing/test-setup/src/lib/reset.mocks.ts', diff --git a/testing/test-setup/src/lib/cliui.mock.ts b/testing/test-setup/src/lib/cliui.mock.ts new file mode 100644 index 000000000..3d3134dd2 --- /dev/null +++ b/testing/test-setup/src/lib/cliui.mock.ts @@ -0,0 +1,15 @@ +import { beforeAll, beforeEach, vi } from 'vitest'; + +beforeAll(async () => { + const utils: typeof import('@code-pushup/utils') = await vi.importActual( + '@code-pushup/utils', + ); + utils.ui().switchMode('raw'); +}); + +beforeEach(async () => { + const { ui }: typeof import('@code-pushup/utils') = await vi.importActual( + '@code-pushup/utils', + ); + ui().logger.flushLogs(); +}); diff --git a/testing/test-utils/src/index.ts b/testing/test-utils/src/index.ts index 4c0507cc4..fe9407153 100644 --- a/testing/test-utils/src/index.ts +++ b/testing/test-utils/src/index.ts @@ -1,6 +1,7 @@ export * from './lib/constants'; export * from './lib/utils/execute-process-helper.mock'; export * from './lib/utils/os-agnostic-paths'; +export * from './lib/utils/logging'; // static mocks export * from './lib/utils/commit.mock'; diff --git a/testing/test-utils/src/lib/utils/logging.ts b/testing/test-utils/src/lib/utils/logging.ts new file mode 100644 index 000000000..43ab1d6c3 --- /dev/null +++ b/testing/test-utils/src/lib/utils/logging.ts @@ -0,0 +1,8 @@ +import type { Logger } from '@poppinss/cliui'; + +export function getLogMessages(logger: Logger): string[] { + return logger + .getRenderer() + .getLogs() + .map(({ message }) => message); +}