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