diff --git a/e2e/cli-e2e/mocks/fs.mock.ts b/e2e/cli-e2e/mocks/fs.mock.ts index 6e28f9635..f9ca993c3 100644 --- a/e2e/cli-e2e/mocks/fs.mock.ts +++ b/e2e/cli-e2e/mocks/fs.mock.ts @@ -1,6 +1,8 @@ import { mkdirSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; +import { ensureDirectoryExists } from '@code-pushup/utils'; +// @TODO move into testing library export function cleanFolder( dirName = 'tmp', content?: { [key in keyof T]: string }, @@ -13,7 +15,7 @@ export function cleanFolder( } } } - +// @TODO move into testing library export function cleanFolderPutGitKeep( dirName = 'tmp', content?: { [key in keyof T]: string }, @@ -27,3 +29,12 @@ export function cleanFolderPutGitKeep( } } } + +export function setupFolder(dirName = 'tmp', content?: Record) { + ensureDirectoryExists(dirName); + if (content) { + for (const fileName in content) { + writeFileSync(join(dirName, fileName), content[fileName]); + } + } +} diff --git a/e2e/cli-e2e/mocks/utils.ts b/e2e/cli-e2e/mocks/utils.ts new file mode 100644 index 000000000..a188ff777 --- /dev/null +++ b/e2e/cli-e2e/mocks/utils.ts @@ -0,0 +1,34 @@ +// @TODO move logic into testing library +import { join } from 'path'; +import { + CliArgsObject, + ProcessConfig, + executeProcess, + objectToCliArgs, +} from '@code-pushup/utils'; + +export const extensions = ['js', 'mjs', 'ts'] as const; +export type Extension = (typeof extensions)[number]; + +export const configFile = (ext: Extension = 'ts') => + join(process.cwd(), `e2e/cli-e2e/mocks/code-pushup.config.${ext}`); + +export const execCli = ( + command: string, + argObj: Partial, + processOptions?: Omit, +) => + executeProcess({ + command: 'npx', + args: [ + './dist/packages/cli', + command, + ...objectToCliArgs({ + verbose: true, + progress: false, + config: configFile(), + ...argObj, + }), + ], + ...processOptions, + }); diff --git a/e2e/cli-e2e/tests/__snapshots__/help.spec.ts.snap b/e2e/cli-e2e/tests/__snapshots__/help.spec.ts.snap index 25ba30afa..38dcb9c58 100644 --- a/e2e/cli-e2e/tests/__snapshots__/help.spec.ts.snap +++ b/e2e/cli-e2e/tests/__snapshots__/help.spec.ts.snap @@ -19,6 +19,7 @@ Options: --config Path the the config file, e.g. code-pushup.config.j s [string] [default: \\"code-pushup.config.js\\"] --persist.outputDir Directory for the produced reports [string] + --persist.filename Filename for the produced reports. [string] --persist.format Format of the report output. e.g. \`md\`, \`json\`, \`st dout\` [array] --upload.organization Organization slug from portal [string] diff --git a/e2e/cli-e2e/tests/print-config.spec.ts b/e2e/cli-e2e/tests/print-config.spec.ts index ec1ca0b29..3c6f6398d 100644 --- a/e2e/cli-e2e/tests/print-config.spec.ts +++ b/e2e/cli-e2e/tests/print-config.spec.ts @@ -1,38 +1,21 @@ import { join } from 'path'; import { expect } from 'vitest'; -import { - CliArgsObject, - executeProcess, - objectToCliArgs, -} from '@code-pushup/utils'; +import { CliArgsObject } from '@code-pushup/utils'; +import { configFile, execCli, extensions } from '../mocks/utils'; -const extensions = ['js', 'mjs', 'ts'] as const; -type Extension = (typeof extensions)[number]; - -const configFile = (ext: Extension) => - join(process.cwd(), `e2e/cli-e2e/mocks/code-pushup.config.${ext}`); - -const execCli = (argObj: Partial) => - executeProcess({ - command: 'npx', - args: [ - './dist/packages/cli', - 'print-config', - ...objectToCliArgs({ - verbose: true, - ...argObj, - }), - ], - }); +const execCliPrintConfig = (argObj: Partial) => + execCli('print-config', argObj); describe('print-config', () => { it.each(extensions)('should load .%s config file', async ext => { - const { code, stderr, stdout } = await execCli({ config: configFile(ext) }); + const { code, stderr, stdout } = await execCliPrintConfig({ + config: configFile(ext), + }); expect(code).toBe(0); expect(stderr).toBe(''); const args = JSON.parse(stdout); expect(args).toEqual({ - progress: true, + progress: false, verbose: true, config: expect.stringContaining(`code-pushup.config.${ext}`), upload: { @@ -51,14 +34,14 @@ describe('print-config', () => { }); it('should load .ts config file and merge cli arguments', async () => { - const { code, stderr, stdout } = await execCli({ - config: configFile('ts'), + const { code, stderr, stdout } = await execCliPrintConfig({ + 'persist.filename': 'my-report', }); expect(code).toBe(0); expect(stderr).toBe(''); const args = JSON.parse(stdout); expect(args).toEqual({ - progress: true, + progress: false, verbose: true, config: expect.stringContaining(`code-pushup.config.ts`), upload: { @@ -69,7 +52,7 @@ describe('print-config', () => { }, persist: { outputDir: join('tmp', 'ts'), - filename: 'report', + filename: 'my-report', }, plugins: expect.any(Array), categories: expect.any(Array), @@ -77,8 +60,7 @@ describe('print-config', () => { }); it('should parse persist.format from arguments', async () => { - const { code, stderr, stdout } = await execCli({ - config: configFile('ts'), + const { code, stderr, stdout } = await execCliPrintConfig({ 'persist.format': ['md', 'json', 'stdout'], }); expect(code).toBe(0); diff --git a/packages/cli/src/lib/autorun/command-object.spec.ts b/packages/cli/src/lib/autorun/command-object.spec.ts index 1f2faee4f..44a800036 100644 --- a/packages/cli/src/lib/autorun/command-object.spec.ts +++ b/packages/cli/src/lib/autorun/command-object.spec.ts @@ -32,7 +32,6 @@ vi.mock('@code-pushup/portal-client', async () => { ), }; }); - const baseArgs = [ 'autorun', ...objectToCliArgs({ @@ -70,6 +69,7 @@ describe('autorun-command-object', () => { ...baseArgs, ...objectToCliArgs({ 'persist.format': 'md', + 'persist.filename': 'my-report', 'upload.apiKey': 'some-other-api-key', 'upload.server': 'https://other-example.com/api', }), diff --git a/packages/cli/src/lib/collect/command-object.spec.ts b/packages/cli/src/lib/collect/command-object.spec.ts index 20dbcbb68..aa629bbe0 100644 --- a/packages/cli/src/lib/collect/command-object.spec.ts +++ b/packages/cli/src/lib/collect/command-object.spec.ts @@ -2,15 +2,12 @@ import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { CollectAndPersistReportsOptions } from '@code-pushup/core'; import { objectToCliArgs } from '@code-pushup/utils'; -import { - cleanFolderPutGitKeep, - mockConsole, - unmockConsole, -} from '../../../test'; +import { mockConsole, unmockConsole } from '../../../test'; import { DEFAULT_CLI_CONFIGURATION } from '../../../test/constants'; import { yargsCli } from '../yargs-cli'; import { yargsCollectCommandObject } from './command-object'; +const getFilename = () => 'report'; const baseArgs = [ ...objectToCliArgs({ progress: false, @@ -36,21 +33,22 @@ describe('collect-command-object', () => { beforeEach(() => { logs = []; - cleanFolderPutGitKeep(); mockConsole((...args: unknown[]) => { logs.push(...args); }); }); afterEach(() => { - cleanFolderPutGitKeep(); + logs = []; unmockConsole(); }); it('should override config with CLI arguments', async () => { + const filename = getFilename(); const args = [ ...baseArgs, ...objectToCliArgs({ 'persist.format': 'md', + 'persist.filename': filename, }), ]; const parsedArgv = (await cli( diff --git a/packages/cli/src/lib/implementation/core-config-options.ts b/packages/cli/src/lib/implementation/core-config-options.ts index 9f497580b..9d9a8d54c 100644 --- a/packages/cli/src/lib/implementation/core-config-options.ts +++ b/packages/cli/src/lib/implementation/core-config-options.ts @@ -9,6 +9,10 @@ export function yargsCoreConfigOptionsDefinition(): Record { describe: 'Directory for the produced reports', type: 'string', }, + 'persist.filename': { + describe: 'Filename for the produced reports.', + type: 'string', + }, 'persist.format': { describe: 'Format of the report output. e.g. `md`, `json`, `stdout`', type: 'array', diff --git a/packages/cli/src/lib/implementation/model.ts b/packages/cli/src/lib/implementation/model.ts index 7175d4407..6ca334195 100644 --- a/packages/cli/src/lib/implementation/model.ts +++ b/packages/cli/src/lib/implementation/model.ts @@ -5,6 +5,7 @@ export type GeneralCliOptions = GlobalOptions; export type CoreConfigCliOptions = { 'persist.outputDir': string; + 'persist.filename': string; 'persist.format': Format | string; 'upload.organization': string; 'upload.project': string; diff --git a/packages/cli/src/lib/print-config/command-object.spec.ts b/packages/cli/src/lib/print-config/command-object.spec.ts index 173680ba7..b322ca2bc 100644 --- a/packages/cli/src/lib/print-config/command-object.spec.ts +++ b/packages/cli/src/lib/print-config/command-object.spec.ts @@ -29,6 +29,7 @@ describe('print-config-command-object', () => { it('should print existing config', async () => { const parsedArgv = await cli(baseArgs).parseAsync(); expect(parsedArgv.persist.outputDir).toBe('tmp'); + expect(parsedArgv.persist.filename).toBe('report'); expect(parsedArgv.persist.format).toBeUndefined(); expect(parsedArgv.upload?.organization).toBe('code-pushup'); expect(parsedArgv.upload?.project).toBe('cli'); @@ -43,6 +44,7 @@ describe('print-config-command-object', () => { const overrides = { 'persist.outputDir': 'custom/output/dir', 'persist.format': ['md'], + 'persist.filename': 'custom-report-filename', 'upload.organization': 'custom-org', 'upload.project': 'custom-project', 'upload.apiKey': 'custom-api-key', @@ -52,6 +54,7 @@ describe('print-config-command-object', () => { const parsedArgv = await cli(args).parseAsync(); expect(parsedArgv.persist.outputDir).toBe(overrides['persist.outputDir']); + expect(parsedArgv.persist.filename).toBe('custom-report-filename'); expect(parsedArgv.persist.format).toEqual(overrides['persist.format']); expect(parsedArgv.upload?.organization).toEqual( overrides['upload.organization'], diff --git a/packages/cli/src/lib/upload/command-object.spec.ts b/packages/cli/src/lib/upload/command-object.spec.ts index 52b17dbe8..a991804a1 100644 --- a/packages/cli/src/lib/upload/command-object.spec.ts +++ b/packages/cli/src/lib/upload/command-object.spec.ts @@ -9,7 +9,7 @@ import { import { UploadOptions } from '@code-pushup/core'; import { report } from '@code-pushup/models/testing'; import { CliArgsObject, objectToCliArgs } from '@code-pushup/utils'; -import { cleanFolderPutGitKeep } from '../../../test'; +import { setupFolder } from '../../../test'; import { DEFAULT_CLI_CONFIGURATION } from '../../../test/constants'; import { yargsCli } from '../yargs-cli'; import { yargsUploadCommandObject } from './command-object'; @@ -26,7 +26,9 @@ vi.mock('@code-pushup/portal-client', async () => { ), }; }); +const dummyReport = report(); +// @TODO move into test library const baseArgs = [ 'upload', ...objectToCliArgs({ @@ -47,22 +49,15 @@ const cli = (args: string[]) => commands: [yargsUploadCommandObject()], }); -const reportFile = (format: 'json' | 'md' = 'json') => 'report.' + format; -const dummyReport = report(); - describe('upload-command-object', () => { beforeEach(async () => { vi.clearAllMocks(); - cleanFolderPutGitKeep('tmp', { - [reportFile()]: JSON.stringify(dummyReport), - }); - }); - - afterEach(async () => { - cleanFolderPutGitKeep('tmp'); }); it('should override config with CLI arguments', async () => { + setupFolder('tmp', { + ['report.json']: JSON.stringify(dummyReport), + }); const args = [ ...baseArgs, ...objectToCliArgs({ @@ -82,7 +77,17 @@ describe('upload-command-object', () => { }); it('should call portal-client function with correct parameters', async () => { - await cli(baseArgs).parseAsync(); + const reportFileName = 'my-report'; + setupFolder('tmp', { + [reportFileName + '.json']: JSON.stringify(dummyReport), + }); + const args = [ + ...baseArgs, + ...objectToCliArgs({ + 'persist.filename': reportFileName, + }), + ]; + await cli(args).parseAsync(); expect(uploadToPortal).toHaveBeenCalledWith({ apiKey: 'dummy-api-key', server: 'https://example.com/api', diff --git a/packages/cli/test/fs.mock.ts b/packages/cli/test/fs.mock.ts index 2bcf17baf..632ec11d7 100644 --- a/packages/cli/test/fs.mock.ts +++ b/packages/cli/test/fs.mock.ts @@ -1,26 +1,12 @@ -import { mkdirSync, rmSync, writeFileSync } from 'fs'; +import { writeFileSync } from 'fs'; import { join } from 'path'; +import { ensureDirectoryExists } from '@code-pushup/utils'; -export function cleanFolder( +export async function setupFolder( dirName = 'tmp', content?: { [key in keyof T]: string }, ) { - rmSync(dirName, { recursive: true, force: true }); - mkdirSync(dirName, { recursive: true }); - if (content) { - for (const fileName in content) { - writeFileSync(join(dirName, fileName), content[fileName]); - } - } -} - -export function cleanFolderPutGitKeep( - dirName = 'tmp', - content?: { [key in keyof T]: string }, -) { - rmSync(dirName, { recursive: true, force: true }); - mkdirSync(dirName, { recursive: true }); - writeFileSync(join(dirName, '.gitkeep'), ''); + await ensureDirectoryExists(dirName); if (content) { for (const fileName in content) { writeFileSync(join(dirName, fileName), content[fileName]); diff --git a/tmp/.gitkeep b/packages/cli/test/utils.ts similarity index 100% rename from tmp/.gitkeep rename to packages/cli/test/utils.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fc35c2be9..3662fedca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,4 @@ export { - logPersistedResults, persistReport, PersistError, PersistDirError, diff --git a/packages/core/src/lib/collect-and-persist.spec.ts b/packages/core/src/lib/collect-and-persist.spec.ts index 02bfffd79..0f91d246b 100644 --- a/packages/core/src/lib/collect-and-persist.spec.ts +++ b/packages/core/src/lib/collect-and-persist.spec.ts @@ -22,8 +22,8 @@ vi.mock('@code-pushup/portal-client', async () => { }); const outputDir = 'tmp'; -const reportPath = (path = outputDir, format: 'json' | 'md' = 'json') => - join(path, 'report.' + format); +const reportPath = (format: 'json' | 'md' = 'json') => + join(outputDir, `report.${format}`); describe('collectAndPersistReports', () => { beforeEach(async () => { @@ -34,9 +34,10 @@ describe('collectAndPersistReports', () => { }); it('should work', async () => { + const cfg = minimalConfig(outputDir); await collectAndPersistReports({ ...DEFAULT_TESTING_CLI_OPTIONS, - ...minimalConfig(outputDir), + ...cfg, }); const result = JSON.parse(readFileSync(reportPath()).toString()) as Report; expect(result.plugins[0]?.audits[0]?.slug).toBe('audit-1'); diff --git a/packages/core/src/lib/implementation/persist.spec.ts b/packages/core/src/lib/implementation/persist.spec.ts index 873414e82..8c0f09436 100644 --- a/packages/core/src/lib/implementation/persist.spec.ts +++ b/packages/core/src/lib/implementation/persist.spec.ts @@ -14,7 +14,7 @@ import { FOOTER_PREFIX, README_LINK, } from '@code-pushup/utils'; -import { mockConsole, unmockConsole } from '../../../test/console.mock'; +import { mockConsole, unmockConsole } from '../../../test'; import { logPersistedResults, persistReport } from './persist'; // Mock file system API's diff --git a/packages/core/src/lib/implementation/persist.ts b/packages/core/src/lib/implementation/persist.ts index 624fdece2..4150ccf38 100644 --- a/packages/core/src/lib/implementation/persist.ts +++ b/packages/core/src/lib/implementation/persist.ts @@ -1,11 +1,11 @@ -import chalk from 'chalk'; import { existsSync, mkdirSync } from 'fs'; import { stat, writeFile } from 'fs/promises'; import { join } from 'path'; import { CoreConfig, Report } from '@code-pushup/models'; import { - formatBytes, + MultipleFileResults, getLatestCommit, + logMultipleFileResults, reportToMd, reportToStdout, scoreReport, @@ -23,15 +23,13 @@ export class PersistError extends Error { } } -export type PersistResult = PromiseSettledResult[]; - export async function persistReport( report: Report, config: CoreConfig, -): Promise { +): Promise { const { persist } = config; const outputDir = persist.outputDir; - const filename = persist?.filename || reportName(report); + const filename = persist.filename; let { format } = persist; format = format && format.length !== 0 ? format : ['stdout']; let scoredReport; @@ -86,34 +84,6 @@ export async function persistReport( ); } -export function logPersistedResults(persistResult: PersistResult) { - const succeededPersistedResults = persistResult.filter( - (result): result is PromiseFulfilledResult<[string, number]> => - result.status === 'fulfilled', - ); - - if (succeededPersistedResults.length) { - console.log(`Generated reports successfully: `); - succeededPersistedResults.forEach(res => { - const [fileName, size] = res.value; - console.log( - `- ${chalk.bold(fileName)} (${chalk.gray(formatBytes(size))})`, - ); - }); - } - - const failedPersistedResults = persistResult.filter( - (result): result is PromiseRejectedResult => result.status === 'rejected', - ); - - if (failedPersistedResults.length) { - console.log(`Generated reports failed: `); - failedPersistedResults.forEach(result => { - console.log(`- ${chalk.bold(result.reason)}`); - }); - } -} - -function reportName(report: Report): string { - return `report.${report.date}`; +export function logPersistedResults(persistResults: MultipleFileResults) { + logMultipleFileResults(persistResults, 'Generated reports'); } diff --git a/packages/core/src/lib/upload.spec.ts b/packages/core/src/lib/upload.spec.ts index 98df8d828..33df89ba0 100644 --- a/packages/core/src/lib/upload.spec.ts +++ b/packages/core/src/lib/upload.spec.ts @@ -60,4 +60,7 @@ describe('uploadToPortal', () => { expect(result.packageName).toBe('dummy-package'); }); + + // @TODO add tests for failed upload + // @TODO add tests for multiple uploads }); diff --git a/packages/core/src/lib/upload.ts b/packages/core/src/lib/upload.ts index 63e3522bc..cea2bb757 100644 --- a/packages/core/src/lib/upload.ts +++ b/packages/core/src/lib/upload.ts @@ -1,8 +1,6 @@ -import { readFileSync } from 'fs'; -import { join } from 'path'; import { uploadToPortal } from '@code-pushup/portal-client'; -import { CoreConfig, reportSchema } from '@code-pushup/models'; -import { getLatestCommit } from '@code-pushup/utils'; +import { CoreConfig, Report } from '@code-pushup/models'; +import { getLatestCommit, loadReport } from '@code-pushup/utils'; import { jsonToGql } from './implementation/json-to-gql'; export type UploadOptions = Pick; @@ -18,12 +16,13 @@ export async function upload( if (options?.upload === undefined) { throw new Error('upload config needs to be set'); } - const { apiKey, server, organization, project } = options.upload; - const { outputDir } = options.persist; - const report = reportSchema.parse( - JSON.parse(readFileSync(join(outputDir, 'report.json')).toString()), - ); + const { outputDir, filename } = options.persist; + const report: Report = await loadReport({ + outputDir, + filename: filename, + format: 'json', + }); const commitData = await getLatestCommit(); if (!commitData) { @@ -37,7 +36,5 @@ export async function upload( ...jsonToGql(report), }; - return uploadFn({ apiKey, server, data }).catch(e => { - throw new Error('upload failed. ' + e.message); - }); + return uploadFn({ apiKey, server, data }); } diff --git a/packages/core/test/fs.mock.ts b/packages/core/test/fs.mock.ts deleted file mode 100644 index 2bcf17baf..000000000 --- a/packages/core/test/fs.mock.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { mkdirSync, rmSync, writeFileSync } from 'fs'; -import { join } from 'path'; - -export function cleanFolder( - dirName = 'tmp', - content?: { [key in keyof T]: string }, -) { - rmSync(dirName, { recursive: true, force: true }); - mkdirSync(dirName, { recursive: true }); - if (content) { - for (const fileName in content) { - writeFileSync(join(dirName, fileName), content[fileName]); - } - } -} - -export function cleanFolderPutGitKeep( - dirName = 'tmp', - content?: { [key in keyof T]: string }, -) { - rmSync(dirName, { recursive: true, force: true }); - mkdirSync(dirName, { recursive: true }); - writeFileSync(join(dirName, '.gitkeep'), ''); - if (content) { - for (const fileName in content) { - writeFileSync(join(dirName, fileName), content[fileName]); - } - } -} diff --git a/packages/core/test/index.ts b/packages/core/test/index.ts index 1d0e96403..25db11c2c 100644 --- a/packages/core/test/index.ts +++ b/packages/core/test/index.ts @@ -1,3 +1,2 @@ export * from './types'; -export * from './fs.mock'; export * from './console.mock'; diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 5bac8ccc0..bea662fd6 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -1,6 +1,6 @@ export { - CategoryRef, CategoryConfig, + CategoryRef, categoryConfigSchema, } from './lib/category-config'; diff --git a/packages/models/src/lib/persist-config.ts b/packages/models/src/lib/persist-config.ts index 86424a3af..9e43af420 100644 --- a/packages/models/src/lib/persist-config.ts +++ b/packages/models/src/lib/persist-config.ts @@ -9,7 +9,7 @@ export const persistConfigSchema = z.object({ filename: fileNameSchema('Artifacts file name (without extension)').default( 'report', ), - format: z.array(formatSchema).default(['stdout']).optional(), // @TODO remove default or optional value and otherwise it will not set defaults + format: z.array(formatSchema).default(['stdout']).optional(), // @TODO remove default or optional value and otherwise it will not set defaults. }); export type PersistConfig = z.infer; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index a29778741..0921d23fe 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -19,6 +19,7 @@ export { formatBytes, formatCount, slugify, + loadReport, } from './lib/report'; export { reportToMd } from './lib/report-to-md'; export { reportToStdout } from './lib/report-to-stdout'; @@ -33,5 +34,9 @@ export { readTextFile, toArray, toUnixPath, + ensureDirectoryExists, + FileResult, + MultipleFileResults, + logMultipleFileResults, } from './lib/utils'; export { verboseUtils } from './lib/verbose-utils'; diff --git a/packages/utils/src/lib/execute-process.spec.ts b/packages/utils/src/lib/execute-process.spec.ts index 11c4d0bc7..f2e887ecb 100644 --- a/packages/utils/src/lib/execute-process.spec.ts +++ b/packages/utils/src/lib/execute-process.spec.ts @@ -1,10 +1,6 @@ import { join } from 'path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - cleanFolderPutGitKeep, - getAsyncProcessRunnerConfig, - mockProcessConfig, -} from '../../test'; +import { describe, expect, it, vi } from 'vitest'; +import { getAsyncProcessRunnerConfig, mockProcessConfig } from '../../test'; import { executeProcess, objectToCliArgs } from './execute-process'; const outFolder = ''; @@ -12,10 +8,6 @@ const outName = 'tmp/out-async-runner.json'; const outputFile = join(outFolder, outName); describe('executeProcess', () => { - afterEach(() => { - cleanFolderPutGitKeep(); - }); - it('should work with node command `node -v`', async () => { const cfg = mockProcessConfig({ command: `node`, args: ['-v'] }); const processResult = await executeProcess(cfg); diff --git a/packages/utils/src/lib/execute-process.ts b/packages/utils/src/lib/execute-process.ts index 2f42deb47..838e2ed52 100644 --- a/packages/utils/src/lib/execute-process.ts +++ b/packages/utils/src/lib/execute-process.ts @@ -192,6 +192,7 @@ export function objectToCliArgs< if (!params) { return []; } + return Object.entries(params).flatMap(([key, value]) => { // process/file/script if (key === '_') { @@ -224,6 +225,8 @@ export function objectToCliArgs< return [`${prefix}${value ? '' : 'no-'}${key}`]; } + // @TODO add support for nested objects `persist.filename` + throw new Error(`Unsupported type ${typeof value} for key ${key}`); }); } diff --git a/packages/utils/src/lib/report.spec.ts b/packages/utils/src/lib/report.spec.ts index 6ba9ccf62..d3396b2ef 100644 --- a/packages/utils/src/lib/report.spec.ts +++ b/packages/utils/src/lib/report.spec.ts @@ -1,15 +1,35 @@ -import { describe, expect } from 'vitest'; -import { CategoryRef } from '@code-pushup/models'; +import { vol } from 'memfs'; +import { afterEach, describe, expect, vi } from 'vitest'; +import { CategoryRef, IssueSeverity, PluginReport } from '@code-pushup/models'; +import { MEMFS_VOLUME, report } from '@code-pushup/models/testing'; import { calcDuration, compareIssueSeverity, countWeightedRefs, formatBytes, formatCount, + loadReport, slugify, sumRefs, } from './report'; +// Mock file system API's +vi.mock('fs', async () => { + const memfs: typeof import('memfs') = await vi.importActual('memfs'); + return memfs.fs; +}); +vi.mock('fs/promises', async () => { + const memfs: typeof import('memfs') = await vi.importActual('memfs'); + return memfs.fs.promises; +}); + +const outputDir = MEMFS_VOLUME; + +const resetFiles = (files?: Record) => { + vol.reset(); + vol.fromJSON(files || {}, outputDir); +}; + describe('slugify', () => { it.each([ ['Largest Contentful Paint', 'largest-contentful-paint'], @@ -151,3 +171,55 @@ describe('sumRefs', () => { expect(sumRefs(refs)).toBe(11); }); }); + +describe('loadReport', () => { + afterEach(() => { + resetFiles({}); + }); + + it('should load reports form outputDir', async () => { + const reportMock = report(); + resetFiles({ + [`report.json`]: JSON.stringify(reportMock), + [`report.md`]: 'test-42', + }); + const reports = await loadReport({ + outputDir, + filename: 'report', + format: 'json', + }); + expect(reports).toEqual(reportMock); + }); + + it('should load reports by format', async () => { + resetFiles({ + [`report.dummy.md`]: 'test-7', + [`report.json`]: '{"test":42}', + [`report.md`]: 'test-42', + }); + const reports = await loadReport({ + outputDir, + format: 'md', + filename: 'report', + }); + expect(reports).toBe('test-42'); + }); + + it('should throw for invalid json reports', async () => { + const reportMock = report(); + reportMock.plugins = [ + { + ...reportMock.plugins[0], + slug: '-Invalud_slug', + } as unknown as PluginReport, + ]; + + resetFiles({ + [`report.json`]: JSON.stringify(reportMock), + }); + + await expect( + loadReport({ outputDir, filename: 'report', format: 'json' }), + ).rejects.toThrow('validation'); + }); +}); diff --git a/packages/utils/src/lib/report.ts b/packages/utils/src/lib/report.ts index b997e7358..7908f09e6 100644 --- a/packages/utils/src/lib/report.ts +++ b/packages/utils/src/lib/report.ts @@ -1,9 +1,19 @@ +import { join } from 'path'; import { CategoryRef, IssueSeverity as CliIssueSeverity, + Format, + PersistConfig, + Report, + reportSchema, } from '@code-pushup/models'; import { ScoredReport } from './scoring'; -import { pluralize } from './utils'; +import { + ensureDirectoryExists, + pluralize, + readJsonFile, + readTextFile, +} from './utils'; export const FOOTER_PREFIX = 'Made with ❤️ by'; export const CODE_PUSHUP_DOMAIN = 'code-pushup.dev'; @@ -88,3 +98,23 @@ export function compareIssueSeverity( export function sumRefs(refs: CategoryRef[]) { return refs.reduce((sum, { weight }) => sum + weight, 0); } + +type LoadedReportFormat = T extends 'json' ? Report : string; + +export async function loadReport( + options: Required> & { + format: T; + }, +): Promise> { + const { outputDir, filename, format } = options; + await ensureDirectoryExists(outputDir); + const filePath = join(outputDir, `${filename}.${format}`); + + if (format === 'json') { + const content = await readJsonFile(filePath); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return reportSchema.parse(content) as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return readTextFile(filePath) as any; +} diff --git a/packages/utils/src/lib/utils.spec.ts b/packages/utils/src/lib/utils.spec.ts index 527edc52d..c9e50eafd 100644 --- a/packages/utils/src/lib/utils.spec.ts +++ b/packages/utils/src/lib/utils.spec.ts @@ -1,12 +1,31 @@ -import { describe, expect } from 'vitest'; +import { stat } from 'fs/promises'; +import { vol } from 'memfs'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MEMFS_VOLUME } from '@code-pushup/models/testing'; +import { mockConsole, unmockConsole } from '../../test/console.mock'; import { countOccurrences, distinct, + ensureDirectoryExists, + logMultipleFileResults, pluralize, toArray, toUnixPath, } from './utils'; +// Mock file system API's +vi.mock('fs', async () => { + const memfs: typeof import('memfs') = await vi.importActual('memfs'); + return memfs.fs; +}); +vi.mock('fs/promises', async () => { + const memfs: typeof import('memfs') = await vi.importActual('memfs'); + return memfs.fs.promises; +}); + +const outputDir = MEMFS_VOLUME; + describe('pluralize', () => { it.each([ ['warning', 'warnings'], @@ -75,3 +94,86 @@ describe('distinct', () => { ]); }); }); + +describe('ensureDirectoryExists', () => { + beforeEach(() => { + vol.reset(); + vol.fromJSON({}, outputDir); + }); + + it('should create folder', async () => { + const dir = join(MEMFS_VOLUME, 'sub', 'dir'); + await ensureDirectoryExists(dir); + await expect( + stat(dir).then(stats => stats.isDirectory()), + ).resolves.toBeTruthy(); + }); +}); + +describe('logMultipleFileResults', () => { + let logs: string[]; + const setupConsole = async () => { + logs = []; + mockConsole(msg => logs.push(msg)); + }; + const teardownConsole = async () => { + logs = []; + unmockConsole(); + }; + + beforeEach(async () => { + logs = []; + setupConsole(); + }); + + afterEach(() => { + teardownConsole(); + }); + + it('should log reports correctly`', async () => { + logMultipleFileResults( + [{ status: 'fulfilled', value: ['out.json'] }], + 'Uploaded reports', + ); + expect(logs).toHaveLength(2); + expect(logs[0]).toContain('Uploaded reports successfully: '); + expect(logs[1]).toContain('- out.json'); + }); + + it('should log report sizes correctly`', async () => { + logMultipleFileResults( + [{ status: 'fulfilled', value: ['out.json', 10000] }], + 'Generated reports', + ); + expect(logs).toHaveLength(2); + expect(logs[0]).toContain('Generated reports successfully: '); + expect(logs[1]).toContain('- out.json (9.77 kB)'); + }); + + it('should log fails correctly`', async () => { + logMultipleFileResults( + [{ status: 'rejected', reason: 'fail' }], + 'Generated reports', + ); + expect(logs).toHaveLength(2); + + expect(logs).toContain('Generated reports failed: '); + expect(logs).toContain('- fail'); + }); + + it('should log report sizes and fails correctly`', async () => { + logMultipleFileResults( + [ + { status: 'fulfilled', value: ['out.json', 10000] }, + { status: 'rejected', reason: 'fail' }, + ], + 'Generated reports', + ); + expect(logs).toHaveLength(4); + expect(logs).toContain('Generated reports successfully: '); + expect(logs).toContain('- out.json (9.77 kB)'); + + expect(logs).toContain('Generated reports failed: '); + expect(logs).toContain('- fail'); + }); +}); diff --git a/packages/utils/src/lib/utils.ts b/packages/utils/src/lib/utils.ts index f246ceaaa..36411e348 100644 --- a/packages/utils/src/lib/utils.ts +++ b/packages/utils/src/lib/utils.ts @@ -1,5 +1,8 @@ -import { readFile } from 'fs/promises'; +import chalk from 'chalk'; +import { mkdir, readFile } from 'fs/promises'; +import { formatBytes } from './report'; +// @TODO move logic out of this file as much as possible. use report.ts or scoring.ts instead. export const reportHeadlineText = 'Code PushUp Report'; export const reportOverviewTableHeaders = [ '🏷 Category', @@ -64,24 +67,16 @@ export function countOccurrences( ); } -export function toUnixPath( - path: string, - options?: { toRelative?: boolean }, -): string { - const unixPath = path.replace(/\\/g, '/'); - - if (options?.toRelative) { - return unixPath.replace(process.cwd().replace(/\\/g, '/') + '/', ''); - } - - return unixPath; +export function distinct(array: T[]): T[] { + return Array.from(new Set(array)); } +// @TODO move to report.ts export function formatReportScore(score: number): string { return Math.round(score * 100).toString(); } -// === Markdown +// === Markdown @TODO move to report-to-md.ts export function getRoundScoreMarker(score: number): string { if (score >= 0.9) { @@ -115,13 +110,32 @@ export function getSeverityIcon( return 'ℹ️'; } -// === Validation +// === Filesystem @TODO move to fs-utils.ts -export function distinct(array: T[]): T[] { - return Array.from(new Set(array)); +export function toUnixPath( + path: string, + options?: { toRelative?: boolean }, +): string { + const unixPath = path.replace(/\\/g, '/'); + + if (options?.toRelative) { + return unixPath.replace(process.cwd().replace(/\\/g, '/') + '/', ''); + } + + return unixPath; } -// === File +export async function ensureDirectoryExists(baseDir: string) { + try { + await mkdir(baseDir, { recursive: true }); + return; + } catch (error) { + console.log((error as { code: string; message: string }).message); + if ((error as { code: string }).code !== 'EEXIST') { + throw error; + } + } +} export async function readTextFile(path: string): Promise { const buffer = await readFile(path); @@ -132,3 +146,38 @@ export async function readJsonFile(path: string): Promise { const text = await readTextFile(path); return JSON.parse(text); } + +export type FileResult = readonly [string] | readonly [string, number]; +export type MultipleFileResults = PromiseSettledResult[]; + +export function logMultipleFileResults( + persistResult: MultipleFileResults, + messagePrefix: string, +) { + const succeededPersistedResults = persistResult.filter( + (result): result is PromiseFulfilledResult<[string, number]> => + result.status === 'fulfilled', + ); + + if (succeededPersistedResults.length) { + console.log(`${messagePrefix} successfully: `); + succeededPersistedResults.forEach(res => { + const [fileName, size] = res.value; + console.log( + `- ${chalk.bold(fileName)}` + + (size ? ` (${chalk.gray(formatBytes(size))})` : ''), + ); + }); + } + + const failedPersistedResults = persistResult.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + + if (failedPersistedResults.length) { + console.log(`${messagePrefix} failed: `); + failedPersistedResults.forEach(result => { + console.log(`- ${chalk.bold(result.reason)}`); + }); + } +} diff --git a/packages/utils/test/fs.mock.ts b/packages/utils/test/fs.mock.ts deleted file mode 100644 index 6e28f9635..000000000 --- a/packages/utils/test/fs.mock.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { mkdirSync, rmSync, writeFileSync } from 'fs'; -import { join } from 'path'; - -export function cleanFolder( - dirName = 'tmp', - content?: { [key in keyof T]: string }, -) { - rmSync(dirName, { recursive: true, force: true }); - mkdirSync(dirName); - if (content) { - for (const fileName in content) { - writeFileSync(join(dirName, fileName), content[fileName]); - } - } -} - -export function cleanFolderPutGitKeep( - dirName = 'tmp', - content?: { [key in keyof T]: string }, -) { - rmSync(dirName, { recursive: true, force: true }); - mkdirSync(dirName); - writeFileSync(join(dirName, '.gitkeep'), ''); - if (content) { - for (const fileName in content) { - writeFileSync(join(dirName, fileName), content[fileName]); - } - } -} diff --git a/packages/utils/test/index.ts b/packages/utils/test/index.ts index 76521fc33..f59f96902 100644 --- a/packages/utils/test/index.ts +++ b/packages/utils/test/index.ts @@ -1,5 +1,4 @@ export { mockConsole, unmockConsole } from './console.mock'; -export * from './fs.mock'; export { mockProcessConfig, getAsyncProcessRunnerConfig,