Skip to content

feat: add report option to cli #1058

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Aug 12, 2025
8 changes: 5 additions & 3 deletions e2e/cli-e2e/tests/__snapshots__/help.e2e.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion e2e/cli-e2e/tests/collect.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/ci/src/lib/run-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/collect/collect-command.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe('collect-command', () => {
expect(collectAndPersistReports).toHaveBeenCalledWith(
expect.objectContaining({
config: '/test/code-pushup.config.ts',
persist: expect.objectContaining<Required<PersistConfig>>({
persist: expect.objectContaining<PersistConfig>({
filename: DEFAULT_PERSIST_FILENAME,
outputDir: DEFAULT_PERSIST_OUTPUT_DIR,
format: DEFAULT_PERSIST_FORMAT,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/lib/compare/compare-command.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/lib/implementation/core-config.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});

Expand All @@ -85,6 +86,7 @@ describe('parsing values from CLI and middleware', () => {
filename: 'cli-filename',
format: ['md'],
outputDir: 'cli-outputDir',
skipReports: false,
});
});

Expand All @@ -101,6 +103,7 @@ describe('parsing values from CLI and middleware', () => {
filename: 'rc-filename',
format: ['json', 'md'],
outputDir: 'rc-outputDir',
skipReports: false,
});
});

Expand All @@ -122,6 +125,7 @@ describe('parsing values from CLI and middleware', () => {
filename: 'cli-filename',
format: ['md'],
outputDir: 'cli-outputDir',
skipReports: false,
});
});

Expand All @@ -141,6 +145,7 @@ describe('parsing values from CLI and middleware', () => {
filename: 'rc-filename',
format: DEFAULT_PERSIST_FORMAT,
outputDir: 'cli-outputdir',
skipReports: false,
});
});

Expand Down
41 changes: 30 additions & 11 deletions packages/cli/src/lib/implementation/core-config.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,24 @@ export type CoreConfigMiddlewareOptions = GeneralCliOptions &
CoreConfigCliOptions &
FilterOptions;

function buildPersistConfig(
cliPersist: CoreConfigCliOptions['persist'],
rcPersist: CoreConfig['persist'],
): Required<CoreConfig['persist']> {
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<GeneralCliOptions & CoreConfig & FilterOptions> {
Expand Down Expand Up @@ -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 = <T extends string>(
propertyName: T,
cliOptions?: Record<T, unknown>,
rcOptions?: Record<T, unknown>,
): 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[]);
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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']);
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/lib/implementation/core-config.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type PersistConfigCliOptions = {
'persist.outputDir'?: string;
'persist.filename'?: string;
'persist.format'?: Format;
'persist.skipReports'?: boolean;
};

export type UploadConfigCliOptions = {
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/lib/implementation/core-config.options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe('merge-diffs-command', () => {
outputDir: DEFAULT_PERSIST_OUTPUT_DIR,
filename: DEFAULT_PERSIST_FILENAME,
format: DEFAULT_PERSIST_FORMAT,
skipReports: false,
},
);
});
Expand Down
38 changes: 25 additions & 13 deletions packages/core/src/lib/collect-and-persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
logStdoutSummary,
scoreReport,
sortReport,
ui,
} from '@code-pushup/utils';
import { collect } from './implementation/collect.js';
import {
Expand All @@ -19,29 +20,40 @@ import type { GlobalOptions } from './types.js';
export type CollectAndPersistReportsOptions = Pick<
CoreConfig,
'plugins' | 'categories'
> & { persist: Required<PersistConfig> } & Partial<GlobalOptions>;
> & {
persist: Required<Omit<PersistConfig, 'skipReports'>> &
Pick<PersistConfig, 'skipReports'>;
} & Partial<GlobalOptions>;

export async function collectAndPersistReports(
options: CollectAndPersistReportsOptions,
): Promise<void> {
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);
});
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/lib/collect-and-persist.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/lib/implementation/persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export class PersistError extends Error {
export async function persistReport(
report: Report,
sortedScoredReport: ScoredReport,
options: Required<PersistConfig>,
options: Required<Omit<PersistConfig, 'skipReports'>>,
): Promise<MultipleFileResults> {
const { outputDir, filename, format } = options;

Expand Down
1 change: 1 addition & 0 deletions packages/models/src/lib/persist-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof persistConfigSchema>;
2 changes: 1 addition & 1 deletion packages/utils/src/lib/file-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export function createReportPath({
filename,
format,
suffix,
}: Omit<Required<PersistConfig>, 'format'> & {
}: Pick<Required<PersistConfig>, 'filename' | 'outputDir'> & {
format: Format;
suffix?: string;
}): string {
Expand Down
Loading