diff --git a/e2e/cli-e2e/tests/__snapshots__/help.e2e.test.ts.snap b/e2e/cli-e2e/tests/__snapshots__/help.e2e.test.ts.snap index 32dc4aee7..6d0973eca 100644 --- a/e2e/cli-e2e/tests/__snapshots__/help.e2e.test.ts.snap +++ b/e2e/cli-e2e/tests/__snapshots__/help.e2e.test.ts.snap @@ -8,7 +8,7 @@ Commands: [default] code-pushup autorun Shortcut for running collect followed by upload - code-pushup collect Run Plugins and collect results + code-pushup collect Run plugins and collect results code-pushup upload Upload report results to the portal code-pushup history Collect reports for commit history code-pushup compare Compare 2 report files and create a diff file diff --git a/e2e/cli-e2e/tests/collect.e2e.test.ts b/e2e/cli-e2e/tests/collect.e2e.test.ts index 3799cd59f..e20db01e6 100644 --- a/e2e/cli-e2e/tests/collect.e2e.test.ts +++ b/e2e/cli-e2e/tests/collect.e2e.test.ts @@ -59,7 +59,7 @@ describe('CLI collect', () => { const md = await readTextFile(path.join(dummyOutputDir, 'report.md')); - expect(md).toContain('# Code PushUp Report'); + expect(md).toContain('# Code PushUp report'); expect(md).toContain(dummyPluginTitle); expect(md).toContain(dummyAuditTitle); }); @@ -112,7 +112,7 @@ describe('CLI collect', () => { expect(code).toBe(0); - expect(stdout).toContain('Code PushUp Report'); + expect(stdout).toContain('Code PushUp report'); expect(stdout).not.toContain('Generated reports'); expect(stdout).toContain(dummyPluginTitle); expect(stdout).toContain(dummyAuditTitle); diff --git a/e2e/cli-e2e/tests/print-config.e2e.test.ts b/e2e/cli-e2e/tests/print-config.e2e.test.ts index bf87be7dd..630d3bb2a 100644 --- a/e2e/cli-e2e/tests/print-config.e2e.test.ts +++ b/e2e/cli-e2e/tests/print-config.e2e.test.ts @@ -40,11 +40,12 @@ describe('CLI print-config', () => { it.each(extensions)( 'should load .%s config file with correct arguments', async ext => { - const { code, stdout } = await executeProcess({ + const { code } = await executeProcess({ command: 'npx', args: [ '@code-pushup/cli', 'print-config', + '--output=config.json', `--config=${configFilePath(ext)}`, '--tsconfig=tsconfig.base.json', '--persist.outputDir=output-dir', @@ -56,7 +57,11 @@ describe('CLI print-config', () => { expect(code).toBe(0); - expect(JSON.parse(stdout)).toEqual( + const output = await readFile( + path.join(testFileDummySetup, 'config.json'), + 'utf8', + ); + expect(JSON.parse(output)).toEqual( expect.objectContaining({ config: expect.stringContaining(`code-pushup.config.${ext}`), tsconfig: 'tsconfig.base.json', @@ -71,30 +76,4 @@ describe('CLI print-config', () => { ); }, ); - - it('should print config to output file', async () => { - const { code, stdout } = await executeProcess({ - command: 'npx', - args: ['@code-pushup/cli', 'print-config', '--output=config.json'], - cwd: testFileDummySetup, - }); - - expect(code).toBe(0); - - const output = await readFile( - path.join(testFileDummySetup, 'config.json'), - 'utf8', - ); - expect(JSON.parse(output)).toEqual( - expect.objectContaining({ - plugins: [ - expect.objectContaining({ - slug: 'dummy-plugin', - title: 'Dummy Plugin', - }), - ], - }), - ); - expect(stdout).not.toContain('dummy-plugin'); - }); }); diff --git a/packages/ci/mocks/fixtures/outputs/report-after.md b/packages/ci/mocks/fixtures/outputs/report-after.md index d2228a026..9b712423a 100644 --- a/packages/ci/mocks/fixtures/outputs/report-after.md +++ b/packages/ci/mocks/fixtures/outputs/report-after.md @@ -1,4 +1,4 @@ -# Code PushUp Report +# Code PushUp report ## 🛡️ Audits diff --git a/packages/ci/mocks/fixtures/outputs/report-before.md b/packages/ci/mocks/fixtures/outputs/report-before.md index 3de9a4f73..7ffe57585 100644 --- a/packages/ci/mocks/fixtures/outputs/report-before.md +++ b/packages/ci/mocks/fixtures/outputs/report-before.md @@ -1,4 +1,4 @@ -# Code PushUp Report +# Code PushUp report ## 🛡️ Audits diff --git a/packages/cli/README.md b/packages/cli/README.md index 5227524fe..7d7e264aa 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -331,9 +331,9 @@ Print the resolved configuration. In addition to the [Common Command Options](#common-command-options), the following options are recognized by the `print-config` command: -| Option | Required | Type | Description | -| -------------- | :------: | -------- | -------------------------------------------------------- | -| **`--output`** | no | `string` | Path to output file to print config (default is stdout). | +| Option | Required | Type | Description | +| -------------- | :------: | -------- | ------------------------------------ | +| **`--output`** | yes | `string` | Path to output file to print config. | #### `merge-diffs` command diff --git a/packages/cli/docs/custom-plugins.md b/packages/cli/docs/custom-plugins.md index 6847c4159..cff150db4 100644 --- a/packages/cli/docs/custom-plugins.md +++ b/packages/cli/docs/custom-plugins.md @@ -92,7 +92,7 @@ Execute the CLI with `npx code-pushup collect` and you should the following outp stdout of CLI for the above code (collapsed for brevity) ```sh -Code PushUp Report - @code-pushup/core@x.y.z +Code PushUp report My plugin audits ● My audit 0 @@ -240,7 +240,7 @@ Now we can execute the CLI with `npx code-pushup collect` and see a similar outp stdout of CLI for the above code (collapsed for brevity) ```sh -Code PushUp Report - @code-pushup/core@x.y.z +Code PushUp report File size plugin audits ● File size audit 2 files @@ -371,7 +371,7 @@ Now we can execute the CLI with `npx code-pushup collect` and see a similar outp stdout of CLI for the above code (collapsed for brevity) ```sh -Code PushUp Report - @code-pushup/core@x.y.z +Code PushUp report Chrome Lighthosue audits ● Largest Contentful Paint 0 @@ -656,7 +656,7 @@ Test the output by running `npx code-pushup collect`. stdout of basic lighthouse plugin (collapsed for brevity) ```sh -Code PushUp Report - @code-pushup/core@x.y.z +Code PushUp report Chrome Lighthouse audits ● Largest Contentful Paint 1,3 s diff --git a/packages/cli/src/lib/autorun/autorun-command.ts b/packages/cli/src/lib/autorun/autorun-command.ts index 0b3699504..4fc3bc505 100644 --- a/packages/cli/src/lib/autorun/autorun-command.ts +++ b/packages/cli/src/lib/autorun/autorun-command.ts @@ -1,4 +1,3 @@ -import ansis from 'ansis'; import type { ArgumentsCamelCase, CommandModule } from 'yargs'; import { type CollectOptions, @@ -7,12 +6,10 @@ import { upload, } from '@code-pushup/core'; import { logger } from '@code-pushup/utils'; -import { CLI_NAME } from '../constants.js'; import { - collectSuccessfulLog, - renderConfigureCategoriesHint, - renderIntegratePortalHint, - uploadSuccessfulLog, + printCliCommand, + renderCategoriesHint, + renderPortalHint, } from '../implementation/logging.js'; type AutorunOptions = CollectOptions & UploadOptions; @@ -23,8 +20,8 @@ export function yargsAutorunCommandObject() { command, describe: 'Shortcut for running collect followed by upload', handler: async (args: ArgumentsCamelCase) => { - logger.info(ansis.bold(CLI_NAME)); - logger.debug(`Running ${ansis.bold(command)} command`); + printCliCommand(command); + const options = args as unknown as AutorunOptions; // we need to ensure `json` is part of the formats as we want to upload @@ -39,20 +36,18 @@ export function yargsAutorunCommandObject() { }; await collectAndPersistReports(optionsWithFormat); - collectSuccessfulLog(); - if (!options.categories || options.categories.length === 0) { - renderConfigureCategoriesHint(); + if (!options.categories?.length) { + renderCategoriesHint(); + logger.newline(); } if (options.upload) { - const report = await upload(options); - if (report?.url) { - uploadSuccessfulLog(report.url); - } + await upload(options); } else { - logger.warn('Upload skipped because configuration is not set.'); - renderIntegratePortalHint(); + logger.warn('Upload skipped because Portal is not configured.'); + logger.newline(); + renderPortalHint(); } }, } satisfies CommandModule; diff --git a/packages/cli/src/lib/cli.ts b/packages/cli/src/lib/cli.ts index 0faa8b2d2..a3a069d01 100644 --- a/packages/cli/src/lib/cli.ts +++ b/packages/cli/src/lib/cli.ts @@ -1,12 +1,12 @@ import { commands } from './commands.js'; -import { CLI_NAME, CLI_SCRIPT_NAME } from './constants.js'; +import { CLI_DISPLAY_NAME, CLI_SCRIPT_NAME } from './constants.js'; import { middlewares } from './middlewares.js'; import { groups, options } from './options.js'; import { yargsCli } from './yargs-cli.js'; export const cli = (args: string[]) => yargsCli(args, { - usageMessage: CLI_NAME, + usageMessage: CLI_DISPLAY_NAME, scriptName: CLI_SCRIPT_NAME, options, groups, diff --git a/packages/cli/src/lib/collect/collect-command.ts b/packages/cli/src/lib/collect/collect-command.ts index 84dc6f4c1..ffe7ad005 100644 --- a/packages/cli/src/lib/collect/collect-command.ts +++ b/packages/cli/src/lib/collect/collect-command.ts @@ -1,63 +1,40 @@ -import ansis from 'ansis'; import type { ArgumentsCamelCase, CommandModule } from 'yargs'; import { type CollectAndPersistReportsOptions, collectAndPersistReports, } from '@code-pushup/core'; import { - formatAsciiLink, - formatAsciiSticker, - logger, -} from '@code-pushup/utils'; -import { CLI_NAME } from '../constants.js'; -import { - collectSuccessfulLog, - renderConfigureCategoriesHint, + printCliCommand, + renderCategoriesHint, + renderPortalHint, + renderUploadHint, } from '../implementation/logging.js'; export function yargsCollectCommandObject(): CommandModule { const command = 'collect'; return { command, - describe: 'Run Plugins and collect results', + describe: 'Run plugins and collect results', handler: async (args: ArgumentsCamelCase) => { + printCliCommand(command); + const options = args as unknown as CollectAndPersistReportsOptions; - logger.info(ansis.bold(CLI_NAME)); - logger.debug(`Running ${ansis.bold(command)} command`); await collectAndPersistReports(options); - collectSuccessfulLog(); - if (!options.categories || options.categories.length === 0) { - renderConfigureCategoriesHint(); + if (!options.categories?.length) { + renderCategoriesHint(); } - const { upload = {} } = args as unknown as Record< + const { upload } = args as unknown as Record< 'upload', object | undefined >; - if (Object.keys(upload).length === 0) { - renderUploadAutorunHint(); + if (upload) { + renderUploadHint(); + } else { + renderPortalHint(); } }, } satisfies CommandModule; } - -export function renderUploadAutorunHint(): void { - logger.info( - formatAsciiSticker([ - ansis.bold.gray('💡 Visualize your reports'), - '', - `${ansis.gray('❯')} npx code-pushup upload - ${ansis.gray( - 'Run upload to upload the created report to the server', - )}`, - ` ${formatAsciiLink( - 'https://github.com/code-pushup/cli/tree/main/packages/cli#upload-command', - )}`, - `${ansis.gray('❯')} npx code-pushup autorun - ${ansis.gray('Run collect & upload')}`, - ` ${formatAsciiLink( - 'https://github.com/code-pushup/cli/tree/main/packages/cli#autorun-command', - )}`, - ]), - ); -} diff --git a/packages/cli/src/lib/compare/compare-command.ts b/packages/cli/src/lib/compare/compare-command.ts index 5e5fb72a2..a6d5e69df 100644 --- a/packages/cli/src/lib/compare/compare-command.ts +++ b/packages/cli/src/lib/compare/compare-command.ts @@ -3,8 +3,8 @@ import type { CommandModule } from 'yargs'; import { type CompareOptions, compareReportFiles } from '@code-pushup/core'; import type { PersistConfig, UploadConfig } from '@code-pushup/models'; import { logger } from '@code-pushup/utils'; -import { CLI_NAME } from '../constants.js'; import { yargsCompareOptionsDefinition } from '../implementation/compare.options.js'; +import { printCliCommand } from '../implementation/logging.js'; export function yargsCompareCommandObject() { const command = 'compare'; @@ -13,8 +13,7 @@ export function yargsCompareCommandObject() { describe: 'Compare 2 report files and create a diff file', builder: yargsCompareOptionsDefinition(), handler: async (args: unknown) => { - logger.info(ansis.bold(CLI_NAME)); - logger.debug(`Running ${ansis.bold(command)} command`); + printCliCommand(command); const options = args as CompareOptions & { persist: Required; diff --git a/packages/cli/src/lib/constants.ts b/packages/cli/src/lib/constants.ts index 84b378b55..ca62d20b0 100644 --- a/packages/cli/src/lib/constants.ts +++ b/packages/cli/src/lib/constants.ts @@ -1,2 +1,2 @@ -export const CLI_NAME = 'Code PushUp CLI'; +export const CLI_DISPLAY_NAME = 'Code PushUp CLI'; export const CLI_SCRIPT_NAME = 'code-pushup'; diff --git a/packages/cli/src/lib/history/history-command.ts b/packages/cli/src/lib/history/history-command.ts index b53150a00..b8286faf4 100644 --- a/packages/cli/src/lib/history/history-command.ts +++ b/packages/cli/src/lib/history/history-command.ts @@ -1,4 +1,3 @@ -import ansis from 'ansis'; import type { CommandModule } from 'yargs'; import { type HistoryOptions, history } from '@code-pushup/core'; import { @@ -9,16 +8,15 @@ import { logger, safeCheckout, } from '@code-pushup/utils'; -import { CLI_NAME } from '../constants.js'; import { yargsFilterOptionsDefinition } from '../implementation/filter.options.js'; +import { printCliCommand } from '../implementation/logging.js'; import type { HistoryCliOptions } from './history.model.js'; import { yargsHistoryOptionsDefinition } from './history.options.js'; import { normalizeHashOptions } from './utils.js'; const command = 'history'; async function handler(args: unknown) { - logger.info(ansis.bold(CLI_NAME)); - logger.debug(`Running ${ansis.bold(command)} command`); + printCliCommand(command); const currentBranch = await getCurrentBranchOrTag(); const { targetBranch: rawTargetBranch, ...opt } = args as HistoryCliOptions & diff --git a/packages/cli/src/lib/implementation/core-config.middleware.int.test.ts b/packages/cli/src/lib/implementation/core-config.middleware.int.test.ts index 464b1dfc2..61e8fa0d5 100644 --- a/packages/cli/src/lib/implementation/core-config.middleware.int.test.ts +++ b/packages/cli/src/lib/implementation/core-config.middleware.int.test.ts @@ -40,7 +40,7 @@ describe('coreConfigMiddleware', () => { it('should throw with invalid config path', async () => { await expect( coreConfigMiddleware({ config: 'wrong/path/to/config', ...CLI_DEFAULTS }), - ).rejects.toThrow(/Provided path .* is not valid./); + ).rejects.toThrow(/File '.*' does not exist/); }); it('should load config which relies on provided --tsconfig', async () => { diff --git a/packages/cli/src/lib/implementation/core-config.middleware.ts b/packages/cli/src/lib/implementation/core-config.middleware.ts index ad2fda834..8b1a161ac 100644 --- a/packages/cli/src/lib/implementation/core-config.middleware.ts +++ b/packages/cli/src/lib/implementation/core-config.middleware.ts @@ -10,6 +10,7 @@ import { uploadConfigSchema, validate, } from '@code-pushup/models'; +import { logger, pluralizeToken } from '@code-pushup/utils'; import type { CoreConfigCliOptions } from './core-config.model.js'; import type { FilterOptions } from './filter.model.js'; import type { GlobalOptions } from './global.model.js'; @@ -47,40 +48,45 @@ export async function coreConfigMiddleware< cache: cliCache, ...remainingCliOptions } = processArgs; - // Search for possible configuration file extensions if path is not given - const importedRc = config - ? await readRcByPath(config, tsconfig) - : await autoloadRc(tsconfig); - const { - persist: rcPersist, - upload: rcUpload, - ...remainingRcConfig - } = importedRc; - const upload = - rcUpload == null && cliUpload == null - ? undefined - : validate(uploadConfigSchema, { ...rcUpload, ...cliUpload }); - return { - ...(config != null && { config }), - cache: normalizeCache(cliCache), - persist: buildPersistConfig(cliPersist, rcPersist), - ...(upload != null && { upload }), - ...remainingRcConfig, - ...remainingCliOptions, - }; + return logger.group('Loading configuration', async () => { + // Search for possible configuration file extensions if path is not given + const importedRc = config + ? await readRcByPath(config, tsconfig) + : await autoloadRc(tsconfig); + const { + persist: rcPersist, + upload: rcUpload, + ...remainingRcConfig + } = importedRc; + const upload = + rcUpload == null && cliUpload == null + ? undefined + : validate(uploadConfigSchema, { ...rcUpload, ...cliUpload }); + + const result: GlobalOptions & CoreConfig & FilterOptions = { + ...(config != null && { config }), + cache: normalizeCache(cliCache), + persist: buildPersistConfig(cliPersist, rcPersist), + ...(upload != null && { upload }), + ...remainingRcConfig, + ...remainingCliOptions, + }; + + return { + message: `Parsed config: ${summarizeConfig(result)}`, + result, + }; + }); } -export const normalizeBooleanWithNegation = ( - propertyName: T, - cliOptions?: Record, - rcOptions?: Record, -): boolean => - propertyName in (cliOptions ?? {}) - ? (cliOptions?.[propertyName] as boolean) - : `no-${propertyName}` in (cliOptions ?? {}) - ? false - : ((rcOptions?.[propertyName] as boolean) ?? true); +function summarizeConfig(config: CoreConfig): string { + return [ + pluralizeToken('plugin', config.plugins.length), + pluralizeToken('category', config.categories?.length ?? 0), + `upload ${config.upload ? 'enabled' : 'disabled'}`, + ].join(', '); +} export const normalizeCache = (cache?: CacheConfig): CacheConfigObject => { if (cache == null) { diff --git a/packages/cli/src/lib/implementation/core-config.middleware.unit.test.ts b/packages/cli/src/lib/implementation/core-config.middleware.unit.test.ts index 8c27e3abd..1c36bed82 100644 --- a/packages/cli/src/lib/implementation/core-config.middleware.unit.test.ts +++ b/packages/cli/src/lib/implementation/core-config.middleware.unit.test.ts @@ -2,12 +2,11 @@ import { describe, expect, vi } from 'vitest'; import { autoloadRc, readRcByPath } from '@code-pushup/core'; import { coreConfigMiddleware, - normalizeBooleanWithNegation, normalizeFormats, } from './core-config.middleware.js'; import type { CoreConfigCliOptions } from './core-config.model.js'; import type { FilterOptions } from './filter.model.js'; -import type { GeneralCliOptions } from './global.model.js'; +import type { GlobalOptions } from './global.model.js'; vi.mock('@code-pushup/core', async () => { const { CORE_CONFIG_MOCK }: typeof import('@code-pushup/test-utils') = @@ -20,36 +19,6 @@ vi.mock('@code-pushup/core', async () => { }; }); -describe('normalizeBooleanWithNegation', () => { - it('should return true when CLI property is true', () => { - expect(normalizeBooleanWithNegation('report', { report: true }, {})).toBe( - true, - ); - }); - - it('should return false when CLI property is false', () => { - expect(normalizeBooleanWithNegation('report', { report: false }, {})).toBe( - false, - ); - }); - - it('should return false when no-property exists in CLI persist', () => { - expect( - normalizeBooleanWithNegation('report', { 'no-report': true }, {}), - ).toBe(false); - }); - - it('should fallback to RC persist when no CLI property', () => { - expect(normalizeBooleanWithNegation('report', {}, { report: false })).toBe( - false, - ); - }); - - it('should return default true when no property anywhere', () => { - expect(normalizeBooleanWithNegation('report', {}, {})).toBe(true); - }); -}); - describe('normalizeFormats', () => { it('should forward valid formats', () => { expect(normalizeFormats(['json', 'md'])).toEqual(['json', 'md']); @@ -71,7 +40,7 @@ describe('normalizeFormats', () => { describe('coreConfigMiddleware', () => { it('should attempt to load code-pushup.config.(ts|mjs|js) by default', async () => { await coreConfigMiddleware( - {} as GeneralCliOptions & CoreConfigCliOptions & FilterOptions, + {} as GlobalOptions & CoreConfigCliOptions & FilterOptions, ); expect(autoloadRc).toHaveBeenCalled(); }); @@ -79,7 +48,7 @@ describe('coreConfigMiddleware', () => { it('should directly attempt to load passed config', async () => { await coreConfigMiddleware({ config: 'cli/custom-config.mjs', - } as GeneralCliOptions & CoreConfigCliOptions & FilterOptions); + } as GlobalOptions & CoreConfigCliOptions & FilterOptions); expect(autoloadRc).not.toHaveBeenCalled(); expect(readRcByPath).toHaveBeenCalledWith( 'cli/custom-config.mjs', @@ -90,7 +59,7 @@ describe('coreConfigMiddleware', () => { it('should forward --tsconfig option to config autoload', async () => { await coreConfigMiddleware({ tsconfig: 'tsconfig.base.json', - } as GeneralCliOptions & CoreConfigCliOptions & FilterOptions); + } as GlobalOptions & CoreConfigCliOptions & FilterOptions); expect(autoloadRc).toHaveBeenCalledWith('tsconfig.base.json'); }); @@ -98,7 +67,7 @@ describe('coreConfigMiddleware', () => { await coreConfigMiddleware({ config: 'apps/website/code-pushup.config.ts', tsconfig: 'apps/website/tsconfig.json', - } as GeneralCliOptions & CoreConfigCliOptions & FilterOptions); + } as GlobalOptions & CoreConfigCliOptions & FilterOptions); expect(readRcByPath).toHaveBeenCalledWith( 'apps/website/code-pushup.config.ts', 'apps/website/tsconfig.json', diff --git a/packages/cli/src/lib/implementation/log-intro-middleware.unit.test.ts b/packages/cli/src/lib/implementation/log-intro-middleware.unit.test.ts new file mode 100644 index 000000000..b5bff8449 --- /dev/null +++ b/packages/cli/src/lib/implementation/log-intro-middleware.unit.test.ts @@ -0,0 +1,17 @@ +import type { ArgumentsCamelCase } from 'yargs'; +import { logger } from '@code-pushup/utils'; +import { logIntroMiddleware } from './log-intro.middleware'; + +describe('logIntroMiddleware', () => { + it('should print logo, name and version', () => { + logIntroMiddleware({ $0: 'code-pushup', _: ['collect'] }); + expect(logger.info).toHaveBeenCalledWith( + expect.stringMatching(/<✓> Code PushUp CLI v\d+\.\d+\.\d+/), + ); + }); + + it('should not change arguments', () => { + const args: ArgumentsCamelCase = { $0: 'code-pushup', _: ['collect'] }; + expect(logIntroMiddleware(args)).toBe(args); + }); +}); diff --git a/packages/cli/src/lib/implementation/log-intro.middleware.ts b/packages/cli/src/lib/implementation/log-intro.middleware.ts new file mode 100644 index 000000000..758239a5e --- /dev/null +++ b/packages/cli/src/lib/implementation/log-intro.middleware.ts @@ -0,0 +1,16 @@ +import ansis from 'ansis'; +import type { ArgumentsCamelCase } from 'yargs'; +import { CODE_PUSHUP_UNICODE_LOGO, logger } from '@code-pushup/utils'; +import { CLI_DISPLAY_NAME } from '../constants.js'; +import { getVersion } from './version.js'; + +export function logIntroMiddleware( + args: ArgumentsCamelCase, +): ArgumentsCamelCase { + logger.info( + ansis.bold.blue( + `${CODE_PUSHUP_UNICODE_LOGO} ${CLI_DISPLAY_NAME} v${getVersion()}`, + ), + ); + return args; +} diff --git a/packages/cli/src/lib/implementation/logging.ts b/packages/cli/src/lib/implementation/logging.ts index 3add53b11..b14f8d097 100644 --- a/packages/cli/src/lib/implementation/logging.ts +++ b/packages/cli/src/lib/implementation/logging.ts @@ -5,40 +5,39 @@ import { logger, } from '@code-pushup/utils'; -export function renderConfigureCategoriesHint(): void { - logger.debug( - `💡 Configure categories to see the scores in an overview table. See: ${formatAsciiLink( - 'https://github.com/code-pushup/cli/blob/main/packages/cli/README.md', - )}`, - { force: true }, - ); +export function printCliCommand(command: string): void { + logger.debug(`Running ${ansis.bold(command)} command`); } -export function uploadSuccessfulLog(url: string): void { - logger.info(ansis.green('Upload successful!')); - logger.info(formatAsciiLink(url)); + +export function renderCategoriesHint(): void { + logger.info( + formatAsciiSticker([ + ansis.bold.gray('💡 Configure categories'), + '', + ansis.gray('❯ Aggregate audit scores to get a high-level overview'), + `${ansis.gray('❯')} ${formatAsciiLink('https://www.npmjs.com/package/@code-pushup/cli')}`, + ]), + ); } -export function collectSuccessfulLog(): void { - logger.info(ansis.green('Collecting report successful!')); +export function renderPortalHint(): void { + logger.info( + formatAsciiSticker([ + ansis.bold.gray('💡 Upload report to Portal'), + '', + ansis.gray('❯ Visualize reports in an interactive UI'), + ansis.gray('❯ Track long-term progress via reports history'), + `${ansis.gray('❯')} ${formatAsciiLink('https://code-pushup.dev/')}`, + ]), + ); } -export function renderIntegratePortalHint(): void { +export function renderUploadHint(): void { logger.info( formatAsciiSticker([ - ansis.bold.gray('💡 Integrate the portal'), + ansis.bold.gray('💡 Upload report to Portal'), '', - `${ansis.gray('❯')} Upload a report to the server - ${ansis.gray( - 'npx code-pushup upload', - )}`, - ` ${formatAsciiLink( - 'https://github.com/code-pushup/cli/tree/main/packages/cli#upload-command', - )}`, - `${ansis.gray('❯')} ${ansis.gray('Portal Integration')} - ${formatAsciiLink( - 'https://github.com/code-pushup/cli/blob/main/packages/cli/README.md#portal-integration', - )}`, - `${ansis.gray('❯')} ${ansis.gray('Upload Command')} - ${formatAsciiLink( - 'https://github.com/code-pushup/cli/blob/main/packages/cli/README.md#portal-integration', - )}`, + `${ansis.gray('❯')} npx code-pushup upload`, ]), ); } diff --git a/packages/cli/src/lib/implementation/print-config.model.ts b/packages/cli/src/lib/implementation/print-config.model.ts index c8ec620dd..09d0cc1b8 100644 --- a/packages/cli/src/lib/implementation/print-config.model.ts +++ b/packages/cli/src/lib/implementation/print-config.model.ts @@ -1,3 +1,3 @@ export type PrintConfigOptions = { - output?: string; + output: string; }; diff --git a/packages/cli/src/lib/implementation/print-config.options.ts b/packages/cli/src/lib/implementation/print-config.options.ts index a38dd4579..b720b346e 100644 --- a/packages/cli/src/lib/implementation/print-config.options.ts +++ b/packages/cli/src/lib/implementation/print-config.options.ts @@ -7,8 +7,9 @@ export function yargsPrintConfigOptionsDefinition(): Record< > { return { output: { - describe: 'Output file path to use instead of stdout', + describe: 'Output file path for resolved JSON config', type: 'string', + demandOption: true, }, }; } diff --git a/packages/cli/src/lib/implementation/version.ts b/packages/cli/src/lib/implementation/version.ts new file mode 100644 index 000000000..4b01b1985 --- /dev/null +++ b/packages/cli/src/lib/implementation/version.ts @@ -0,0 +1,8 @@ +import { createRequire } from 'node:module'; + +export function getVersion(): string { + const packageJson = createRequire(import.meta.url)( + '../../../package.json', + ) as typeof import('../../../package.json'); + return packageJson.version; +} diff --git a/packages/cli/src/lib/merge-diffs/merge-diffs-command.ts b/packages/cli/src/lib/merge-diffs/merge-diffs-command.ts index 77dafae87..d12b5015d 100644 --- a/packages/cli/src/lib/merge-diffs/merge-diffs-command.ts +++ b/packages/cli/src/lib/merge-diffs/merge-diffs-command.ts @@ -3,7 +3,7 @@ import type { CommandModule } from 'yargs'; import { mergeDiffs } from '@code-pushup/core'; import type { PersistConfig } from '@code-pushup/models'; import { logger } from '@code-pushup/utils'; -import { CLI_NAME } from '../constants.js'; +import { printCliCommand } from '../implementation/logging.js'; import type { MergeDiffsOptions } from '../implementation/merge-diffs.model.js'; import { yargsMergeDiffsOptionsDefinition } from '../implementation/merge-diffs.options.js'; @@ -14,8 +14,7 @@ export function yargsMergeDiffsCommandObject() { describe: 'Combine many report diffs into a single diff file', builder: yargsMergeDiffsOptionsDefinition(), handler: async (args: unknown) => { - logger.info(ansis.bold(CLI_NAME)); - logger.debug(`Running ${ansis.bold(command)} command`); + printCliCommand(command); const options = args as MergeDiffsOptions & { persist: Required; diff --git a/packages/cli/src/lib/middlewares.ts b/packages/cli/src/lib/middlewares.ts index d40df4325..cdf8245ae 100644 --- a/packages/cli/src/lib/middlewares.ts +++ b/packages/cli/src/lib/middlewares.ts @@ -1,11 +1,16 @@ import type { MiddlewareFunction } from 'yargs'; import { coreConfigMiddleware } from './implementation/core-config.middleware.js'; import { filterMiddleware } from './implementation/filter.middleware.js'; +import { logIntroMiddleware } from './implementation/log-intro.middleware.js'; import { setVerboseMiddleware } from './implementation/set-verbose.middleware.js'; export const middlewares = [ { - middlewareFunction: setVerboseMiddleware as unknown as MiddlewareFunction, + middlewareFunction: logIntroMiddleware as unknown as MiddlewareFunction, + applyBeforeValidation: true, + }, + { + middlewareFunction: setVerboseMiddleware as MiddlewareFunction, applyBeforeValidation: false, }, { 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 61854d3e6..853d65a5a 100644 --- a/packages/cli/src/lib/print-config/print-config-command.ts +++ b/packages/cli/src/lib/print-config/print-config-command.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import type { CommandModule } from 'yargs'; import { logger } from '@code-pushup/utils'; import { filterKebabCaseKeys } from '../implementation/global.utils.js'; +import { printCliCommand } from '../implementation/logging.js'; import type { PrintConfigOptions } from '../implementation/print-config.model.js'; import { yargsPrintConfigOptionsDefinition } from '../implementation/print-config.options.js'; @@ -14,6 +15,8 @@ export function yargsPrintConfigCommandObject() { describe: 'Print config', builder: yargsPrintConfigOptionsDefinition(), handler: async yargsArgs => { + printCliCommand(command); + // it is important to filter out kebab case keys // because yargs duplicates options in camel case and kebab case const { _, $0, ...args } = filterKebabCaseKeys(yargsArgs); @@ -22,13 +25,9 @@ export function yargsPrintConfigCommandObject() { const content = JSON.stringify(config, null, 2); - if (output) { - await mkdir(path.dirname(output), { recursive: true }); - await writeFile(output, content); - logger.info(`Config printed to file ${ansis.bold(output)}`); - } else { - logger.info(content); - } + await mkdir(path.dirname(output), { recursive: true }); + await writeFile(output, content); + logger.info(`Config printed to file ${ansis.bold(output)}`); }, } satisfies CommandModule; } 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 83da94a1b..ed50001fc 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 @@ -19,17 +19,6 @@ vi.mock('@code-pushup/core', async () => { }); describe('print-config-command', () => { - it('should log config to stdout by default', async () => { - await yargsCli(['print-config'], { - ...DEFAULT_CLI_CONFIGURATION, - commands: [yargsPrintConfigCommandObject()], - }).parseAsync(); - - expect(logger.info).toHaveBeenCalledWith( - expect.stringContaining('"plugins": ['), - ); - }); - it('should write config to file if output option is given', async () => { const outputPath = path.join(MEMFS_VOLUME, 'config.json'); await yargsCli(['print-config', `--output=${outputPath}`], { @@ -40,32 +29,30 @@ describe('print-config-command', () => { await expect(readFile(outputPath, 'utf8')).resolves.toContain( '"plugins": [', ); - expect(logger.info).not.toHaveBeenCalledWith( - expect.stringContaining('"plugins": ['), - ); expect(logger.info).toHaveBeenCalledWith( `Config printed to file ${ansis.bold(outputPath)}`, ); }); it('should filter out meta arguments and kebab duplicates', async () => { - await yargsCli(['print-config', '--persist.outputDir=destinationDir'], { - ...DEFAULT_CLI_CONFIGURATION, - commands: [yargsPrintConfigCommandObject()], - }).parseAsync(); - - expect(logger.info).not.toHaveBeenCalledWith( - expect.stringContaining('"$0":'), - ); - expect(logger.info).not.toHaveBeenCalledWith( - expect.stringContaining('"_":'), - ); - - expect(logger.info).toHaveBeenCalledWith( - expect.stringContaining('"outputDir": "destinationDir"'), - ); - expect(logger.info).not.toHaveBeenCalledWith( - expect.stringContaining('"output-dir":'), - ); + const outputPath = path.join(MEMFS_VOLUME, 'config.json'); + await yargsCli( + [ + 'print-config', + `--output=${outputPath}`, + '--persist.outputDir=destinationDir', + ], + { + ...DEFAULT_CLI_CONFIGURATION, + commands: [yargsPrintConfigCommandObject()], + }, + ).parseAsync(); + const output = await readFile(outputPath, 'utf8'); + + expect(output).not.toContain('"$0":'); + expect(output).not.toContain('"_":'); + + expect(output).toContain('"outputDir": "destinationDir"'); + expect(output).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 8d4318cfa..17799c7f8 100644 --- a/packages/cli/src/lib/upload/upload-command.ts +++ b/packages/cli/src/lib/upload/upload-command.ts @@ -1,11 +1,9 @@ -import ansis from 'ansis'; import type { ArgumentsCamelCase, CommandModule } from 'yargs'; import { type UploadOptions, upload } from '@code-pushup/core'; import { logger } from '@code-pushup/utils'; -import { CLI_NAME } from '../constants.js'; import { - renderIntegratePortalHint, - uploadSuccessfulLog, + printCliCommand, + renderPortalHint, } from '../implementation/logging.js'; export function yargsUploadCommandObject() { @@ -14,18 +12,16 @@ export function yargsUploadCommandObject() { command, describe: 'Upload report results to the portal', handler: async (args: ArgumentsCamelCase) => { - logger.info(ansis.bold(CLI_NAME)); - logger.debug(`Running ${ansis.bold(command)} command`); + printCliCommand(command); const options = args as unknown as UploadOptions; if (options.upload == null) { - renderIntegratePortalHint(); - throw new Error('Upload configuration not set'); - } - const report = await upload(options); - if (report?.url) { - uploadSuccessfulLog(report.url); + logger.newline(); + renderPortalHint(); + logger.newline(); + throw new Error('Upload to Portal is missing configuration'); } + await upload(options); }, } satisfies CommandModule; } diff --git a/packages/cli/src/lib/yargs-cli.ts b/packages/cli/src/lib/yargs-cli.ts index c6868d579..59d0391d0 100644 --- a/packages/cli/src/lib/yargs-cli.ts +++ b/packages/cli/src/lib/yargs-cli.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines-per-function */ import ansis from 'ansis'; -import { createRequire } from 'node:module'; import yargs, { type Argv, type CommandModule, @@ -22,6 +21,7 @@ import { titleStyle, } from './implementation/formatting.js'; import { logErrorBeforeThrow } from './implementation/global.utils.js'; +import { getVersion } from './implementation/version.js'; export const yargsDecorator = { 'Commands:': `${ansis.green('Commands')}:`, @@ -54,7 +54,7 @@ export function yargsCli( groups?: { [key: string]: string[] }; examples?: [string, string][]; middlewares?: { - middlewareFunction: unknown; + middlewareFunction: MiddlewareFunction; applyBeforeValidation?: boolean; }[]; noExitProcess?: boolean; @@ -68,10 +68,6 @@ export function yargsCli( const examples = cfg.examples ?? []; const cli = yargs(argv); - const packageJson = createRequire(import.meta.url)( - '../../package.json', - ) as typeof import('../../package.json'); - // setup yargs cli .updateLocale(yargsDecorator) @@ -80,7 +76,7 @@ export function yargsCli( .help('help', descriptionStyle('Show help')) .alias('h', 'help') .showHelpOnFail(false) - .version('version', ansis.dim('Show version'), packageJson.version) + .version('version', ansis.dim('Show version'), getVersion()) .check(args => { const persist = args['persist'] as PersistConfig | undefined; return persist == null || validatePersistFormat(persist); @@ -116,7 +112,7 @@ export function yargsCli( // add middlewares middlewares.forEach(({ middlewareFunction, applyBeforeValidation }) => { cli.middleware( - logErrorBeforeThrow(middlewareFunction as MiddlewareFunction), + logErrorBeforeThrow(middlewareFunction), applyBeforeValidation, ); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 52883e901..3bf7dc3a4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,11 +24,7 @@ export { executePlugins, } from './lib/implementation/execute-plugin.js'; export { persistReport } from './lib/implementation/persist.js'; -export { - autoloadRc, - ConfigPathError, - readRcByPath, -} from './lib/implementation/read-rc-file.js'; +export { autoloadRc, readRcByPath } from './lib/implementation/read-rc-file.js'; export { AuditOutputsMissingAuditError } from './lib/implementation/runner.js'; export { mergeDiffs } from './lib/merge-diffs.js'; export { upload, type UploadOptions } from './lib/upload.js'; diff --git a/packages/core/src/lib/collect-and-persist.ts b/packages/core/src/lib/collect-and-persist.ts index f0bb16ff5..22c6fbe51 100644 --- a/packages/core/src/lib/collect-and-persist.ts +++ b/packages/core/src/lib/collect-and-persist.ts @@ -1,9 +1,7 @@ -import { - type CacheConfigObject, - type CoreConfig, - type PersistConfig, - pluginReportSchema, - validate, +import type { + CacheConfigObject, + CoreConfig, + PersistConfig, } from '@code-pushup/models'; import { logStdoutSummary, @@ -12,10 +10,7 @@ import { sortReport, } from '@code-pushup/utils'; import { collect } from './implementation/collect.js'; -import { - logPersistedResults, - persistReport, -} from './implementation/persist.js'; +import { logPersistedReport, persistReport } from './implementation/persist.js'; export type CollectAndPersistReportsOptions = Pick< CoreConfig, @@ -36,22 +31,20 @@ export async function collectAndPersistReports( const { skipReports = false, ...persistOptions } = persist ?? {}; if (skipReports) { - logger.info('Skipping saving reports as `persist.skipReports` is true'); + logger.info('Skipped saving report as persist.skipReports flag is set'); } else { - const persistResults = await persistReport( + const reportFiles = await persistReport( reportResult, sortedScoredReport, persistOptions, ); - logPersistedResults(persistResults); + logPersistedReport(reportFiles); } // terminal output + logger.newline(); + logger.newline(); logStdoutSummary(sortedScoredReport); - - // validate report and throw if invalid - reportResult.plugins.forEach(plugin => { - // Running checks after persisting helps while debugging as you can check the invalid output after the error is thrown - validate(pluginReportSchema, plugin); - }); + logger.newline(); + logger.newline(); } diff --git a/packages/core/src/lib/collect-and-persist.unit.test.ts b/packages/core/src/lib/collect-and-persist.unit.test.ts index ffd3a5975..6e034abed 100644 --- a/packages/core/src/lib/collect-and-persist.unit.test.ts +++ b/packages/core/src/lib/collect-and-persist.unit.test.ts @@ -15,10 +15,7 @@ import { collectAndPersistReports, } from './collect-and-persist.js'; import { collect } from './implementation/collect.js'; -import { - logPersistedResults, - persistReport, -} from './implementation/persist.js'; +import { logPersistedReport, persistReport } from './implementation/persist.js'; vi.mock('./implementation/collect', () => ({ collect: vi.fn().mockResolvedValue(MINIMAL_REPORT_MOCK), @@ -26,7 +23,7 @@ vi.mock('./implementation/collect', () => ({ vi.mock('./implementation/persist', () => ({ persistReport: vi.fn(), - logPersistedResults: vi.fn(), + logPersistedReport: vi.fn(), })); describe('collectAndPersistReports', () => { @@ -60,7 +57,7 @@ describe('collectAndPersistReports', () => { >(MINIMAL_REPORT_MOCK, sortedScoredReport, config.persist); expect(logStdoutSummary).toHaveBeenCalledWith(sortedScoredReport); - expect(logPersistedResults).toHaveBeenCalled(); + expect(logPersistedReport).toHaveBeenCalled(); }); it('should call collect and not persistReport if skipReports options is true', async () => { @@ -81,7 +78,7 @@ describe('collectAndPersistReports', () => { expect(collect).toHaveBeenCalledWith(verboseConfig); expect(persistReport).not.toHaveBeenCalled(); - expect(logPersistedResults).not.toHaveBeenCalled(); + expect(logPersistedReport).not.toHaveBeenCalled(); expect(logStdoutSummary).toHaveBeenCalledWith(sortedScoredReport); }); diff --git a/packages/core/src/lib/history.ts b/packages/core/src/lib/history.ts index d8c34d92e..ecc4879a7 100644 --- a/packages/core/src/lib/history.ts +++ b/packages/core/src/lib/history.ts @@ -5,6 +5,7 @@ import type { UploadConfig, } from '@code-pushup/models'; import { + type WithRequired, getCurrentBranchOrTag, logger, safeCheckout, @@ -55,7 +56,7 @@ export async function history( if (skipUploads) { logger.info('Upload is skipped because skipUploads is set to true.'); } else { - if (currentConfig.upload) { + if (hasUpload(currentConfig)) { await upload(currentConfig); } else { logger.info('Upload is skipped because upload config is undefined.'); @@ -70,3 +71,9 @@ export async function history( return reports; } + +function hasUpload( + config: HistoryOptions, +): config is WithRequired { + return config.upload != null; +} diff --git a/packages/core/src/lib/implementation/collect.ts b/packages/core/src/lib/implementation/collect.ts index 5c7a97e73..1689be2c7 100644 --- a/packages/core/src/lib/implementation/collect.ts +++ b/packages/core/src/lib/implementation/collect.ts @@ -1,3 +1,4 @@ +import ansis from 'ansis'; import { createRequire } from 'node:module'; import type { CacheConfigObject, @@ -5,7 +6,12 @@ import type { PersistConfig, Report, } from '@code-pushup/models'; -import { calcDuration, getLatestCommit } from '@code-pushup/utils'; +import { + calcDuration, + getLatestCommit, + logger, + pluralizeToken, +} from '@code-pushup/utils'; import { executePlugins } from './execute-plugin.js'; export type CollectOptions = Pick & { @@ -19,13 +25,26 @@ export type CollectOptions = Pick & { */ export async function collect(options: CollectOptions): Promise { const { plugins, categories, persist = {}, cache } = options; + const date = new Date().toISOString(); const start = performance.now(); - const commit = await getLatestCommit(); - const pluginOutputs = await executePlugins({ plugins, persist, cache }); const packageJson = createRequire(import.meta.url)( '../../../package.json', ) as typeof import('../../../package.json'); + + const commit = await getLatestCommit(); + logger.debug( + commit + ? `Found latest commit ${commit.hash} ("${commit.message}" by ${commit.author})` + : 'Latest commit not found', + ); + + logger.info( + `Collecting report from ${pluralizeToken('plugin', plugins.length)} ...`, + ); + const pluginOutputs = await executePlugins({ plugins, persist, cache }); + logger.info(ansis.green('Collected report ✓')); + return { commit, packageName: packageJson.name, diff --git a/packages/core/src/lib/implementation/persist.ts b/packages/core/src/lib/implementation/persist.ts index 36041fd37..0c4db2145 100644 --- a/packages/core/src/lib/implementation/persist.ts +++ b/packages/core/src/lib/implementation/persist.ts @@ -1,24 +1,30 @@ import ansis from 'ansis'; import { mkdir, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; import type { Format, PersistConfig, Report } from '@code-pushup/models'; import { - type MultipleFileResults, type ScoredReport, createReportPath, directoryExists, + formatBytes, generateMdReport, - logMultipleFileResults, + logger, stringifyError, } from '@code-pushup/utils'; +type FileSize = { + file: string; + size: number; +}; + export async function persistReport( report: Report, sortedScoredReport: ScoredReport, options: Required>, -): Promise { +): Promise { const { outputDir, filename, format } = options; - // collect physical format outputs + // format report const results = format.map( (reportType): { format: Format; content: string } => { switch (reportType) { @@ -47,7 +53,7 @@ export async function persistReport( } // write relevant format outputs to file system - return Promise.allSettled( + return Promise.all( results.map(result => persistResult( createReportPath({ outputDir, filename, format: result.format }), @@ -57,20 +63,25 @@ export async function persistReport( ); } -function persistResult(reportPath: string, content: string) { +function persistResult(reportPath: string, content: string): Promise { return ( writeFile(reportPath, content) // return reportPath instead of void .then(() => stat(reportPath)) - .then(stats => [reportPath, stats.size] as const) + .then((stats): FileSize => ({ file: reportPath, size: stats.size })) .catch((error: unknown) => { throw new Error( - `Failed to persist report in ${ansis.bold(reportPath)} - ${stringifyError(error)}`, + `Failed to save report in ${ansis.bold(reportPath)} - ${stringifyError(error)}`, ); }) ); } -export function logPersistedResults(persistResults: MultipleFileResults) { - logMultipleFileResults(persistResults, 'Generated reports'); +export function logPersistedReport(reportFiles: FileSize[]) { + logger.info(`Persisted report to file system:`); + reportFiles.forEach(({ file, size }) => { + const name = ansis.bold(path.relative(process.cwd(), file)); + const suffix = ansis.gray(`(${formatBytes(size)})`); + logger.info(`• ${name} ${suffix}`); + }); } diff --git a/packages/core/src/lib/implementation/persist.unit.test.ts b/packages/core/src/lib/implementation/persist.unit.test.ts index bcb10158c..1c3e69bdd 100644 --- a/packages/core/src/lib/implementation/persist.unit.test.ts +++ b/packages/core/src/lib/implementation/persist.unit.test.ts @@ -1,3 +1,4 @@ +import ansis from 'ansis'; import { vol } from 'memfs'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; @@ -9,7 +10,7 @@ import { REPORT_MOCK, } from '@code-pushup/test-utils'; import { logger, scoreReport, sortReport } from '@code-pushup/utils'; -import { logPersistedResults, persistReport } from './persist.js'; +import { logPersistedReport, persistReport } from './persist.js'; describe('persistReport', () => { beforeEach(() => { @@ -51,7 +52,7 @@ describe('persistReport', () => { path.join(MEMFS_VOLUME, 'report.md'), 'utf8', ); - expect(mdReport).toContain('Code PushUp Report'); + expect(mdReport).toContain('Code PushUp report'); await expect(() => readFile(path.join(MEMFS_VOLUME, 'report.json'), 'utf8'), @@ -70,7 +71,7 @@ describe('persistReport', () => { path.join(MEMFS_VOLUME, 'report.md'), 'utf8', ); - expect(mdReport).toContain('Code PushUp Report'); + expect(mdReport).toContain('Code PushUp report'); expect(mdReport).toContainMarkdownTableRow([ '🏷 Category', '⭐ Score', @@ -89,59 +90,30 @@ describe('persistReport', () => { }); }); -describe('logPersistedResults', () => { +describe('logPersistedReport', () => { it('should log report sizes correctly`', () => { - logPersistedResults([{ status: 'fulfilled', value: ['out.json', 10_000] }]); - expect(logger.debug).toHaveBeenNthCalledWith( - 1, - expect.stringContaining('Generated reports successfully: '), - ); - expect(logger.debug).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('9.77 kB'), - ); - expect(logger.debug).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('out.json'), - ); - }); - - it('should log fails correctly`', () => { - logPersistedResults([{ status: 'rejected', reason: 'fail' }]); - expect(logger.warn).toHaveBeenNthCalledWith( - 1, - 'Generated reports failed: ', - ); - expect(logger.warn).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('fail'), - ); - }); + let output = ''; + vi.spyOn(logger, 'info').mockImplementation(msg => { + output += `${msg}\n`; + }); - it('should log report sizes and fails correctly`', () => { - logPersistedResults([ - { status: 'fulfilled', value: ['out.json', 10_000] }, - { status: 'rejected', reason: 'fail' }, + logPersistedReport([ + { + file: path.join('.code-pushup', 'report.json'), + size: 2 * Math.pow(2, 20), + }, + { + file: path.join('.code-pushup', 'report.md'), + size: 3 * Math.pow(2, 20), + }, ]); - expect(logger.debug).toHaveBeenNthCalledWith( - 1, - 'Generated reports successfully: ', - ); - expect(logger.debug).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('out.json'), - ); - expect(logger.debug).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('9.77 kB'), - ); - expect(logger.warn).toHaveBeenNthCalledWith( - 1, - expect.stringContaining('Generated reports failed: '), - ); - expect(logger.warn).toHaveBeenNthCalledWith( - 1, - expect.stringContaining('fail'), + + expect(ansis.strip(output)).toBe( + ` +Persisted report to file system: +• ${path.join('.code-pushup', 'report.json')} (2 MB) +• ${path.join('.code-pushup', 'report.md')} (3 MB) +`.trimStart(), ); }); }); diff --git a/packages/core/src/lib/implementation/read-rc-file.int.test.ts b/packages/core/src/lib/implementation/read-rc-file.int.test.ts index dae34e77a..88dc1fede 100644 --- a/packages/core/src/lib/implementation/read-rc-file.int.test.ts +++ b/packages/core/src/lib/implementation/read-rc-file.int.test.ts @@ -55,15 +55,13 @@ describe('readRcByPath', () => { }); it('should throw if the path is empty', async () => { - await expect(readRcByPath('')).rejects.toThrow( - 'The path to the configuration file is empty.', - ); + await expect(readRcByPath('')).rejects.toThrow("File '' does not exist"); }); it('should throw if the file does not exist', async () => { await expect( readRcByPath(path.join('non-existent', 'config.file.js')), - ).rejects.toThrow(/Provided path .* is not valid./); + ).rejects.toThrow(/File '.*' does not exist/); }); it('should throw if the configuration is empty', async () => { diff --git a/packages/core/src/lib/implementation/read-rc-file.ts b/packages/core/src/lib/implementation/read-rc-file.ts index 9dd2afb5f..090ad2c0e 100644 --- a/packages/core/src/lib/implementation/read-rc-file.ts +++ b/packages/core/src/lib/implementation/read-rc-file.ts @@ -1,3 +1,4 @@ +import ansis from 'ansis'; import path from 'node:path'; import { CONFIG_FILE_NAME, @@ -6,36 +7,45 @@ import { coreConfigSchema, validate, } from '@code-pushup/models'; -import { fileExists, importModule } from '@code-pushup/utils'; - -export class ConfigPathError extends Error { - constructor(configPath: string) { - super(`Provided path '${configPath}' is not valid.`); - } -} +import { fileExists, importModule, logger } from '@code-pushup/utils'; export async function readRcByPath( filePath: string, tsconfig?: string, ): Promise { - if (filePath.length === 0) { - throw new Error('The path to the configuration file is empty.'); - } - - if (!(await fileExists(filePath))) { - throw new ConfigPathError(filePath); - } + const formattedTarget = [ + `${ansis.bold(path.relative(process.cwd(), filePath))}`, + tsconfig && + `(paths from ${ansis.bold(path.relative(process.cwd(), tsconfig))})`, + ] + .filter(Boolean) + .join(' '); - const cfg: CoreConfig = await importModule({ - filepath: filePath, - tsconfig, - format: 'esm', - }); + const value = await logger.task( + `Importing config from ${formattedTarget}`, + async () => { + const result = await importModule({ + filepath: filePath, + tsconfig, + format: 'esm', + }); + return { result, message: `Imported config from ${formattedTarget}` }; + }, + ); - return validate(coreConfigSchema, cfg, { filePath }); + const config = validate(coreConfigSchema, value, { filePath }); + logger.info('Configuration is valid ✓'); + return config; } export async function autoloadRc(tsconfig?: string): Promise { + const configFilePatterns = [ + CONFIG_FILE_NAME, + `{${SUPPORTED_CONFIG_FILE_FORMATS.join(',')}}`, + ].join('.'); + + logger.debug(`Looking for default config file ${configFilePatterns}`); + // eslint-disable-next-line functional/no-let let ext = ''; // eslint-disable-next-line functional/no-loop-statements @@ -44,6 +54,7 @@ export async function autoloadRc(tsconfig?: string): Promise { const exists = await fileExists(filePath); if (exists) { + logger.debug(`Found default config file ${ansis.bold(filePath)}`); ext = extension; break; } @@ -51,9 +62,7 @@ export async function autoloadRc(tsconfig?: string): Promise { if (!ext) { throw new Error( - `No file ${CONFIG_FILE_NAME}.(${SUPPORTED_CONFIG_FILE_FORMATS.join( - '|', - )}) present in ${process.cwd()}`, + `No ${configFilePatterns} file present in ${process.cwd()}`, ); } diff --git a/packages/core/src/lib/implementation/read-rc-file.unit.test.ts b/packages/core/src/lib/implementation/read-rc-file.unit.test.ts index d9c5e2001..91e2fe498 100644 --- a/packages/core/src/lib/implementation/read-rc-file.unit.test.ts +++ b/packages/core/src/lib/implementation/read-rc-file.unit.test.ts @@ -76,7 +76,7 @@ describe('autoloadRc', () => { it('should throw if no configuration file is present', async () => { await expect(autoloadRc()).rejects.toThrow( - 'No file code-pushup.config.(ts|mjs|js) present in', + `No code-pushup.config.{ts,mjs,js} file present in ${MEMFS_VOLUME}`, ); }); }); diff --git a/packages/core/src/lib/implementation/runner.ts b/packages/core/src/lib/implementation/runner.ts index c1b125859..eede1a749 100644 --- a/packages/core/src/lib/implementation/runner.ts +++ b/packages/core/src/lib/implementation/runner.ts @@ -15,6 +15,7 @@ import { ensureDirectoryExists, executeProcess, fileExists, + logger, readJsonFile, removeDirectoryIfExists, runnerArgsToEnv, @@ -127,21 +128,33 @@ export async function writeRunnerResults( const cacheFilePath = getRunnerOutputsPath(pluginSlug, outputDir); await ensureDirectoryExists(path.dirname(cacheFilePath)); await writeFile(cacheFilePath, JSON.stringify(runnerResult.audits, null, 2)); + logger.info( + `Wrote runner output to cache ${formatCachePathSuffix(cacheFilePath)}`, + ); } export async function readRunnerResults( pluginSlug: string, outputDir: string, ): Promise { - const auditOutputsPath = getRunnerOutputsPath(pluginSlug, outputDir); - if (await fileExists(auditOutputsPath)) { - const cachedResult = await readJsonFile(auditOutputsPath); - + const cachePath = getRunnerOutputsPath(pluginSlug, outputDir); + if (await fileExists(cachePath)) { + const cachedResult = await readJsonFile(cachePath); + logger.info( + `Read runner output from cache ${formatCachePathSuffix(cachePath)}`, + ); return { audits: cachedResult, duration: 0, date: new Date().toISOString(), }; } + logger.info( + `Cached runner output is not available ${formatCachePathSuffix(cachePath)}`, + ); return null; } + +function formatCachePathSuffix(cacheFilePath: string): string { + return ansis.gray(`(${cacheFilePath})`); +} diff --git a/packages/core/src/lib/load-portal-client.ts b/packages/core/src/lib/load-portal-client.ts index 0f7a13e45..3028940a9 100644 --- a/packages/core/src/lib/load-portal-client.ts +++ b/packages/core/src/lib/load-portal-client.ts @@ -1,17 +1,17 @@ +import ansis from 'ansis'; import { logger, stringifyError } from '@code-pushup/utils'; export async function loadPortalClient(): Promise< - typeof import('@code-pushup/portal-client') | null + typeof import('@code-pushup/portal-client') > { try { return await import('@code-pushup/portal-client'); } catch (error) { - logger.warn( + logger.error( `Failed to import @code-pushup/portal-client - ${stringifyError(error)}`, ); - logger.error( - 'Optional peer dependency @code-pushup/portal-client is not available. Make sure it is installed to enable upload functionality.', + throw new Error( + `The ${ansis.bold('@code-pushup/portal-client')} peer dependency must be installed to enable uploading to Portal.`, ); - return null; } } diff --git a/packages/core/src/lib/upload.ts b/packages/core/src/lib/upload.ts index 0dbd68c29..749f3fc3c 100644 --- a/packages/core/src/lib/upload.ts +++ b/packages/core/src/lib/upload.ts @@ -1,42 +1,45 @@ import type { SaveReportMutationVariables } from '@code-pushup/portal-client'; import type { PersistConfig, Report, UploadConfig } from '@code-pushup/models'; -import { loadReport } from '@code-pushup/utils'; +import { formatAsciiLink, loadReport, logger } from '@code-pushup/utils'; import { reportToGQL } from './implementation/report-to-gql.js'; import { loadPortalClient } from './load-portal-client.js'; -export type UploadOptions = { upload?: UploadConfig } & { +export type UploadOptions = { + upload: UploadConfig; persist: Required; }; /** * Uploads collected audits to the portal - * @param options - * @param uploadFn */ export async function upload(options: UploadOptions) { - if (options.upload == null) { - throw new Error('Upload configuration is not set.'); - } - const portalClient = await loadPortalClient(); - if (!portalClient) { - return; - } - const { uploadReportToPortal } = portalClient; - const { apiKey, server, organization, project, timeout } = options.upload; - const report: Report = await loadReport({ - ...options.persist, - format: 'json', - }); - if (!report.commit) { - throw new Error('Commit must be linked in order to upload report'); - } + await logger.task('Uploading report to Portal', async () => { + const portalClient = await loadPortalClient(); + const { uploadReportToPortal } = portalClient; + const { apiKey, server, organization, project, timeout } = options.upload; + const report: Report = await loadReport({ + ...options.persist, + format: 'json', + }); + if (!report.commit) { + throw new Error('Commit must be linked in order to upload report'); + } + + const data: SaveReportMutationVariables = { + organization, + project, + commit: report.commit.hash, + ...reportToGQL(report), + }; - const data: SaveReportMutationVariables = { - organization, - project, - commit: report.commit.hash, - ...reportToGQL(report), - }; + const { url } = await uploadReportToPortal({ + apiKey, + server, + data, + timeout, + }); + logger.info(formatAsciiLink(url)); - return uploadReportToPortal({ apiKey, server, data, timeout }); + return `Uploaded report to Portal`; + }); } diff --git a/packages/core/src/lib/upload.unit.test.ts b/packages/core/src/lib/upload.unit.test.ts index 92712b113..143b753db 100644 --- a/packages/core/src/lib/upload.unit.test.ts +++ b/packages/core/src/lib/upload.unit.test.ts @@ -19,22 +19,22 @@ describe('upload', () => { }); it('should call upload with correct data', async () => { - const result = await upload({ - upload: { - apiKey: 'dummy-api-key', - server: 'https://example.com/api', - organization: 'code-pushup', - project: 'cli', - }, - persist: { - outputDir: MEMFS_VOLUME, - filename: 'report', - format: ['json'], - skipReports: false, - }, - }); - - expect(result).toEqual({ url: expect.stringContaining('code-pushup/cli') }); + await expect( + upload({ + upload: { + apiKey: 'dummy-api-key', + server: 'https://example.com/api', + organization: 'code-pushup', + project: 'cli', + }, + persist: { + outputDir: MEMFS_VOLUME, + filename: 'report', + format: ['json'], + skipReports: false, + }, + }), + ).resolves.toBeUndefined(); expect(uploadReportToPortal).toHaveBeenCalledWith< Parameters @@ -54,21 +54,4 @@ describe('upload', () => { }, }); }); - - it('should throw for missing upload configuration', async () => { - await expect( - upload({ - persist: { - outputDir: MEMFS_VOLUME, - filename: 'report', - format: ['json'], - skipReports: false, - }, - upload: undefined, - }), - ).rejects.toThrow('Upload configuration is not set.'); - }); - - // @TODO add tests for failed upload - // @TODO add tests for multiple uploads }); diff --git a/packages/plugin-coverage/src/lib/nx/coverage-paths.unit.test.ts b/packages/plugin-coverage/src/lib/nx/coverage-paths.unit.test.ts index 8d57dba34..5bcaaa422 100644 --- a/packages/plugin-coverage/src/lib/nx/coverage-paths.unit.test.ts +++ b/packages/plugin-coverage/src/lib/nx/coverage-paths.unit.test.ts @@ -261,7 +261,9 @@ describe('getCoveragePathForJest', () => { vol.fromJSON( { // values come from bundle-require mock above + 'jest-preset.config.ts': '', 'jest-valid.config.unit.ts': '', + 'jest-valid.config.integration.ts': '', 'jest-no-dir.config.integration.ts': '', 'jest-no-lcov.config.integration.ts': '', }, diff --git a/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts b/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts index 4fd0bb555..536512e74 100644 --- a/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts @@ -368,6 +368,7 @@ describe('getConfig', () => { }); it('should load config from lh-config.js file if configPath is specified', async () => { + vol.fromJSON({ 'lh-config.js': '// mocked above' }, MEMFS_VOLUME); await expect(getConfig({ configPath: 'lh-config.js' })).resolves.toEqual( expect.objectContaining({ upload: expect.objectContaining({ diff --git a/packages/utils/mocks/fixtures/invalid-js-file.json b/packages/utils/mocks/fixtures/invalid-js-file.json new file mode 100644 index 000000000..7ba1b793e --- /dev/null +++ b/packages/utils/mocks/fixtures/invalid-js-file.json @@ -0,0 +1 @@ +{ "key": "value" } diff --git a/packages/utils/mocks/fixtures/valid-ts-default-export.ts b/packages/utils/mocks/fixtures/valid-ts-default-export.ts new file mode 100644 index 000000000..5243d58ef --- /dev/null +++ b/packages/utils/mocks/fixtures/valid-ts-default-export.ts @@ -0,0 +1 @@ +export default 'valid-ts-default-export'; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e7f287def..c878b9e1f 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -8,6 +8,7 @@ export { toTitleCase, uppercase, } from './lib/case-conversions.js'; +export { formatCommandStatus } from './lib/command.js'; export { filesCoverageToTree, type FileCoverage } from './lib/coverage-tree.js'; export { createRunnerFiles } from './lib/create-runner-files.js'; export { dateToUnixTimestamp } from './lib/dates.js'; @@ -37,15 +38,12 @@ export { findLineNumberInText, findNearestFile, importModule, - logMultipleFileResults, pluginWorkDir, projectToFilename, readJsonFile, readTextFile, removeDirectoryIfExists, type CrawlFileSystemOptions, - type FileResult, - type MultipleFileResults, } from './lib/file-system.js'; export { filterItemRefsBy } from './lib/filter.js'; export { @@ -87,9 +85,7 @@ export { isRecord, } from './lib/guards.js'; export { interpolate } from './lib/interpolate.js'; -export { logMultipleResults } from './lib/log-results.js'; export { Logger, logger } from './lib/logger.js'; -export { formatCommandStatus } from './lib/command.js'; export { mergeConfigs } from './lib/merge-configs.js'; export { addIndex, diff --git a/packages/utils/src/lib/file-system.int.test.ts b/packages/utils/src/lib/file-system.int.test.ts index 74e969225..b355e1bb1 100644 --- a/packages/utils/src/lib/file-system.int.test.ts +++ b/packages/utils/src/lib/file-system.int.test.ts @@ -37,11 +37,31 @@ describe('importModule', () => { ); }); - it('should throw if the file does not exist', async () => { + it('should load a valid TS module with a default export', async () => { await expect( importModule({ - filepath: 'path/to/non-existent-export.mjs', + filepath: path.join(mockDir, 'valid-ts-default-export.ts'), }), - ).rejects.toThrow('non-existent-export.mjs'); + ).resolves.toBe('valid-ts-default-export'); + }); + + it('should throw if the file does not exist', async () => { + await expect( + importModule({ filepath: 'path/to/non-existent-export.mjs' }), + ).rejects.toThrow("File 'path/to/non-existent-export.mjs' does not exist"); + }); + + it('should throw if path is a directory', async () => { + await expect(importModule({ filepath: mockDir })).rejects.toThrow( + `Expected '${mockDir}' to be a file`, + ); + }); + + it('should throw if file is not valid JS', async () => { + await expect( + importModule({ filepath: path.join(mockDir, 'invalid-js-file.json') }), + ).rejects.toThrow( + `${path.join(mockDir, 'invalid-js-file.json')} is not a valid JS file`, + ); }); }); diff --git a/packages/utils/src/lib/file-system.ts b/packages/utils/src/lib/file-system.ts index 5dacec734..71db43c6a 100644 --- a/packages/utils/src/lib/file-system.ts +++ b/packages/utils/src/lib/file-system.ts @@ -1,11 +1,9 @@ -import ansis from 'ansis'; import { type Options, bundleRequire } from 'bundle-require'; import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import type { Format, PersistConfig } from '@code-pushup/models'; -import { formatBytes } from './formatting.js'; -import { logMultipleResults } from './log-results.js'; import { logger } from './logger.js'; +import { settlePromise } from './promises.js'; export async function readTextFile(filePath: string): Promise { const buffer = await readFile(filePath); @@ -54,30 +52,15 @@ export async function removeDirectoryIfExists(dir: string) { } } -export type FileResult = readonly [string] | readonly [string, number]; -export type MultipleFileResults = PromiseSettledResult[]; - -export function logMultipleFileResults( - fileResults: MultipleFileResults, - messagePrefix: string, -): void { - const succeededTransform = (result: PromiseFulfilledResult) => { - const [fileName, size] = result.value; - const formattedSize = size ? ` (${ansis.gray(formatBytes(size))})` : ''; - return `- ${ansis.bold(fileName)}${formattedSize}`; - }; - const failedTransform = (result: PromiseRejectedResult) => - `- ${ansis.bold(String(result.reason))}`; - - logMultipleResults( - fileResults, - messagePrefix, - succeededTransform, - failedTransform, - ); -} - export async function importModule(options: Options): Promise { + const resolvedStats = await settlePromise(stat(options.filepath)); + if (resolvedStats.status === 'rejected') { + throw new Error(`File '${options.filepath}' does not exist`); + } + if (!resolvedStats.value.isFile()) { + throw new Error(`Expected '${options.filepath}' to be a file`); + } + const { mod } = await bundleRequire(options); if (typeof mod === 'object' && 'default' in mod) { diff --git a/packages/utils/src/lib/file-system.unit.test.ts b/packages/utils/src/lib/file-system.unit.test.ts index b141ee4ff..515b3091a 100644 --- a/packages/utils/src/lib/file-system.unit.test.ts +++ b/packages/utils/src/lib/file-system.unit.test.ts @@ -1,21 +1,18 @@ import { vol } from 'memfs'; import { stat } from 'node:fs/promises'; import path from 'node:path'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import { - type FileResult, crawlFileSystem, createReportPath, ensureDirectoryExists, filePathToCliArg, findLineNumberInText, findNearestFile, - logMultipleFileResults, projectToFilename, splitFilePath, } from './file-system.js'; -import * as logResults from './log-results.js'; describe('ensureDirectoryExists', () => { it('should create a nested folder', async () => { @@ -53,32 +50,6 @@ describe('createReportPath', () => { }); }); -describe('logMultipleFileResults', () => { - it('should call logMultipleResults with the correct arguments', () => { - const logMultipleResultsSpy = vi.spyOn( - logResults, - 'logMultipleResults' as never, - ); - const persistResult = [ - { - status: 'fulfilled', - value: ['out.json', 10_000], - } as PromiseFulfilledResult, - ]; - const messagePrefix = 'Generated reports'; - - logMultipleFileResults(persistResult, messagePrefix); - - expect(logMultipleResultsSpy).toHaveBeenCalled(); - expect(logMultipleResultsSpy).toHaveBeenCalledWith( - persistResult, - messagePrefix, - expect.any(Function), - expect.any(Function), - ); - }); -}); - describe('crawlFileSystem', () => { beforeEach(() => { vol.fromJSON( diff --git a/packages/utils/src/lib/git/git.commits-and-tags.ts b/packages/utils/src/lib/git/git.commits-and-tags.ts index abb7b0a07..19711db8e 100644 --- a/packages/utils/src/lib/git/git.commits-and-tags.ts +++ b/packages/utils/src/lib/git/git.commits-and-tags.ts @@ -15,7 +15,7 @@ export async function getLatestCommit( }); return validate(commitSchema, log.latest); } catch (error) { - logger.error(stringifyError(error)); + logger.warn(stringifyError(error)); return null; } } diff --git a/packages/utils/src/lib/log-results.ts b/packages/utils/src/lib/log-results.ts deleted file mode 100644 index 953ec1cbc..000000000 --- a/packages/utils/src/lib/log-results.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { isPromiseFulfilledResult, isPromiseRejectedResult } from './guards.js'; -import { logger } from './logger.js'; - -export function logMultipleResults( - results: PromiseSettledResult[], - messagePrefix: string, - succeededTransform?: (result: PromiseFulfilledResult) => string, - failedTransform?: (result: PromiseRejectedResult) => string, -) { - if (succeededTransform) { - const succeededResults = results.filter(isPromiseFulfilledResult); - - logPromiseResults( - succeededResults, - `${messagePrefix} successfully: `, - succeededTransform, - ); - } - - if (failedTransform) { - const failedResults = results.filter(isPromiseRejectedResult); - - logPromiseResults( - failedResults, - `${messagePrefix} failed: `, - failedTransform, - ); - } -} - -export function logPromiseResults< - T extends PromiseFulfilledResult[] | PromiseRejectedResult[], ->(results: T, logMessage: string, getMsg: (result: T[number]) => string): void { - if (results.length > 0) { - const log = - results[0]?.status === 'fulfilled' - ? (message: string) => { - logger.debug(message); - } - : (message: string) => { - logger.warn(message); - }; - - 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 deleted file mode 100644 index ad7086f31..000000000 --- a/packages/utils/src/lib/log-results.unit.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { FileResult } from './file-system.js'; -import { logMultipleResults, logPromiseResults } from './log-results.js'; -import { logger } from './logger.js'; - -describe('logMultipleResults', () => { - const succeededCallbackMock = vi.fn(); - const failedCallbackMock = vi.fn(); - - it('should call logPromiseResults with successful plugin result', () => { - logMultipleResults( - [ - { - status: 'fulfilled', - value: ['out.json', 10_000], - } as PromiseFulfilledResult, - ], - 'Generated reports', - succeededCallbackMock, - failedCallbackMock, - ); - - expect(succeededCallbackMock).toHaveBeenCalled(); - expect(failedCallbackMock).not.toHaveBeenCalled(); - }); - - it('should call logPromiseResults with failed plugin result', () => { - logMultipleResults( - [{ status: 'rejected', reason: 'fail' } as PromiseRejectedResult], - 'Generated reports', - succeededCallbackMock, - failedCallbackMock, - ); - - expect(failedCallbackMock).toHaveBeenCalled(); - expect(succeededCallbackMock).not.toHaveBeenCalled(); - }); - - it('should call logPromiseResults twice', () => { - logMultipleResults( - [ - { - status: 'fulfilled', - value: ['out.json', 10_000], - } as PromiseFulfilledResult, - { status: 'rejected', reason: 'fail' } as PromiseRejectedResult, - ], - 'Generated reports', - succeededCallbackMock, - failedCallbackMock, - ); - - expect(succeededCallbackMock).toHaveBeenCalledOnce(); - expect(failedCallbackMock).toHaveBeenCalledOnce(); - }); -}); - -describe('logPromiseResults', () => { - it('should log on success', () => { - logPromiseResults( - [ - { - status: 'fulfilled', - value: ['out.json'], - } as PromiseFulfilledResult, - ], - 'Uploaded reports successfully:', - (result): string => result.value.toString(), - ); - expect(logger.debug).toHaveBeenNthCalledWith( - 1, - 'Uploaded reports successfully:', - ); - expect(logger.debug).toHaveBeenNthCalledWith(2, 'out.json'); - }); - - it('should log on fail', () => { - logPromiseResults( - [{ status: 'rejected', reason: 'fail' } as PromiseRejectedResult], - 'Generated reports failed:', - (result: { reason: string }) => result.reason, - ); - expect(logger.warn).toHaveBeenNthCalledWith(1, 'Generated reports failed:'); - expect(logger.warn).toHaveBeenNthCalledWith(2, 'fail'); - }); -}); diff --git a/packages/utils/src/lib/logger.int.test.ts b/packages/utils/src/lib/logger.int.test.ts index 6be9cc114..d0dd327bb 100644 --- a/packages/utils/src/lib/logger.int.test.ts +++ b/packages/utils/src/lib/logger.int.test.ts @@ -376,6 +376,23 @@ ${ansis.green('✔')} Uploaded report to portal ${ansis.gray('(42 ms)')} ); }); + it('should resolve value from worker', async () => { + const config = { plugins: ['eslint'] }; + + await expect( + new Logger().task('Loading configuration', async () => ({ + message: 'Loaded configuration', + result: config, + })), + ).resolves.toBe(config); + + expect(stdout).toBe( + ` +${ansis.green('✔')} Loaded configuration ${ansis.gray('(42 ms)')} +`.trimStart(), + ); + }); + it('should allow spinners to be run in sequence', async () => { performanceNowSpy .mockReset() diff --git a/packages/utils/src/lib/logger.ts b/packages/utils/src/lib/logger.ts index 8777771ce..b607c5e79 100644 --- a/packages/utils/src/lib/logger.ts +++ b/packages/utils/src/lib/logger.ts @@ -212,12 +212,16 @@ export class Logger { * @param title Display text used as pending message. * @param worker Asynchronous implementation. Returned promise determines spinner status and final message. Support for inner logs has some limitations (described above). */ - async task(title: string, worker: () => Promise): Promise { - await this.#spinner(worker, { + async task( + title: string, + worker: () => Promise, + ): Promise { + const result = await this.#spinner(worker, { pending: title, - success: value => value, + success: value => (typeof value === 'string' ? value : value.message), failure: error => `${title} → ${ansis.red(String(error))}`, }); + return typeof result === 'object' ? result.result : (undefined as T); } /** diff --git a/packages/utils/src/lib/reports/__snapshots__/generate-md-report.unit.test.ts.snap b/packages/utils/src/lib/reports/__snapshots__/generate-md-report.unit.test.ts.snap index cca882a1e..a8304ca33 100644 --- a/packages/utils/src/lib/reports/__snapshots__/generate-md-report.unit.test.ts.snap +++ b/packages/utils/src/lib/reports/__snapshots__/generate-md-report.unit.test.ts.snap @@ -68,7 +68,7 @@ No unsafe any assignment [📖 Docs](https://web.dev/lcp) `; exports[`generateMdReport > should render complete md report 1`] = ` -"# Code PushUp Report +"# Code PushUp report | 🏷 Category | ⭐ Score | 🛡 Audits | | :-------------------------- | :----------: | :-------: | diff --git a/packages/utils/src/lib/reports/__snapshots__/report-stdout-all-perfect-scores.txt b/packages/utils/src/lib/reports/__snapshots__/report-stdout-all-perfect-scores.txt index 42c7ae5e9..948d01aab 100644 --- a/packages/utils/src/lib/reports/__snapshots__/report-stdout-all-perfect-scores.txt +++ b/packages/utils/src/lib/reports/__snapshots__/report-stdout-all-perfect-scores.txt @@ -1,11 +1,9 @@ -Code PushUp Report - @code-pushup/core@0.0.1 - +Code PushUp report ESLint audits ● ... All 47 audits have perfect scores ... - Lighthouse audits ● Minimize third-party usage Third-party code blocked the main @@ -26,4 +24,3 @@ Categories └──────────────────┴─────────┴──────────┘ Made with ❤ by code-pushup.dev - diff --git a/packages/utils/src/lib/reports/__snapshots__/report-stdout-no-categories.txt b/packages/utils/src/lib/reports/__snapshots__/report-stdout-no-categories.txt index 5f608893e..aac494b78 100644 --- a/packages/utils/src/lib/reports/__snapshots__/report-stdout-no-categories.txt +++ b/packages/utils/src/lib/reports/__snapshots__/report-stdout-no-categories.txt @@ -1,5 +1,4 @@ -Code PushUp Report - @code-pushup/core@0.0.1 - +Code PushUp report ESLint audits @@ -19,7 +18,6 @@ ESLint audits ● Require the use of `===` and `!==` 1 warning ● ... + 37 audits with perfect scores ... - Lighthouse audits ● Minimize third-party usage Third-party code blocked the main @@ -30,4 +28,3 @@ Lighthouse audits ● ... + 2 audits with perfect scores ... Made with ❤ by code-pushup.dev - diff --git a/packages/utils/src/lib/reports/__snapshots__/report-stdout-verbose.txt b/packages/utils/src/lib/reports/__snapshots__/report-stdout-verbose.txt index 00d567cb6..9cea66b74 100644 --- a/packages/utils/src/lib/reports/__snapshots__/report-stdout-verbose.txt +++ b/packages/utils/src/lib/reports/__snapshots__/report-stdout-verbose.txt @@ -1,5 +1,4 @@ -Code PushUp Report - @code-pushup/core@0.0.1 - +Code PushUp report ESLint audits @@ -60,7 +59,6 @@ ESLint audits ● Require or disallow "Yoda" conditions passed ● Require using arrow functions for callbacks passed - Lighthouse audits ● Minimize third-party usage Third-party code blocked the main thread for @@ -82,4 +80,3 @@ Categories └──────────────────┴─────────┴──────────┘ Made with ❤ by code-pushup.dev - diff --git a/packages/utils/src/lib/reports/__snapshots__/report-stdout.txt b/packages/utils/src/lib/reports/__snapshots__/report-stdout.txt index 48c06632e..7a0c8010e 100644 --- a/packages/utils/src/lib/reports/__snapshots__/report-stdout.txt +++ b/packages/utils/src/lib/reports/__snapshots__/report-stdout.txt @@ -1,5 +1,4 @@ -Code PushUp Report - @code-pushup/core@0.0.1 - +Code PushUp report ESLint audits @@ -19,7 +18,6 @@ ESLint audits ● Require the use of `===` and `!==` 1 warning ● ... + 37 audits with perfect scores ... - Lighthouse audits ● Minimize third-party usage Third-party code blocked the main @@ -40,4 +38,3 @@ Categories └──────────────────┴─────────┴──────────┘ Made with ❤ by code-pushup.dev - diff --git a/packages/utils/src/lib/reports/__snapshots__/report.md b/packages/utils/src/lib/reports/__snapshots__/report.md index 2278e4641..b8722d513 100644 --- a/packages/utils/src/lib/reports/__snapshots__/report.md +++ b/packages/utils/src/lib/reports/__snapshots__/report.md @@ -1,4 +1,4 @@ -# Code PushUp Report +# Code PushUp report | 🏷 Category | ⭐ Score | 🛡 Audits | | :-------------------------------- | :-------: | :-------: | diff --git a/packages/utils/src/lib/reports/constants.ts b/packages/utils/src/lib/reports/constants.ts index e81c6eaa1..1e7048481 100644 --- a/packages/utils/src/lib/reports/constants.ts +++ b/packages/utils/src/lib/reports/constants.ts @@ -10,7 +10,7 @@ export const SCORE_COLOR_RANGE = { export const FOOTER_PREFIX = 'Made with ❤ by'; // replace ❤️ with ❤, because ❤️ has output issues in terminal export const CODE_PUSHUP_DOMAIN = 'code-pushup.dev'; export const README_LINK = 'https://github.com/code-pushup/cli#readme'; -export const REPORT_HEADLINE_TEXT = 'Code PushUp Report'; +export const REPORT_HEADLINE_TEXT = 'Code PushUp report'; export const CODE_PUSHUP_UNICODE_LOGO = '<✓>'; diff --git a/packages/utils/src/lib/reports/generate-md-report.unit.test.ts b/packages/utils/src/lib/reports/generate-md-report.unit.test.ts index 25761d37d..28caf1c36 100644 --- a/packages/utils/src/lib/reports/generate-md-report.unit.test.ts +++ b/packages/utils/src/lib/reports/generate-md-report.unit.test.ts @@ -670,7 +670,7 @@ describe('generateMdReport', () => { it('should render all sections of the report', () => { const md = generateMdReport(baseScoredReport); // report title - expect(md).toMatch('# Code PushUp Report'); + expect(md).toMatch('# Code PushUp report'); // categories section heading expect(md).toContainMarkdownTableRow([ '🏷 Category', @@ -709,7 +709,7 @@ describe('generateMdReport', () => { it('should skip categories section when categories are missing', () => { const md = generateMdReport({ ...baseScoredReport, categories: undefined }); expect(md).not.toMatch('## 🏷 Categories'); - expect(md).toMatch('# Code PushUp Report\n\n## 🛡️ Audits'); + expect(md).toMatch('# Code PushUp report\n\n## 🛡️ Audits'); }); it('should render complete md report', () => { diff --git a/packages/utils/src/lib/reports/log-stdout-summary.ts b/packages/utils/src/lib/reports/log-stdout-summary.ts index 2763cd6ef..653b031bd 100644 --- a/packages/utils/src/lib/reports/log-stdout-summary.ts +++ b/packages/utils/src/lib/reports/log-stdout-summary.ts @@ -15,26 +15,20 @@ import { } from './utils.js'; export function logStdoutSummary(report: ScoredReport): void { - const { plugins, categories, packageName, version } = report; - logger.info(reportToHeaderSection({ packageName, version })); + const { plugins, categories } = report; + logger.info(ansis.bold.blue(REPORT_HEADLINE_TEXT)); logger.newline(); logPlugins(plugins); if (categories && categories.length > 0) { + logger.newline(); logCategories({ plugins, categories }); } - logger.info(`${FOOTER_PREFIX} ${CODE_PUSHUP_DOMAIN}`); logger.newline(); -} - -function reportToHeaderSection({ - packageName, - version, -}: Pick): string { - return `${ansis.bold(REPORT_HEADLINE_TEXT)} - ${packageName}@${version}`; + logger.info(`${FOOTER_PREFIX} ${CODE_PUSHUP_DOMAIN}`); } export function logPlugins(plugins: ScoredReport['plugins']): void { - plugins.forEach(plugin => { + plugins.forEach((plugin, idx) => { const { title, audits } = plugin; const filteredAudits = logger.isVerbose() || audits.length === 1 @@ -49,6 +43,10 @@ export function logPlugins(plugins: ScoredReport['plugins']): void { : `... + ${diff} audits with perfect scores ...` : null; + if (idx > 0) { + logger.newline(); + } + logAudits(title, filteredAudits, footer); }); } @@ -60,8 +58,6 @@ function logAudits( ): void { const marker = '●'; - logger.newline(); - logger.info( formatAsciiTable( { @@ -81,8 +77,6 @@ function logAudits( { borderless: true }, ), ); - - logger.newline(); } export function logCategories({ @@ -107,8 +101,6 @@ export function logCategories({ { padding: 2 }, ), ); - - logger.newline(); } export function binaryIconPrefix( diff --git a/testing/test-setup/src/lib/logger.mock.ts b/testing/test-setup/src/lib/logger.mock.ts index a80435117..6b348ef60 100644 --- a/testing/test-setup/src/lib/logger.mock.ts +++ b/testing/test-setup/src/lib/logger.mock.ts @@ -35,7 +35,8 @@ beforeAll(async () => { return typeof value === 'object' ? value.result : undefined; }), vi.spyOn(logger, 'task').mockImplementation(async (_, worker) => { - await worker(); + const value = await worker(); + return typeof value === 'object' ? value.result : undefined; }), vi.spyOn(logger, 'command').mockImplementation((_, worker) => worker()), );