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 beb76af1f..90368b081 100644 --- a/e2e/cli-e2e/tests/__snapshots__/help.e2e.test.ts.snap +++ b/e2e/cli-e2e/tests/__snapshots__/help.e2e.test.ts.snap @@ -37,12 +37,14 @@ Global Options: [array] [default: []] Persist Options: - --persist.outputDir Directory for the produced reports + --persist.outputDir Directory for the produced reports [string] - --persist.filename Filename for the produced reports. + --persist.filename Filename for the produced reports. [string] - --persist.format Format of the report output. e.g. \`md\`, \`json\` + --persist.format Format of the report output. e.g. \`md\`, \`json\` [array] + --persist.skipReports Skip generating report files. (useful in combinatio + n with caching) [boolean] Upload Options: --upload.organization Organization slug from portal diff --git a/e2e/cli-e2e/tests/collect.e2e.test.ts b/e2e/cli-e2e/tests/collect.e2e.test.ts index b29f26471..f1f76ddfc 100644 --- a/e2e/cli-e2e/tests/collect.e2e.test.ts +++ b/e2e/cli-e2e/tests/collect.e2e.test.ts @@ -7,7 +7,7 @@ import { TEST_OUTPUT_DIR, teardownTestFolder, } from '@code-pushup/test-utils'; -import { executeProcess, readTextFile } from '@code-pushup/utils'; +import { executeProcess, fileExists, readTextFile } from '@code-pushup/utils'; describe('CLI collect', () => { const dummyPluginTitle = 'Dummy Plugin'; @@ -61,6 +61,28 @@ describe('CLI collect', () => { expect(md).toContain(dummyAuditTitle); }); + it('should not create reports if --persist.skipReports is given', async () => { + const { code } = await executeProcess({ + command: 'npx', + args: [ + '@code-pushup/cli', + '--no-progress', + 'collect', + '--persist.skipReports', + ], + cwd: dummyDir, + }); + + expect(code).toBe(0); + + await expect( + fileExists(path.join(dummyOutputDir, 'report.md')), + ).resolves.toBeFalsy(); + await expect( + fileExists(path.join(dummyOutputDir, 'report.json')), + ).resolves.toBeFalsy(); + }); + it('should print report summary to stdout', async () => { const { code, stdout } = await executeProcess({ command: 'npx', diff --git a/packages/ci/src/lib/run-utils.ts b/packages/ci/src/lib/run-utils.ts index 8c3fdd475..29db1dcbf 100644 --- a/packages/ci/src/lib/run-utils.ts +++ b/packages/ci/src/lib/run-utils.ts @@ -521,6 +521,7 @@ export function configFromPatterns( outputDir: interpolate(persist.outputDir, variables), filename: interpolate(persist.filename, variables), format: persist.format, + skipReports: persist.skipReports, }, ...(upload && { upload: { diff --git a/packages/cli/README.md b/packages/cli/README.md index 0d09ad1ad..7bb2e8f90 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -212,6 +212,7 @@ Each example is fully tested to demonstrate best practices for plugin testing as | **`--persist.outputDir`** | `string` | n/a | Directory for the produced reports. | | **`--persist.filename`** | `string` | `report` | Filename for the produced reports without extension. | | **`--persist.format`** | `('json' \| 'md')[]` | `json` | Format(s) of the report file. | +| **`--persist.skipReports`** | `boolean` | `false` | Skip generating report files. (useful in combination with caching) | | **`--upload.organization`** | `string` | n/a | Organization slug from portal. | | **`--upload.project`** | `string` | n/a | Project slug from portal. | | **`--upload.server`** | `string` | n/a | URL to your portal server. | diff --git a/packages/cli/src/lib/collect/collect-command.unit.test.ts b/packages/cli/src/lib/collect/collect-command.unit.test.ts index 796fee62b..6765add61 100644 --- a/packages/cli/src/lib/collect/collect-command.unit.test.ts +++ b/packages/cli/src/lib/collect/collect-command.unit.test.ts @@ -37,7 +37,7 @@ describe('collect-command', () => { expect(collectAndPersistReports).toHaveBeenCalledWith( expect.objectContaining({ config: '/test/code-pushup.config.ts', - persist: expect.objectContaining>({ + persist: expect.objectContaining({ filename: DEFAULT_PERSIST_FILENAME, outputDir: DEFAULT_PERSIST_OUTPUT_DIR, format: DEFAULT_PERSIST_FORMAT, diff --git a/packages/cli/src/lib/compare/compare-command.unit.test.ts b/packages/cli/src/lib/compare/compare-command.unit.test.ts index b2279fd80..69d78cfc7 100644 --- a/packages/cli/src/lib/compare/compare-command.unit.test.ts +++ b/packages/cli/src/lib/compare/compare-command.unit.test.ts @@ -41,6 +41,7 @@ describe('compare-command', () => { outputDir: DEFAULT_PERSIST_OUTPUT_DIR, filename: DEFAULT_PERSIST_FILENAME, format: DEFAULT_PERSIST_FORMAT, + skipReports: false, }, upload: expect.any(Object), }, diff --git a/packages/cli/src/lib/implementation/core-config.int.test.ts b/packages/cli/src/lib/implementation/core-config.int.test.ts index 5cca9ee80..0e9e57616 100644 --- a/packages/cli/src/lib/implementation/core-config.int.test.ts +++ b/packages/cli/src/lib/implementation/core-config.int.test.ts @@ -64,6 +64,7 @@ describe('parsing values from CLI and middleware', () => { filename: DEFAULT_PERSIST_FILENAME, format: DEFAULT_PERSIST_FORMAT, outputDir: DEFAULT_PERSIST_OUTPUT_DIR, + skipReports: false, }); }); @@ -85,6 +86,7 @@ describe('parsing values from CLI and middleware', () => { filename: 'cli-filename', format: ['md'], outputDir: 'cli-outputDir', + skipReports: false, }); }); @@ -101,6 +103,7 @@ describe('parsing values from CLI and middleware', () => { filename: 'rc-filename', format: ['json', 'md'], outputDir: 'rc-outputDir', + skipReports: false, }); }); @@ -122,6 +125,7 @@ describe('parsing values from CLI and middleware', () => { filename: 'cli-filename', format: ['md'], outputDir: 'cli-outputDir', + skipReports: false, }); }); @@ -141,6 +145,7 @@ describe('parsing values from CLI and middleware', () => { filename: 'rc-filename', format: DEFAULT_PERSIST_FORMAT, outputDir: 'cli-outputdir', + skipReports: false, }); }); diff --git a/packages/cli/src/lib/implementation/core-config.middleware.ts b/packages/cli/src/lib/implementation/core-config.middleware.ts index 0f2891178..f21ecf474 100644 --- a/packages/cli/src/lib/implementation/core-config.middleware.ts +++ b/packages/cli/src/lib/implementation/core-config.middleware.ts @@ -15,6 +15,24 @@ export type CoreConfigMiddlewareOptions = GeneralCliOptions & CoreConfigCliOptions & FilterOptions; +function buildPersistConfig( + cliPersist: CoreConfigCliOptions['persist'], + rcPersist: CoreConfig['persist'], +): Required { + return { + outputDir: + cliPersist?.outputDir ?? + rcPersist?.outputDir ?? + DEFAULT_PERSIST_OUTPUT_DIR, + filename: + cliPersist?.filename ?? rcPersist?.filename ?? DEFAULT_PERSIST_FILENAME, + format: normalizeFormats( + cliPersist?.format ?? rcPersist?.format ?? DEFAULT_PERSIST_FORMAT, + ), + skipReports: cliPersist?.skipReports ?? rcPersist?.skipReports ?? false, + }; +} + export async function coreConfigMiddleware< T extends CoreConfigMiddlewareOptions, >(processArgs: T): Promise { @@ -43,22 +61,23 @@ export async function coreConfigMiddleware< }); return { ...(config != null && { config }), - persist: { - outputDir: - cliPersist?.outputDir ?? - rcPersist?.outputDir ?? - DEFAULT_PERSIST_OUTPUT_DIR, - filename: - cliPersist?.filename ?? rcPersist?.filename ?? DEFAULT_PERSIST_FILENAME, - format: normalizeFormats( - cliPersist?.format ?? rcPersist?.format ?? DEFAULT_PERSIST_FORMAT, - ), - }, + persist: buildPersistConfig(cliPersist, rcPersist), ...(upload != null && { upload }), ...remainingRcConfig, ...remainingCliOptions, }; } +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); + export const normalizeFormats = (formats?: string[]): Format[] => (formats ?? []).flatMap(format => format.split(',') as Format[]); 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 134b8f183..8c27e3abd 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,6 +2,7 @@ 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'; @@ -19,6 +20,36 @@ 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']); diff --git a/packages/cli/src/lib/implementation/core-config.model.ts b/packages/cli/src/lib/implementation/core-config.model.ts index ed011da56..a5f668bb5 100644 --- a/packages/cli/src/lib/implementation/core-config.model.ts +++ b/packages/cli/src/lib/implementation/core-config.model.ts @@ -4,6 +4,7 @@ export type PersistConfigCliOptions = { 'persist.outputDir'?: string; 'persist.filename'?: string; 'persist.format'?: Format; + 'persist.skipReports'?: boolean; }; export type UploadConfigCliOptions = { diff --git a/packages/cli/src/lib/implementation/core-config.options.ts b/packages/cli/src/lib/implementation/core-config.options.ts index 79d62346a..bc97e98c7 100644 --- a/packages/cli/src/lib/implementation/core-config.options.ts +++ b/packages/cli/src/lib/implementation/core-config.options.ts @@ -31,6 +31,11 @@ export function yargsPersistConfigOptionsDefinition(): Record< describe: 'Format of the report output. e.g. `md`, `json`', type: 'array', }, + 'persist.skipReports': { + describe: + 'Skip generating report files. (useful in combination with caching)', + type: 'boolean', + }, }; } diff --git a/packages/cli/src/lib/merge-diffs/merge-diffs-command.unit.test.ts b/packages/cli/src/lib/merge-diffs/merge-diffs-command.unit.test.ts index aca98fb91..3313004c4 100644 --- a/packages/cli/src/lib/merge-diffs/merge-diffs-command.unit.test.ts +++ b/packages/cli/src/lib/merge-diffs/merge-diffs-command.unit.test.ts @@ -47,6 +47,7 @@ describe('merge-diffs-command', () => { outputDir: DEFAULT_PERSIST_OUTPUT_DIR, filename: DEFAULT_PERSIST_FILENAME, format: DEFAULT_PERSIST_FORMAT, + skipReports: false, }, ); }); diff --git a/packages/core/src/lib/collect-and-persist.ts b/packages/core/src/lib/collect-and-persist.ts index dfa48ba76..8445050d0 100644 --- a/packages/core/src/lib/collect-and-persist.ts +++ b/packages/core/src/lib/collect-and-persist.ts @@ -8,6 +8,7 @@ import { logStdoutSummary, scoreReport, sortReport, + ui, } from '@code-pushup/utils'; import { collect } from './implementation/collect.js'; import { @@ -19,29 +20,40 @@ import type { GlobalOptions } from './types.js'; export type CollectAndPersistReportsOptions = Pick< CoreConfig, 'plugins' | 'categories' -> & { persist: Required } & Partial; +> & { + persist: Required> & + Pick; +} & Partial; export async function collectAndPersistReports( options: CollectAndPersistReportsOptions, ): Promise { - const report = await collect(options); - const sortedScoredReport = sortReport(scoreReport(report)); + const logger = ui().logger; + const reportResult = await collect(options); + const sortedScoredReport = sortReport(scoreReport(reportResult)); - const persistResults = await persistReport( - report, - sortedScoredReport, - options.persist, - ); + const { persist } = options; + const { skipReports = false, ...persistOptions } = persist ?? {}; - // terminal output - logStdoutSummary(sortedScoredReport); + if (skipReports === true) { + logger.info('Skipping saving reports as `persist.skipReports` is true'); + } else { + const persistResults = await persistReport( + reportResult, + sortedScoredReport, + persistOptions, + ); - if (isVerbose()) { - logPersistedResults(persistResults); + if (isVerbose()) { + logPersistedResults(persistResults); + } } + // terminal output + logStdoutSummary(sortedScoredReport); + // validate report and throw if invalid - report.plugins.forEach(plugin => { + reportResult.plugins.forEach(plugin => { // Running checks after persisting helps while debugging as you can check the invalid output after the error is thrown pluginReportSchema.parse(plugin); }); 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 3a7ee4d43..49a22c3b1 100644 --- a/packages/core/src/lib/collect-and-persist.unit.test.ts +++ b/packages/core/src/lib/collect-and-persist.unit.test.ts @@ -115,6 +115,31 @@ describe('collectAndPersistReports', () => { expect(logPersistedResults).toHaveBeenCalled(); }); + it('should call collect and not persistReport if skipReports options is true in verbose mode', async () => { + const sortedScoredReport = sortReport(scoreReport(MINIMAL_REPORT_MOCK)); + + vi.stubEnv('CP_VERBOSE', 'true'); + + const verboseConfig: CollectAndPersistReportsOptions = { + ...MINIMAL_CONFIG_MOCK, + persist: { + outputDir: 'output', + filename: 'report', + format: ['md'], + skipReports: true, + }, + progress: false, + }; + await collectAndPersistReports(verboseConfig); + + expect(collect).toHaveBeenCalledWith(verboseConfig); + + expect(persistReport).not.toHaveBeenCalled(); + expect(logPersistedResults).not.toHaveBeenCalled(); + + expect(logStdoutSummary).toHaveBeenCalledWith(sortedScoredReport); + }); + it('should print a summary to stdout', async () => { await collectAndPersistReports( MINIMAL_CONFIG_MOCK as CollectAndPersistReportsOptions, diff --git a/packages/core/src/lib/implementation/persist.ts b/packages/core/src/lib/implementation/persist.ts index 915af9459..069b16c69 100644 --- a/packages/core/src/lib/implementation/persist.ts +++ b/packages/core/src/lib/implementation/persist.ts @@ -25,7 +25,7 @@ export class PersistError extends Error { export async function persistReport( report: Report, sortedScoredReport: ScoredReport, - options: Required, + options: Required>, ): Promise { const { outputDir, filename, format } = options; diff --git a/packages/models/src/lib/persist-config.ts b/packages/models/src/lib/persist-config.ts index b349343ff..81cc80c0b 100644 --- a/packages/models/src/lib/persist-config.ts +++ b/packages/models/src/lib/persist-config.ts @@ -10,6 +10,7 @@ export const persistConfigSchema = z.object({ .describe('Artifacts file name (without extension)') .optional(), format: z.array(formatSchema).optional(), + skipReports: z.boolean().optional(), }); export type PersistConfig = z.infer; diff --git a/packages/utils/src/lib/file-system.ts b/packages/utils/src/lib/file-system.ts index d97982bfe..268eed737 100644 --- a/packages/utils/src/lib/file-system.ts +++ b/packages/utils/src/lib/file-system.ts @@ -90,7 +90,7 @@ export function createReportPath({ filename, format, suffix, -}: Omit, 'format'> & { +}: Pick, 'filename' | 'outputDir'> & { format: Format; suffix?: string; }): string {