From cc030f0e53753c2c994c109a445e9aa71ce8722e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Wed, 4 Dec 2024 11:25:22 +0100 Subject: [PATCH 01/38] feat(ci): detect persist config from print-config --- e2e/ci-e2e/tests/ci.e2e.test.ts | 25 +- packages/ci/README.md | 6 +- packages/ci/package.json | 3 +- packages/ci/src/lib/cli/commands/collect.ts | 14 +- packages/ci/src/lib/cli/commands/compare.ts | 14 +- .../ci/src/lib/cli/commands/merge-diffs.ts | 27 ++- .../ci/src/lib/cli/commands/print-config.ts | 11 +- packages/ci/src/lib/cli/context.ts | 8 +- packages/ci/src/lib/cli/context.unit.test.ts | 44 +--- packages/ci/src/lib/cli/index.ts | 2 +- packages/ci/src/lib/cli/persist.ts | 124 +++------- packages/ci/src/lib/cli/persist.unit.test.ts | 216 ++++++++---------- packages/ci/src/lib/models.ts | 14 +- packages/ci/src/lib/run.integration.test.ts | 98 +++----- packages/ci/src/lib/run.ts | 129 +++++++---- packages/models/src/lib/core-config.ts | 4 +- 16 files changed, 294 insertions(+), 445 deletions(-) diff --git a/e2e/ci-e2e/tests/ci.e2e.test.ts b/e2e/ci-e2e/tests/ci.e2e.test.ts index 96d85f9ac..f631dac12 100644 --- a/e2e/ci-e2e/tests/ci.e2e.test.ts +++ b/e2e/ci-e2e/tests/ci.e2e.test.ts @@ -87,13 +87,10 @@ describe('CI package', () => { ), ).resolves.toEqual({ mode: 'standalone', - artifacts: { + files: { report: { - rootDir: outputDir, - files: [ - join(outputDir, 'report.json'), - join(outputDir, 'report.md'), - ], + json: join(outputDir, 'report.json'), + md: join(outputDir, 'report.md'), }, }, } satisfies RunResult); @@ -156,20 +153,14 @@ describe('CI package', () => { mode: 'standalone', commentId: comment.id, newIssues: [], - artifacts: { + files: { report: { - rootDir: outputDir, - files: [ - join(outputDir, 'report.json'), - join(outputDir, 'report.md'), - ], + json: join(outputDir, 'report.json'), + md: join(outputDir, 'report.md'), }, diff: { - rootDir: outputDir, - files: [ - join(outputDir, 'report-diff.json'), - join(outputDir, 'report-diff.md'), - ], + json: join(outputDir, 'report-diff.json'), + md: join(outputDir, 'report-diff.md'), }, }, } satisfies RunResult); diff --git a/packages/ci/README.md b/packages/ci/README.md index 3a5831254..31f59d63e 100644 --- a/packages/ci/README.md +++ b/packages/ci/README.md @@ -138,7 +138,7 @@ const result = await runInCI(refs, api); if (result.mode === 'standalone') { const { // output files, can be uploaded as job artifact - artifacts: { report, diff }, + files: { report, diff }, // ID of created/updated PR comment commentId, // array of source code issues, can be used to annotate changed files in PR @@ -211,7 +211,7 @@ if (result.mode === 'monorepo') { // ID of created/updated PR comment commentId, // merged report-diff.md used in PR comment, can also be uploaded as job artifact - diffArtifact, + diffPath, } = result; for (const project of projects) { @@ -219,7 +219,7 @@ if (result.mode === 'monorepo') { // detected project name (from package.json, project.json or folder name) name, // output files, can be uploaded as job artifacts - artifacts: { report, diff }, + files: { report, diff }, // array of source code issues, can be used to annotate changed files in PR newIssues, } = project; diff --git a/packages/ci/package.json b/packages/ci/package.json index a49066a1d..c6c030b79 100644 --- a/packages/ci/package.json +++ b/packages/ci/package.json @@ -30,6 +30,7 @@ "@code-pushup/utils": "0.56.0", "glob": "^10.4.5", "simple-git": "^3.20.0", - "yaml": "^2.5.1" + "yaml": "^2.5.1", + "zod": "^3.22.1" } } diff --git a/packages/ci/src/lib/cli/commands/collect.ts b/packages/ci/src/lib/cli/commands/collect.ts index 754f0291c..ae24f66b7 100644 --- a/packages/ci/src/lib/cli/commands/collect.ts +++ b/packages/ci/src/lib/cli/commands/collect.ts @@ -1,30 +1,22 @@ +import { DEFAULT_PERSIST_FORMAT } from '@code-pushup/models'; import { executeProcess } from '@code-pushup/utils'; import type { CommandContext } from '../context.js'; -import { - type PersistedCliFiles, - persistCliOptions, - persistedCliFiles, -} from '../persist.js'; export async function runCollect({ bin, config, directory, silent, - project, - output, -}: CommandContext): Promise { +}: CommandContext): Promise { const { stdout } = await executeProcess({ command: bin, args: [ ...(config ? [`--config=${config}`] : []), - ...persistCliOptions({ directory, project, output }), + ...DEFAULT_PERSIST_FORMAT.map(format => `--persist.format=${format}`), ], cwd: directory, }); if (!silent) { console.info(stdout); } - - return persistedCliFiles({ directory, project, output }); } diff --git a/packages/ci/src/lib/cli/commands/compare.ts b/packages/ci/src/lib/cli/commands/compare.ts index 34ff4ee02..a28f2126e 100644 --- a/packages/ci/src/lib/cli/commands/compare.ts +++ b/packages/ci/src/lib/cli/commands/compare.ts @@ -1,10 +1,6 @@ +import { DEFAULT_PERSIST_FORMAT } from '@code-pushup/models'; import { executeProcess } from '@code-pushup/utils'; import type { CommandContext } from '../context.js'; -import { - type PersistedCliFiles, - persistCliOptions, - persistedCliFiles, -} from '../persist.js'; type CompareOptions = { before: string; @@ -14,8 +10,8 @@ type CompareOptions = { export async function runCompare( { before, after, label }: CompareOptions, - { bin, config, directory, silent, project, output }: CommandContext, -): Promise { + { bin, config, directory, silent }: CommandContext, +): Promise { const { stdout } = await executeProcess({ command: bin, args: [ @@ -24,13 +20,11 @@ export async function runCompare( `--after=${after}`, ...(label ? [`--label=${label}`] : []), ...(config ? [`--config=${config}`] : []), - ...persistCliOptions({ directory, project, output }), + ...DEFAULT_PERSIST_FORMAT.map(format => `--persist.format=${format}`), ], cwd: directory, }); if (!silent) { console.info(stdout); } - - return persistedCliFiles({ directory, isDiff: true, project, output }); } diff --git a/packages/ci/src/lib/cli/commands/merge-diffs.ts b/packages/ci/src/lib/cli/commands/merge-diffs.ts index 1a6528acd..b6a4284f2 100644 --- a/packages/ci/src/lib/cli/commands/merge-diffs.ts +++ b/packages/ci/src/lib/cli/commands/merge-diffs.ts @@ -1,22 +1,26 @@ +import { join } from 'node:path'; +import { + DEFAULT_PERSIST_FILENAME, + DEFAULT_PERSIST_OUTPUT_DIR, +} from '@code-pushup/models'; import { executeProcess } from '@code-pushup/utils'; import type { CommandContext } from '../context.js'; -import { - type PersistedCliFiles, - persistCliOptions, - persistedCliFiles, -} from '../persist.js'; export async function runMergeDiffs( files: string[], - { bin, config, directory, silent, output }: CommandContext, -): Promise> { + { bin, config, directory, silent }: CommandContext, +): Promise { + const outputDir = join(process.cwd(), DEFAULT_PERSIST_OUTPUT_DIR); + const filename = `merged-${DEFAULT_PERSIST_FILENAME}`; + const { stdout } = await executeProcess({ command: bin, args: [ 'merge-diffs', ...files.map(file => `--files=${file}`), ...(config ? [`--config=${config}`] : []), - ...persistCliOptions({ directory, output }), + `--persist.outputDir=${outputDir}`, + `--persist.filename=${filename}`, ], cwd: directory, }); @@ -24,10 +28,5 @@ export async function runMergeDiffs( console.info(stdout); } - return persistedCliFiles({ - directory, - isDiff: true, - formats: ['md'], - output, - }); + return join(outputDir, `${filename}-diff.md`); } diff --git a/packages/ci/src/lib/cli/commands/print-config.ts b/packages/ci/src/lib/cli/commands/print-config.ts index ca7a5e455..b2e69182a 100644 --- a/packages/ci/src/lib/cli/commands/print-config.ts +++ b/packages/ci/src/lib/cli/commands/print-config.ts @@ -1,4 +1,4 @@ -import { executeProcess } from '@code-pushup/utils'; +import { executeProcess, stringifyError } from '@code-pushup/utils'; import type { CommandContext } from '../context.js'; export async function runPrintConfig({ @@ -6,7 +6,7 @@ export async function runPrintConfig({ config, directory, silent, -}: CommandContext): Promise { +}: CommandContext): Promise { const { stdout } = await executeProcess({ command: bin, args: [...(config ? [`--config=${config}`] : []), 'print-config'], @@ -15,4 +15,11 @@ export async function runPrintConfig({ if (!silent) { console.info(stdout); } + try { + return JSON.parse(stdout) as unknown; + } catch (error) { + throw new Error( + `Error parsing output of print-config command - ${stringifyError(error)}`, + ); + } } diff --git a/packages/ci/src/lib/cli/context.ts b/packages/ci/src/lib/cli/context.ts index 3ed0d682a..82184ee95 100644 --- a/packages/ci/src/lib/cli/context.ts +++ b/packages/ci/src/lib/cli/context.ts @@ -3,21 +3,17 @@ import type { ProjectConfig } from '../monorepo/index.js'; export type CommandContext = Pick< Settings, - 'bin' | 'config' | 'directory' | 'silent' | 'output' -> & { - project?: string; -}; + 'bin' | 'config' | 'directory' | 'silent' +>; export function createCommandContext( settings: Settings, project: ProjectConfig | null | undefined, ): CommandContext { return { - project: project?.name, bin: project?.bin ?? settings.bin, directory: project?.directory ?? settings.directory, config: settings.config, silent: settings.silent, - output: settings.output.replaceAll('{project}', project?.name ?? ''), }; } diff --git a/packages/ci/src/lib/cli/context.unit.test.ts b/packages/ci/src/lib/cli/context.unit.test.ts index f6489d7c0..6366bc896 100644 --- a/packages/ci/src/lib/cli/context.unit.test.ts +++ b/packages/ci/src/lib/cli/context.unit.test.ts @@ -1,4 +1,3 @@ -import { DEFAULT_SETTINGS } from '../constants.js'; import { type CommandContext, createCommandContext } from './context.js'; describe('createCommandContext', () => { @@ -13,6 +12,7 @@ describe('createCommandContext', () => { directory: '/test', logger: console, monorepo: false, + nxProjectsFilter: '--with-target={task}', output: '.code-pushup', projects: null, silent: false, @@ -21,12 +21,10 @@ describe('createCommandContext', () => { null, ), ).toStrictEqual({ - project: undefined, bin: 'npx --no-install code-pushup', directory: '/test', config: null, silent: false, - output: '.code-pushup', }); }); @@ -41,6 +39,7 @@ describe('createCommandContext', () => { directory: '/test', logger: console, monorepo: false, + nxProjectsFilter: '--with-target={task}', output: '.code-pushup', projects: null, silent: false, @@ -53,49 +52,10 @@ describe('createCommandContext', () => { }, ), ).toStrictEqual({ - project: 'ui', bin: 'yarn code-pushup', directory: '/test/ui', config: null, silent: false, - output: '.code-pushup', }); }); - - it('should interpolate project name in output path for monorepo project', () => { - expect( - createCommandContext( - { - ...DEFAULT_SETTINGS, - output: '.code-pushup/{project}', - }, - { - name: 'website', - bin: 'npx nx run website:code-pushup --', - }, - ), - ).toEqual( - expect.objectContaining>({ - project: 'website', - bin: 'npx nx run website:code-pushup --', - output: '.code-pushup/website', - }), - ); - }); - - it('should omit {project} placeholder in output path when in standalone mode', () => { - expect( - createCommandContext( - { - ...DEFAULT_SETTINGS, - output: '.code-pushup/{project}', - }, - undefined, - ), - ).toEqual( - expect.objectContaining>({ - output: '.code-pushup/', - }), - ); - }); }); diff --git a/packages/ci/src/lib/cli/index.ts b/packages/ci/src/lib/cli/index.ts index a472958bf..f252efdc7 100644 --- a/packages/ci/src/lib/cli/index.ts +++ b/packages/ci/src/lib/cli/index.ts @@ -3,4 +3,4 @@ export { runCompare } from './commands/compare.js'; export { runMergeDiffs } from './commands/merge-diffs.js'; export { runPrintConfig } from './commands/print-config.js'; export { createCommandContext, type CommandContext } from './context.js'; -export { findPersistedFiles, type PersistedCliFiles } from './persist.js'; +export { persistedFilesFromConfig } from './persist.js'; diff --git a/packages/ci/src/lib/cli/persist.ts b/packages/ci/src/lib/cli/persist.ts index 80326e447..f4cebf9ef 100644 --- a/packages/ci/src/lib/cli/persist.ts +++ b/packages/ci/src/lib/cli/persist.ts @@ -1,106 +1,44 @@ -import path from 'node:path'; +import { isAbsolute, join } from 'node:path'; +import { z } from 'zod'; import { + type CoreConfig, DEFAULT_PERSIST_FILENAME, DEFAULT_PERSIST_FORMAT, + DEFAULT_PERSIST_OUTPUT_DIR, type Format, + persistConfigSchema, } from '@code-pushup/models'; -import { projectToFilename } from '@code-pushup/utils'; +import { objectFromEntries, stringifyError } from '@code-pushup/utils'; -export type PersistedCliFiles = - PersistedCliFilesFormats & { - artifactData: { - rootDir: string; - files: string[]; - }; - }; +export function persistedFilesFromConfig( + config: Pick, + { isDiff, directory }: { isDiff?: boolean; directory: string }, +): Record { + const { + persist: { + outputDir = DEFAULT_PERSIST_OUTPUT_DIR, + filename = DEFAULT_PERSIST_FILENAME, + } = {}, + } = config; -export type PersistedCliFilesFormats = { - [F in T as `${F}FilePath`]: string; -}; + const dir = isAbsolute(outputDir) ? outputDir : join(directory, outputDir); + const name = isDiff ? `${filename}-diff` : filename; -export function persistCliOptions({ - directory, - project, - output, -}: { - directory: string; - project?: string; - output: string; -}): string[] { - return [ - `--persist.outputDir=${path.join(directory, output)}`, - `--persist.filename=${createFilename(project)}`, - ...DEFAULT_PERSIST_FORMAT.map(format => `--persist.format=${format}`), - ]; -} - -export function persistedCliFiles({ - directory, - isDiff, - project, - formats, - output, -}: { - directory: string; - isDiff?: boolean; - project?: string; - formats?: TFormat[]; - output: string; -}): PersistedCliFiles { - const rootDir = path.join(directory, output); - const filename = isDiff - ? `${createFilename(project)}-diff` - : createFilename(project); - const filePaths = (formats ?? DEFAULT_PERSIST_FORMAT).reduce( - (acc, format) => ({ - ...acc, - [`${format}FilePath`]: path.join(rootDir, `${filename}.${format}`), - }), - // eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter, @typescript-eslint/consistent-type-assertions - {} as PersistedCliFilesFormats, + return objectFromEntries( + DEFAULT_PERSIST_FORMAT.map(format => [ + format, + join(dir, `${name}.${format}`), + ]), ); - const files = Object.values(filePaths); - - return { - ...filePaths, - artifactData: { - rootDir, - files, - }, - }; -} - -export function findPersistedFiles({ - rootDir, - files, - project, -}: { - rootDir: string; - files: string[]; - project?: string; -}): PersistedCliFiles { - const filename = createFilename(project); - const filePaths = DEFAULT_PERSIST_FORMAT.reduce((acc, format) => { - const matchedFile = files.find(file => file === `${filename}.${format}`); - if (!matchedFile) { - return acc; - } - return { ...acc, [`${format}FilePath`]: path.join(rootDir, matchedFile) }; - // eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter, @typescript-eslint/consistent-type-assertions - }, {} as PersistedCliFilesFormats); - return { - ...filePaths, - artifactData: { - rootDir, - files: Object.values(filePaths), - }, - }; } -function createFilename(project: string | undefined): string { - if (!project) { - return DEFAULT_PERSIST_FILENAME; +export async function parsePersistConfig( + json: unknown, +): Promise> { + const schema = z.object({ persist: persistConfigSchema.optional() }); + const result = await schema.safeParseAsync(json); + if (result.error) { + throw new Error(`Invalid persist config - ${stringifyError(result.error)}`); } - const prefix = projectToFilename(project); - return `${prefix}-${DEFAULT_PERSIST_FILENAME}`; + return result.data; } diff --git a/packages/ci/src/lib/cli/persist.unit.test.ts b/packages/ci/src/lib/cli/persist.unit.test.ts index cf599efa7..3470fc714 100644 --- a/packages/ci/src/lib/cli/persist.unit.test.ts +++ b/packages/ci/src/lib/cli/persist.unit.test.ts @@ -1,152 +1,120 @@ import { join } from 'node:path'; -import { - type PersistedCliFiles, - findPersistedFiles, - persistCliOptions, - persistedCliFiles, -} from './persist.js'; +import type { CoreConfig } from '@code-pushup/models'; +import { parsePersistConfig, persistedFilesFromConfig } from './persist.js'; -describe('persistCliOptions', () => { - it('should create CLI arguments for standalone project', () => { +describe('persistedFilesFromConfig', () => { + it('should return default report paths when no config is set', () => { + expect(persistedFilesFromConfig({}, { directory: process.cwd() })).toEqual({ + json: join(process.cwd(), '.code-pushup', 'report.json'), + md: join(process.cwd(), '.code-pushup', 'report.md'), + }); + }); + + it('should return default diff paths when no config is set', () => { expect( - persistCliOptions({ - directory: process.cwd(), - output: '.code-pushup', - }), - ).toEqual([ - `--persist.outputDir=${join(process.cwd(), '.code-pushup')}`, - '--persist.filename=report', - '--persist.format=json', - '--persist.format=md', - ]); + persistedFilesFromConfig( + { persist: {} }, + { directory: process.cwd(), isDiff: true }, + ), + ).toEqual({ + json: join(process.cwd(), '.code-pushup', 'report-diff.json'), + md: join(process.cwd(), '.code-pushup', 'report-diff.md'), + }); }); - it('should create CLI arguments for monorepo project', () => { + it('should return diff paths with filename from config', () => { expect( - persistCliOptions({ - project: 'utils', - directory: process.cwd(), - output: '.code-pushup', - }), - ).toEqual([ - `--persist.outputDir=${join(process.cwd(), '.code-pushup')}`, - '--persist.filename=utils-report', - '--persist.format=json', - '--persist.format=md', - ]); + persistedFilesFromConfig( + { persist: { filename: 'merged-report' } }, + { directory: process.cwd(), isDiff: true }, + ), + ).toEqual({ + json: join(process.cwd(), '.code-pushup', 'merged-report-diff.json'), + md: join(process.cwd(), '.code-pushup', 'merged-report-diff.md'), + }); }); -}); -describe('persistedCliFiles', () => { - it('should determine persisted files for standalone report', () => { + it('should return report paths with outputDir from config', () => { expect( - persistedCliFiles({ - directory: process.cwd(), - output: '.code-pushup', - }), - ).toEqual({ - jsonFilePath: join(process.cwd(), '.code-pushup/report.json'), - mdFilePath: join(process.cwd(), '.code-pushup/report.md'), - artifactData: { - rootDir: join(process.cwd(), '.code-pushup'), - files: [ - join(process.cwd(), '.code-pushup/report.json'), - join(process.cwd(), '.code-pushup/report.md'), - ], - }, + persistedFilesFromConfig( + { persist: { outputDir: 'tmp' } }, + { directory: process.cwd() }, + ), + ).toEqual({ + json: join(process.cwd(), 'tmp', 'report.json'), + md: join(process.cwd(), 'tmp', 'report.md'), }); }); - it('should determine persisted files for monorepo report', () => { + it('should append relative outputDir to working directory', () => { expect( - persistedCliFiles({ - directory: process.cwd(), - output: '.code-pushup/auth', - project: 'auth', - }), - ).toEqual({ - jsonFilePath: join(process.cwd(), '.code-pushup/auth/auth-report.json'), - mdFilePath: join(process.cwd(), '.code-pushup/auth/auth-report.md'), - artifactData: { - rootDir: join(process.cwd(), '.code-pushup/auth'), - files: [ - join(process.cwd(), '.code-pushup/auth/auth-report.json'), - join(process.cwd(), '.code-pushup/auth/auth-report.md'), - ], - }, + persistedFilesFromConfig( + { persist: { outputDir: 'tmp' } }, + { directory: join(process.cwd(), 'backend') }, + ), + ).toEqual({ + json: join(process.cwd(), 'backend', 'tmp', 'report.json'), + md: join(process.cwd(), 'backend', 'tmp', 'report.md'), }); }); - it('should determine persisted files for diff in Markdown format only', () => { + it('should ignore working directory when absolute outputDir in config', () => { expect( - persistedCliFiles({ - directory: process.cwd(), - output: '.code-pushup', - isDiff: true, - formats: ['md'], - }), - ).toEqual>({ - mdFilePath: join(process.cwd(), '.code-pushup/report-diff.md'), - artifactData: { - rootDir: join(process.cwd(), '.code-pushup'), - files: [join(process.cwd(), '.code-pushup/report-diff.md')], - }, + persistedFilesFromConfig( + { persist: { outputDir: join(process.cwd(), 'tmp') } }, + { directory: join(process.cwd(), 'backend') }, + ), + ).toEqual({ + json: join(process.cwd(), 'tmp', 'report.json'), + md: join(process.cwd(), 'tmp', 'report.md'), }); }); }); -describe('findPersistedFiles', () => { - it('should find report files in artifact data for standalone project', () => { - expect( - findPersistedFiles({ - rootDir: join(process.cwd(), '.code-pushup'), - files: [ - 'report-diff.json', - 'report-diff.md', - 'report.json', - 'report.md', - ], - }), - ).toEqual({ - jsonFilePath: join(process.cwd(), '.code-pushup/report.json'), - mdFilePath: join(process.cwd(), '.code-pushup/report.md'), - artifactData: { - rootDir: join(process.cwd(), '.code-pushup'), - files: [ - join(process.cwd(), '.code-pushup/report.json'), - join(process.cwd(), '.code-pushup/report.md'), - ], +describe('parsePersistConfig', () => { + it('should validate only persist config', async () => { + await expect( + parsePersistConfig({ + persist: { + outputDir: '.code-pushup', + filename: 'report', + format: ['json', 'md'], + }, + // missing props (slug, etc.) + plugins: [{ title: 'some plugin', audits: [{ title: 'some audit' }] }], + } as CoreConfig), + ).resolves.toEqual({ + persist: { + outputDir: '.code-pushup', + filename: 'report', + format: ['json', 'md'], }, }); }); - it('should find report files in artifact data for monorepo project', () => { - expect( - findPersistedFiles({ - rootDir: join(process.cwd(), '.code-pushup'), - files: [ - 'backend-report-diff.json', - 'backend-report-diff.md', - 'backend-report.json', - 'backend-report.md', - 'frontend-report-diff.json', - 'frontend-report-diff.md', - 'frontend-report.json', - 'frontend-report.md', - 'report-diff.md', - ], - project: 'frontend', - }), - ).toEqual({ - jsonFilePath: join(process.cwd(), '.code-pushup/frontend-report.json'), - mdFilePath: join(process.cwd(), '.code-pushup/frontend-report.md'), - artifactData: { - rootDir: join(process.cwd(), '.code-pushup'), - files: [ - join(process.cwd(), '.code-pushup/frontend-report.json'), - join(process.cwd(), '.code-pushup/frontend-report.md'), - ], - }, + it('should accept missing persist config', async () => { + await expect(parsePersistConfig({})).resolves.toEqual({}); + }); + + it('should accept empty persist config', async () => { + await expect(parsePersistConfig({ persist: {} })).resolves.toEqual({ + persist: {}, }); }); + + it('should accept partial persist config', async () => { + await expect( + parsePersistConfig({ persist: { outputDir: 'tmp' } }), + ).resolves.toEqual({ + persist: { outputDir: 'tmp' }, + }); + }); + + it('should error if persist config is invalid', async () => { + await expect( + parsePersistConfig({ persist: { format: ['json', 'html'] } }), + ).rejects.toThrow( + /^Invalid persist config - ZodError:.*Invalid enum value. Expected 'json' \| 'md', received 'html'/s, + ); + }); }); diff --git a/packages/ci/src/lib/models.ts b/packages/ci/src/lib/models.ts index 1ed578cd8..a98232480 100644 --- a/packages/ci/src/lib/models.ts +++ b/packages/ci/src/lib/models.ts @@ -1,3 +1,4 @@ +import type { Format } from '@code-pushup/models'; import type { SourceFileIssue } from './issues.js'; import type { MonorepoTool } from './monorepo/index.js'; @@ -92,7 +93,7 @@ export type MonorepoRunResult = { mode: 'monorepo'; projects: ProjectRunResult[]; commentId?: number; - diffArtifact?: ArtifactData; + diffPath?: string; }; /** @@ -100,9 +101,9 @@ export type MonorepoRunResult = { */ export type ProjectRunResult = { name: string; - artifacts: { - report: ArtifactData; - diff?: ArtifactData; + files: { + report: OutputFiles; + diff?: OutputFiles; }; newIssues?: SourceFileIssue[]; }; @@ -110,7 +111,4 @@ export type ProjectRunResult = { /** * Paths to output files from {@link runInCI}, for uploading as job artifact */ -export type ArtifactData = { - rootDir: string; - files: string[]; -}; +export type OutputFiles = Record; diff --git a/packages/ci/src/lib/run.integration.test.ts b/packages/ci/src/lib/run.integration.test.ts index 80c2ae9d4..9b10df09c 100644 --- a/packages/ci/src/lib/run.integration.test.ts +++ b/packages/ci/src/lib/run.integration.test.ts @@ -143,26 +143,23 @@ describe('runInCI', () => { ), ).resolves.toEqual({ mode: 'standalone', - artifacts: { + files: { report: { - rootDir: outputDir, - files: [ - join(outputDir, 'report.json'), - join(outputDir, 'report.md'), - ], + json: join(outputDir, 'report.json'), + md: join(outputDir, 'report.md'), }, }, } satisfies RunResult); - expect(utils.executeProcess).toHaveBeenCalledTimes(1); - expect(utils.executeProcess).toHaveBeenCalledWith({ + expect(utils.executeProcess).toHaveBeenCalledTimes(2); + expect(utils.executeProcess).toHaveBeenNthCalledWith(1, { command: options.bin, - args: [ - `--persist.outputDir=${outputDir}`, - '--persist.filename=report', - '--persist.format=json', - '--persist.format=md', - ], + args: ['print-config'], + cwd: workDir, + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { + command: options.bin, + args: ['--persist.format=json', '--persist.format=md'], cwd: workDir, } satisfies utils.ProcessConfig); @@ -214,20 +211,14 @@ describe('runInCI', () => { mode: 'standalone', commentId: mockComment.id, newIssues: [], - artifacts: { + files: { report: { - rootDir: outputDir, - files: [ - join(outputDir, 'report.json'), - join(outputDir, 'report.md'), - ], + json: join(outputDir, 'report.json'), + md: join(outputDir, 'report.md'), }, diff: { - rootDir: outputDir, - files: [ - join(outputDir, 'report-diff.json'), - join(outputDir, 'report-diff.md'), - ], + json: join(outputDir, 'report-diff.json'), + md: join(outputDir, 'report-diff.md'), }, }, } satisfies RunResult); @@ -238,40 +229,33 @@ describe('runInCI', () => { ); expect(api.updateComment).not.toHaveBeenCalled(); - expect(utils.executeProcess).toHaveBeenCalledTimes(4); + expect(utils.executeProcess).toHaveBeenCalledTimes(5); expect(utils.executeProcess).toHaveBeenNthCalledWith(1, { command: options.bin, - args: [ - `--persist.outputDir=${outputDir}`, - '--persist.filename=report', - '--persist.format=json', - '--persist.format=md', - ], + args: ['print-config'], cwd: workDir, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { command: options.bin, - args: ['print-config'], + args: ['--persist.format=json', '--persist.format=md'], cwd: workDir, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(3, { command: options.bin, - args: [ - `--persist.outputDir=${outputDir}`, - '--persist.filename=report', - '--persist.format=json', - '--persist.format=md', - ], + args: ['print-config'], cwd: workDir, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(4, { + command: options.bin, + args: ['--persist.format=json', '--persist.format=md'], + cwd: workDir, + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenNthCalledWith(5, { command: options.bin, args: [ 'compare', `--before=${join(outputDir, 'prev-report.json')}`, `--after=${join(outputDir, 'curr-report.json')}`, - `--persist.outputDir=${outputDir}`, - '--persist.filename=report', '--persist.format=json', '--persist.format=md', ], @@ -304,20 +288,14 @@ describe('runInCI', () => { mode: 'standalone', commentId: mockComment.id, newIssues: [], - artifacts: { + files: { report: { - rootDir: outputDir, - files: [ - join(outputDir, 'report.json'), - join(outputDir, 'report.md'), - ], + json: join(outputDir, 'report.json'), + md: join(outputDir, 'report.md'), }, diff: { - rootDir: outputDir, - files: [ - join(outputDir, 'report-diff.json'), - join(outputDir, 'report-diff.md'), - ], + json: join(outputDir, 'report-diff.json'), + md: join(outputDir, 'report-diff.md'), }, }, } satisfies RunResult); @@ -330,25 +308,23 @@ describe('runInCI', () => { expect(api.createComment).not.toHaveBeenCalled(); expect(api.downloadReportArtifact).toHaveBeenCalledWith(undefined); - expect(utils.executeProcess).toHaveBeenCalledTimes(2); + expect(utils.executeProcess).toHaveBeenCalledTimes(3); expect(utils.executeProcess).toHaveBeenNthCalledWith(1, { command: options.bin, - args: [ - `--persist.outputDir=${outputDir}`, - '--persist.filename=report', - '--persist.format=json', - '--persist.format=md', - ], + args: ['print-config'], cwd: workDir, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { + command: options.bin, + args: ['--persist.format=json', '--persist.format=md'], + cwd: workDir, + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenNthCalledWith(3, { command: options.bin, args: [ 'compare', `--before=${join(outputDir, 'prev-report.json')}`, `--after=${join(outputDir, 'curr-report.json')}`, - `--persist.outputDir=${outputDir}`, - '--persist.filename=report', '--persist.format=json', '--persist.format=md', ], diff --git a/packages/ci/src/lib/run.ts b/packages/ci/src/lib/run.ts index b3ac85e41..1d5b865be 100644 --- a/packages/ci/src/lib/run.ts +++ b/packages/ci/src/lib/run.ts @@ -1,17 +1,18 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { type SimpleGit, simpleGit } from 'simple-git'; -import type { Report, ReportsDiff } from '@code-pushup/models'; +import type { CoreConfig, Report, ReportsDiff } from '@code-pushup/models'; import { stringifyError } from '@code-pushup/utils'; import { type CommandContext, - type PersistedCliFiles, createCommandContext, + persistedFilesFromConfig, runCollect, runCompare, runMergeDiffs, runPrintConfig, } from './cli/index.js'; +import { parsePersistConfig } from './cli/persist.js'; import { commentOnPR } from './comment.js'; import { DEFAULT_SETTINGS } from './constants.js'; import { listChangedFiles } from './git.js'; @@ -21,6 +22,7 @@ import type { GitRefs, Logger, Options, + OutputFiles, ProjectRunResult, ProviderAPIClient, RunResult, @@ -57,48 +59,44 @@ export async function runInCI( Promise.resolve([]), ); const diffJsonPaths = projectResults - .map(({ artifacts: { diff } }) => - diff?.files.find(file => file.endsWith('.json')), - ) + .map(({ files }) => files.diff?.json) .filter((file): file is string => file != null); if (diffJsonPaths.length > 0) { - const { mdFilePath, artifactData: diffArtifact } = await runMergeDiffs( + const diffPath = await runMergeDiffs( diffJsonPaths, createCommandContext(settings, projects[0]), ); - logger.debug(`Merged ${diffJsonPaths.length} diffs into ${mdFilePath}`); - const commentId = await commentOnPR(mdFilePath, api, logger); + logger.debug(`Merged ${diffJsonPaths.length} diffs into ${diffPath}`); + const commentId = await commentOnPR(diffPath, api, logger); return { mode: 'monorepo', projects: projectResults, commentId, - diffArtifact, + diffPath, }; } return { mode: 'monorepo', projects: projectResults }; } logger.info('Running Code PushUp in standalone project mode'); - const { artifacts, newIssues } = await runOnProject({ + const { files, newIssues } = await runOnProject({ project: null, settings, api, refs, git, }); - const commentMdPath = artifacts.diff?.files.find(file => - file.endsWith('.md'), - ); + const commentMdPath = files.diff?.md; if (commentMdPath) { const commentId = await commentOnPR(commentMdPath, api, logger); return { mode: 'standalone', - artifacts, + files, commentId, newIssues, }; } - return { mode: 'standalone', artifacts, newIssues }; + return { mode: 'standalone', files, newIssues }; } type RunOnProjectArgs = { @@ -125,15 +123,20 @@ async function runOnProject(args: RunOnProjectArgs): Promise { logger.info(`Running Code PushUp on monorepo project ${project.name}`); } - const { jsonFilePath: currReportPath, artifactData: reportArtifact } = - await runCollect(ctx); - const currReport = await fs.readFile(currReportPath, 'utf8'); - logger.debug(`Collected current report at ${currReportPath}`); + const config = await printPersistConfig(ctx, settings); + logger.debug( + `Loaded persist config from print-config command - ${JSON.stringify(config.persist)}`, + ); + + await runCollect(ctx); + const reportFiles = persistedFilesFromConfig(config, ctx); + const currReport = await fs.readFile(reportFiles.json, 'utf8'); + logger.debug(`Collected current report at ${reportFiles.json}`); const noDiffOutput = { name: project?.name ?? '-', - artifacts: { - report: reportArtifact, + files: { + report: reportFiles, }, } satisfies ProjectRunResult; @@ -157,20 +160,24 @@ async function runOnProject(args: RunOnProjectArgs): Promise { await fs.writeFile(prevPath, prevReport); logger.debug(`Saved reports to ${currPath} and ${prevPath}`); - const comparisonFiles = await runCompare( + await runCompare( { before: prevPath, after: currPath, label: project?.name }, ctx, ); + const comparisonFiles = persistedFilesFromConfig(config, { + directory: ctx.directory, + isDiff: true, + }); logger.info('Compared reports and generated diff files'); logger.debug( - `Generated diff files at ${comparisonFiles.jsonFilePath} and ${comparisonFiles.mdFilePath}`, + `Generated diff files at ${comparisonFiles.json} and ${comparisonFiles.md}`, ); const diffOutput = { ...noDiffOutput, - artifacts: { - ...noDiffOutput.artifacts, - diff: comparisonFiles.artifactData, + files: { + ...noDiffOutput.files, + diff: comparisonFiles, }, } satisfies ProjectRunResult; @@ -221,47 +228,69 @@ async function collectPreviousReport( if (cachedBaseReport) { return fs.readFile(cachedBaseReport, 'utf8'); - } else { - await git.fetch('origin', base.ref, ['--depth=1']); - await git.checkout(['-f', base.ref]); - logger.info(`Switched to base branch ${base.ref}`); + } - try { - await runPrintConfig({ ...ctx, silent: !settings.debug }); - logger.debug( - `Executing print-config verified code-pushup installed in base branch ${base.ref}`, - ); - } catch (error) { - logger.debug(`Error from print-config - ${stringifyError(error)}`); - logger.info( - `Executing print-config failed, assuming code-pushup not installed in base branch ${base.ref} and skipping comparison`, - ); - return null; - } + await git.fetch('origin', base.ref, ['--depth=1']); + await git.checkout(['-f', base.ref]); + logger.info(`Switched to base branch ${base.ref}`); - const { jsonFilePath: prevReportPath } = await runCollect(ctx); - const prevReport = await fs.readFile(prevReportPath, 'utf8'); - logger.debug(`Collected previous report at ${prevReportPath}`); + const config = await checkPrintConfig(args); + if (!config) { + return null; + } + + await runCollect(ctx); + const { json: prevReportPath } = persistedFilesFromConfig(config, ctx); + const prevReport = await fs.readFile(prevReportPath, 'utf8'); + logger.debug(`Collected previous report at ${prevReportPath}`); + + await git.checkout(['-f', '-']); + logger.info('Switched back to PR/MR branch'); - await git.checkout(['-f', '-']); - logger.info('Switched back to PR/MR branch'); + return prevReport; +} + +async function checkPrintConfig( + args: CollectPreviousReportArgs, +): Promise | null> { + const { ctx, base, settings } = args; + const { logger } = settings; - return prevReport; + try { + const config = await printPersistConfig(ctx, settings); + logger.debug( + `Executing print-config verified code-pushup installed in base branch ${base.ref}`, + ); + return config; + } catch (error) { + logger.debug(`Error from print-config - ${stringifyError(error)}`); + logger.info( + `Executing print-config failed, assuming code-pushup not installed in base branch ${base.ref} and skipping comparison`, + ); + return null; } } +async function printPersistConfig( + ctx: CommandContext, + settings: Settings, +): Promise> { + const json = await runPrintConfig({ ...ctx, silent: !settings.debug }); + return parsePersistConfig(json); +} + async function findNewIssues(args: { base: GitBranch; currReport: string; prevReport: string; - comparisonFiles: PersistedCliFiles; + comparisonFiles: OutputFiles; logger: Logger; git: SimpleGit; }): Promise { const { base, currReport, prevReport, comparisonFiles, logger, git } = args; await git.fetch('origin', base.ref, ['--depth=1']); - const reportsDiff = await fs.readFile(comparisonFiles.jsonFilePath, 'utf8'); + const reportsDiff = await fs.readFile(comparisonFiles.json, 'utf8'); const changedFiles = await listChangedFiles( { base: 'FETCH_HEAD', head: 'HEAD' }, git, diff --git a/packages/models/src/lib/core-config.ts b/packages/models/src/lib/core-config.ts index 48e142d1b..4c5307557 100644 --- a/packages/models/src/lib/core-config.ts +++ b/packages/models/src/lib/core-config.ts @@ -37,7 +37,7 @@ export function refineCoreConfig(schema: typeof unrefinedCoreConfigSchema) { ({ categories, plugins }) => ({ message: missingRefsForCategoriesErrorMsg(categories, plugins), }), - ) as unknown as typeof unrefinedCoreConfigSchema; + ); } -export type CoreConfig = z.infer; +export type CoreConfig = z.infer; From 01b6505fb867e79bbc4920d5bb41632dc28d5609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Wed, 4 Dec 2024 12:24:23 +0100 Subject: [PATCH 02/38] feat(ci): remove obsolete output option --- packages/ci/README.md | 7 ++----- packages/ci/src/lib/cli/context.unit.test.ts | 2 -- packages/ci/src/lib/constants.ts | 2 -- packages/ci/src/lib/models.ts | 1 - 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/ci/README.md b/packages/ci/README.md index 31f59d63e..577ad8d34 100644 --- a/packages/ci/README.md +++ b/packages/ci/README.md @@ -99,20 +99,17 @@ Optionally, you can override default options for further customization: | `monorepo` | `boolean \| MonorepoTool` | `false` | Enables [monorepo mode](#monorepo-mode) | | `projects` | `string[] \| null` | `null` | Custom projects configuration for [monorepo mode](#monorepo-mode) | | `task` | `string` | `'code-pushup'` | Name of command to run Code PushUp per project in [monorepo mode](#monorepo-mode) | -| `nxProjectsFilter` | `string \| string[]` | `'--with-target={task}'` | Arguments passed to [`nx show projects`](https://nx.dev/nx-api/nx/documents/show#projects), only relevant for Nx in [monorepo mode](#monorepo-mode) [^3] | +| `nxProjectsFilter` | `string \| string[]` | `'--with-target={task}'` | Arguments passed to [`nx show projects`](https://nx.dev/nx-api/nx/documents/show#projects), only relevant for Nx in [monorepo mode](#monorepo-mode) [^2] | | `directory` | `string` | `process.cwd()` | Directory in which Code PushUp CLI should run | | `config` | `string \| null` | `null` [^1] | Path to config file (`--config` option) | | `silent` | `boolean` | `false` | Toggles if logs from CLI commands are printed | | `bin` | `string` | `'npx --no-install code-pushup'` | Command for executing Code PushUp CLI | | `detectNewIssues` | `boolean` | `true` | Toggles if new issues should be detected and returned in `newIssues` property | | `logger` | `Logger` | `console` | Logger for reporting progress and encountered problems | -| `output` | `string` | `'.code-pushup'` | Directory where Code PushUp reports will be created (interpolates project name [^2]) | [^1]: By default, the `code-pushup.config` file is autodetected as described in [`@code-pushup/cli` docs](../cli/README.md#configuration). -[^2]: In monorepo mode, any occurrence of `{project}` in the `output` path will be replaced with a project name. This separation of folders per project (e.g. `output: '.code-pushup/{project}'`) may be useful for caching purposes. - -[^3]: The `{task}` pattern is replaced with the `task` value, so the default behaviour is to list projects using `npx nx show projects --with-target=code-pushup --json`. The `nxProjectsFilter` options gives Nx users the flexibility to filter projects in alternative ways supported by the Nx CLI (e.g. `--affected`, `--projects`, `--exclude`, `--type`) - refer to [options in Nx docs](https://nx.dev/nx-api/nx/documents/show#options) for details. +[^2]: The `{task}` pattern is replaced with the `task` value, so the default behaviour is to list projects using `npx nx show projects --with-target=code-pushup --json`. The `nxProjectsFilter` options gives Nx users the flexibility to filter projects in alternative ways supported by the Nx CLI (e.g. `--affected`, `--projects`, `--exclude`, `--type`) - refer to [options in Nx docs](https://nx.dev/nx-api/nx/documents/show#options) for details. The `Logger` object has the following required properties: diff --git a/packages/ci/src/lib/cli/context.unit.test.ts b/packages/ci/src/lib/cli/context.unit.test.ts index 6366bc896..8961caa77 100644 --- a/packages/ci/src/lib/cli/context.unit.test.ts +++ b/packages/ci/src/lib/cli/context.unit.test.ts @@ -13,7 +13,6 @@ describe('createCommandContext', () => { logger: console, monorepo: false, nxProjectsFilter: '--with-target={task}', - output: '.code-pushup', projects: null, silent: false, task: 'code-pushup', @@ -40,7 +39,6 @@ describe('createCommandContext', () => { logger: console, monorepo: false, nxProjectsFilter: '--with-target={task}', - output: '.code-pushup', projects: null, silent: false, task: 'code-pushup', diff --git a/packages/ci/src/lib/constants.ts b/packages/ci/src/lib/constants.ts index 85de8af3e..c7ddde7f9 100644 --- a/packages/ci/src/lib/constants.ts +++ b/packages/ci/src/lib/constants.ts @@ -1,4 +1,3 @@ -import { DEFAULT_PERSIST_OUTPUT_DIR } from '@code-pushup/models'; import type { Settings } from './models.js'; export const DEFAULT_SETTINGS: Settings = { @@ -12,6 +11,5 @@ export const DEFAULT_SETTINGS: Settings = { debug: false, detectNewIssues: true, logger: console, - output: DEFAULT_PERSIST_OUTPUT_DIR, nxProjectsFilter: '--with-target={task}', }; diff --git a/packages/ci/src/lib/models.ts b/packages/ci/src/lib/models.ts index a98232480..54c9991f1 100644 --- a/packages/ci/src/lib/models.ts +++ b/packages/ci/src/lib/models.ts @@ -18,7 +18,6 @@ export type Options = { debug?: boolean; detectNewIssues?: boolean; logger?: Logger; - output?: string; }; /** From bdfc5b12e82ca646634c9dfd242b59c2fd377324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Wed, 4 Dec 2024 16:32:14 +0100 Subject: [PATCH 03/38] feat(ci): implement run many command resolution for each monorepo tool --- packages/ci/src/lib/monorepo/handlers/npm.ts | 11 +- packages/ci/src/lib/monorepo/handlers/nx.ts | 16 ++ packages/ci/src/lib/monorepo/handlers/pnpm.ts | 26 ++- .../ci/src/lib/monorepo/handlers/turbo.ts | 25 ++- packages/ci/src/lib/monorepo/handlers/yarn.ts | 26 ++- packages/ci/src/lib/monorepo/list-projects.ts | 72 ++++-- .../lib/monorepo/list-projects.unit.test.ts | 207 +++++++++++------- packages/ci/src/lib/monorepo/tools.ts | 5 + packages/ci/src/lib/run.ts | 2 +- 9 files changed, 286 insertions(+), 104 deletions(-) diff --git a/packages/ci/src/lib/monorepo/handlers/npm.ts b/packages/ci/src/lib/monorepo/handlers/npm.ts index 4657b4531..c232d8382 100644 --- a/packages/ci/src/lib/monorepo/handlers/npm.ts +++ b/packages/ci/src/lib/monorepo/handlers/npm.ts @@ -10,12 +10,14 @@ import type { MonorepoToolHandler } from '../tools.js'; export const npmHandler: MonorepoToolHandler = { tool: 'npm', + async isConfigured(options) { return ( (await fileExists(join(options.cwd, 'package-lock.json'))) && (await hasWorkspacesEnabled(options.cwd)) ); }, + async listProjects(options) { const { workspaces, rootPackageJson } = await listWorkspaces(options.cwd); return workspaces @@ -28,8 +30,13 @@ export const npmHandler: MonorepoToolHandler = { .map(({ name, packageJson }) => ({ name, bin: hasScript(packageJson, options.task) - ? `npm -w ${name} run ${options.task} --` - : `npm -w ${name} exec ${options.task} --`, + ? `npm --workspace=${name} run ${options.task} --` + : `npm --workspace=${name} exec ${options.task} --`, })); }, + + createRunManyCommand(options) { + // neither parallel execution nor projects filter are supported in NPM workspaces + return `npm run ${options.task} --workspaces --if-present --`; + }, }; diff --git a/packages/ci/src/lib/monorepo/handlers/nx.ts b/packages/ci/src/lib/monorepo/handlers/nx.ts index 7e7ad236e..19d26128f 100644 --- a/packages/ci/src/lib/monorepo/handlers/nx.ts +++ b/packages/ci/src/lib/monorepo/handlers/nx.ts @@ -9,6 +9,7 @@ import type { MonorepoToolHandler } from '../tools.js'; export const nxHandler: MonorepoToolHandler = { tool: 'nx', + async isConfigured(options) { return ( (await fileExists(join(options.cwd, 'nx.json'))) && @@ -18,10 +19,12 @@ export const nxHandler: MonorepoToolHandler = { args: ['nx', 'report'], cwd: options.cwd, observer: options.observer, + ignoreExitCode: true, }) ).code === 0 ); }, + async listProjects(options) { const { stdout } = await executeProcess({ command: 'npx', @@ -43,6 +46,19 @@ export const nxHandler: MonorepoToolHandler = { bin: `npx nx run ${project}:${options.task} --`, })); }, + + createRunManyCommand(options, onlyProjects) { + return [ + 'npx', + 'nx', + 'run-many', // TODO: allow affected instead of run-many? + `--targets=${options.task}`, + // TODO: add options.nxRunManyFilter? (e.g. --exclude=...) + ...(onlyProjects ? [`--projects=${onlyProjects.join(',')}`] : []), + `--parallel=${options.parallel}`, + '--', + ].join(' '); + }, }; function parseProjects(stdout: string): string[] { diff --git a/packages/ci/src/lib/monorepo/handlers/pnpm.ts b/packages/ci/src/lib/monorepo/handlers/pnpm.ts index 01bc368d5..3083ecc64 100644 --- a/packages/ci/src/lib/monorepo/handlers/pnpm.ts +++ b/packages/ci/src/lib/monorepo/handlers/pnpm.ts @@ -11,14 +11,19 @@ import type { MonorepoToolHandler } from '../tools.js'; const WORKSPACE_FILE = 'pnpm-workspace.yaml'; +// https://pnpm.io/cli/recursive#--workspace-concurrency +const DEFAULT_WORKSPACE_CONCURRENCY = 4; + export const pnpmHandler: MonorepoToolHandler = { tool: 'pnpm', + async isConfigured(options) { return ( (await fileExists(join(options.cwd, WORKSPACE_FILE))) && (await fileExists(join(options.cwd, 'package.json'))) ); }, + async listProjects(options) { const yaml = await readTextFile(join(options.cwd, WORKSPACE_FILE)); const workspace = YAML.parse(yaml) as { packages?: string[] }; @@ -34,8 +39,25 @@ export const pnpmHandler: MonorepoToolHandler = { .map(({ name, packageJson }) => ({ name, bin: hasScript(packageJson, options.task) - ? `pnpm -F ${name} run ${options.task}` - : `pnpm -F ${name} exec ${options.task}`, + ? `pnpm --filter=${name} run ${options.task}` + : `pnpm --filter=${name} exec ${options.task}`, })); }, + + createRunManyCommand(options, onlyProjects) { + const workspaceConcurrency: number = + options.parallel === true + ? DEFAULT_WORKSPACE_CONCURRENCY + : options.parallel === false + ? 1 + : options.parallel; + return [ + 'pnpm', + '--recursive', + `--workspace-concurrency=${workspaceConcurrency}`, + ...(onlyProjects?.map(project => `--filter=${project}`) ?? []), + 'run', + options.task, + ].join(' '); + }, }; diff --git a/packages/ci/src/lib/monorepo/handlers/turbo.ts b/packages/ci/src/lib/monorepo/handlers/turbo.ts index 0f8e3ff85..1c8e3c126 100644 --- a/packages/ci/src/lib/monorepo/handlers/turbo.ts +++ b/packages/ci/src/lib/monorepo/handlers/turbo.ts @@ -7,12 +7,16 @@ import { yarnHandler } from './yarn.js'; const WORKSPACE_HANDLERS = [pnpmHandler, yarnHandler, npmHandler]; +// https://turbo.build/repo/docs/reference/run#--concurrency-number--percentage +const DEFAULT_CONCURRENCY = 10; + type TurboConfig = { tasks: Record; }; export const turboHandler: MonorepoToolHandler = { tool: 'turbo', + async isConfigured(options) { const configPath = join(options.cwd, 'turbo.json'); return ( @@ -20,6 +24,7 @@ export const turboHandler: MonorepoToolHandler = { options.task in (await readJsonFile(configPath)).tasks ); }, + async listProjects(options) { // eslint-disable-next-line functional/no-loop-statements for (const handler of WORKSPACE_HANDLERS) { @@ -29,7 +34,7 @@ export const turboHandler: MonorepoToolHandler = { .filter(({ bin }) => bin.includes(`run ${options.task}`)) // must have package.json script .map(({ name }) => ({ name, - bin: `npx turbo run ${options.task} -F ${name} --`, + bin: `npx turbo run ${options.task} --filter=${name} --`, })); } } @@ -39,4 +44,22 @@ export const turboHandler: MonorepoToolHandler = { ).join('/')}`, ); }, + + createRunManyCommand(options, onlyProjects) { + const concurrency: number = + options.parallel === true + ? DEFAULT_CONCURRENCY + : options.parallel === false + ? 1 + : options.parallel; + return [ + 'npx', + 'turbo', + 'run', + options.task, + ...(onlyProjects?.map(project => `--filter=${project}`) ?? []), + `--concurrency=${concurrency}`, + '--', + ].join(' '); + }, }; diff --git a/packages/ci/src/lib/monorepo/handlers/yarn.ts b/packages/ci/src/lib/monorepo/handlers/yarn.ts index db5c3f632..e2022fb03 100644 --- a/packages/ci/src/lib/monorepo/handlers/yarn.ts +++ b/packages/ci/src/lib/monorepo/handlers/yarn.ts @@ -1,5 +1,5 @@ import { join } from 'node:path'; -import { fileExists } from '@code-pushup/utils'; +import { executeProcess, fileExists } from '@code-pushup/utils'; import { hasCodePushUpDependency, hasScript, @@ -10,12 +10,14 @@ import type { MonorepoToolHandler } from '../tools.js'; export const yarnHandler: MonorepoToolHandler = { tool: 'yarn', + async isConfigured(options) { return ( (await fileExists(join(options.cwd, 'yarn.lock'))) && (await hasWorkspacesEnabled(options.cwd)) ); }, + async listProjects(options) { const { workspaces, rootPackageJson } = await listWorkspaces(options.cwd); return workspaces @@ -32,4 +34,26 @@ export const yarnHandler: MonorepoToolHandler = { : `yarn workspace ${name} exec ${options.task}`, })); }, + + async createRunManyCommand(options, onlyProjects) { + const { stdout } = await executeProcess({ command: 'yarn', args: ['-v'] }); + const isV1 = stdout.startsWith('1.'); + + if (isV1) { + // neither parallel execution nor projects filter are supported in Yarn v1 + return `yarn workspaces run ${options.task}`; + } + + return [ + 'yarn', + 'workspaces', + 'foreach', + ...(options.parallel ? ['--parallel'] : []), + ...(typeof options.parallel === 'number' + ? [`--jobs=${options.parallel}`] + : []), + ...(onlyProjects?.map(project => `--include=${project}`) ?? ['--all']), + options.task, + ].join(' '); + }, }; diff --git a/packages/ci/src/lib/monorepo/list-projects.ts b/packages/ci/src/lib/monorepo/list-projects.ts index 961608af5..3caa64086 100644 --- a/packages/ci/src/lib/monorepo/list-projects.ts +++ b/packages/ci/src/lib/monorepo/list-projects.ts @@ -4,55 +4,80 @@ import type { Logger, Settings } from '../models.js'; import { detectMonorepoTool } from './detect-tool.js'; import { getToolHandler } from './handlers/index.js'; import { listPackages } from './packages.js'; -import type { MonorepoHandlerOptions, ProjectConfig } from './tools.js'; +import type { + MonorepoHandlerOptions, + MonorepoTool, + ProjectConfig, +} from './tools.js'; + +export type MonorepoProjects = { + tool: MonorepoTool | null; + projects: ProjectConfig[]; + runManyCommand?: (onlyProjects?: string[]) => string | Promise; +}; export async function listMonorepoProjects( settings: Settings, -): Promise { - if (!settings.monorepo) { - throw new Error('Monorepo mode not enabled'); - } - +): Promise { const logger = settings.logger; - const options = createMonorepoHandlerOptions(settings); - const tool = - settings.monorepo === true - ? await detectMonorepoTool(options) - : settings.monorepo; - if (settings.monorepo === true) { - if (tool) { - logger.info(`Auto-detected monorepo tool ${tool}`); - } else { - logger.info("Couldn't auto-detect any supported monorepo tool"); - } - } else { - logger.info(`Using monorepo tool "${tool}" from inputs`); - } + const tool = await resolveMonorepoTool(settings, options); if (tool) { const handler = getToolHandler(tool); const projects = await handler.listProjects(options); logger.info(`Found ${projects.length} projects in ${tool} monorepo`); logger.debug(`Projects: ${projects.map(({ name }) => name).join(', ')}`); - return projects; + return { + tool, + projects, + runManyCommand: onlyProjects => + handler.createRunManyCommand(options, onlyProjects), + }; } if (settings.projects) { - return listProjectsByGlobs({ + const projects = await listProjectsByGlobs({ patterns: settings.projects, cwd: options.cwd, bin: settings.bin, logger, }); + return { tool, projects }; } - return listProjectsByNpmPackages({ + const projects = await listProjectsByNpmPackages({ cwd: options.cwd, bin: settings.bin, logger, }); + return { tool, projects }; +} + +async function resolveMonorepoTool( + settings: Settings, + options: MonorepoHandlerOptions, +): Promise { + if (!settings.monorepo) { + // shouldn't happen, handled by caller + throw new Error('Monorepo mode not enabled'); + } + const logger = settings.logger; + + if (typeof settings.monorepo === 'string') { + logger.info(`Using monorepo tool "${settings.monorepo}" from inputs`); + return settings.monorepo; + } + + const tool = await detectMonorepoTool(options); + if (tool) { + logger.info(`Auto-detected monorepo tool ${tool}`); + } else { + logger.info("Couldn't auto-detect any supported monorepo tool"); + } + + return tool; } function createMonorepoHandlerOptions( @@ -61,6 +86,7 @@ function createMonorepoHandlerOptions( return { task: settings.task, cwd: settings.directory, + parallel: false, // TODO: add to settings nxProjectsFilter: settings.nxProjectsFilter, ...(!settings.silent && { observer: { diff --git a/packages/ci/src/lib/monorepo/list-projects.unit.test.ts b/packages/ci/src/lib/monorepo/list-projects.unit.test.ts index 439897473..d75a1b0d5 100644 --- a/packages/ci/src/lib/monorepo/list-projects.unit.test.ts +++ b/packages/ci/src/lib/monorepo/list-projects.unit.test.ts @@ -5,8 +5,10 @@ import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import * as utils from '@code-pushup/utils'; import { DEFAULT_SETTINGS } from '../constants.js'; import type { Settings } from '../models.js'; -import { listMonorepoProjects } from './list-projects.js'; -import type { ProjectConfig } from './tools.js'; +import { + type MonorepoProjects, + listMonorepoProjects, +} from './list-projects.js'; describe('listMonorepoProjects', () => { const MONOREPO_SETTINGS: Settings = { @@ -53,10 +55,14 @@ describe('listMonorepoProjects', () => { MEMFS_VOLUME, ); - await expect(listMonorepoProjects(MONOREPO_SETTINGS)).resolves.toEqual([ - { name: 'backend', bin: 'npx nx run backend:code-pushup --' }, - { name: 'frontend', bin: 'npx nx run frontend:code-pushup --' }, - ] satisfies ProjectConfig[]); + await expect(listMonorepoProjects(MONOREPO_SETTINGS)).resolves.toEqual({ + tool: 'nx', + projects: [ + { name: 'backend', bin: 'npx nx run backend:code-pushup --' }, + { name: 'frontend', bin: 'npx nx run frontend:code-pushup --' }, + ], + runManyCommand: expect.any(Function), + } satisfies MonorepoProjects); expect(utils.executeProcess).toHaveBeenCalledWith< Parameters<(typeof utils)['executeProcess']> @@ -96,24 +102,28 @@ describe('listMonorepoProjects', () => { 'e2e/package.json': pkgJsonContent({ name: 'e2e', }), - 'frontend/backoffice/package.json': pkgJsonContent({ - name: 'backoffice', + 'frontend/cms/package.json': pkgJsonContent({ + name: 'cms', scripts: { 'code-pushup': 'code-pushup --no-progress' }, }), - 'frontend/website/package.json': pkgJsonContent({ - name: 'website', + 'frontend/web/package.json': pkgJsonContent({ + name: 'web', scripts: { 'code-pushup': 'code-pushup --no-progress' }, }), }, MEMFS_VOLUME, ); - await expect(listMonorepoProjects(MONOREPO_SETTINGS)).resolves.toEqual([ - { name: 'api', bin: 'npx turbo run code-pushup -F api --' }, - { name: 'auth', bin: 'npx turbo run code-pushup -F auth --' }, - { name: 'backoffice', bin: 'npx turbo run code-pushup -F backoffice --' }, - { name: 'website', bin: 'npx turbo run code-pushup -F website --' }, - ] satisfies ProjectConfig[]); + await expect(listMonorepoProjects(MONOREPO_SETTINGS)).resolves.toEqual({ + tool: 'turbo', + projects: [ + { name: 'api', bin: 'npx turbo run code-pushup --filter=api --' }, + { name: 'auth', bin: 'npx turbo run code-pushup --filter=auth --' }, + { name: 'cms', bin: 'npx turbo run code-pushup --filter=cms --' }, + { name: 'web', bin: 'npx turbo run code-pushup --filter=web --' }, + ], + runManyCommand: expect.any(Function), + } satisfies MonorepoProjects); }); it('should detect packages in PNPM workspace with code-pushup script', async () => { @@ -140,11 +150,24 @@ describe('listMonorepoProjects', () => { MEMFS_VOLUME, ); - await expect(listMonorepoProjects(MONOREPO_SETTINGS)).resolves.toEqual([ - { name: 'backend', bin: 'pnpm -F backend run code-pushup' }, - { name: 'frontend', bin: 'pnpm -F frontend run code-pushup' }, - { name: '@repo/utils', bin: 'pnpm -F @repo/utils run code-pushup' }, - ] satisfies ProjectConfig[]); + await expect(listMonorepoProjects(MONOREPO_SETTINGS)).resolves.toEqual({ + tool: 'pnpm', + projects: [ + { + name: 'backend', + bin: 'pnpm --filter=backend run code-pushup', + }, + { + name: 'frontend', + bin: 'pnpm --filter=frontend run code-pushup', + }, + { + name: '@repo/utils', + bin: 'pnpm --filter=@repo/utils run code-pushup', + }, + ], + runManyCommand: expect.any(Function), + } satisfies MonorepoProjects); }); it('should detect Yarn workspaces with code-pushup installed individually', async () => { @@ -170,10 +193,14 @@ describe('listMonorepoProjects', () => { MEMFS_VOLUME, ); - await expect(listMonorepoProjects(MONOREPO_SETTINGS)).resolves.toEqual([ - { name: 'cli', bin: 'yarn workspace cli exec code-pushup' }, - { name: 'core', bin: 'yarn workspace core exec code-pushup' }, - ] satisfies ProjectConfig[]); + await expect(listMonorepoProjects(MONOREPO_SETTINGS)).resolves.toEqual({ + tool: 'yarn', + projects: [ + { name: 'cli', bin: 'yarn workspace cli exec code-pushup' }, + { name: 'core', bin: 'yarn workspace core exec code-pushup' }, + ], + runManyCommand: expect.any(Function), + } satisfies MonorepoProjects); }); it('should detect NPM workspaces when code-pushup installed at root level', async () => { @@ -195,10 +222,20 @@ describe('listMonorepoProjects', () => { MEMFS_VOLUME, ); - await expect(listMonorepoProjects(MONOREPO_SETTINGS)).resolves.toEqual([ - { name: 'backend', bin: 'npm -w backend exec code-pushup --' }, - { name: 'frontend', bin: 'npm -w frontend exec code-pushup --' }, - ] satisfies ProjectConfig[]); + await expect(listMonorepoProjects(MONOREPO_SETTINGS)).resolves.toEqual({ + tool: 'npm', + projects: [ + { + name: 'backend', + bin: 'npm --workspace=backend exec code-pushup --', + }, + { + name: 'frontend', + bin: 'npm --workspace=frontend exec code-pushup --', + }, + ], + runManyCommand: expect.any(Function), + } satisfies MonorepoProjects); }); it('should list folders matching globs passed as input when no tool detected', async () => { @@ -226,23 +263,26 @@ describe('listMonorepoProjects', () => { monorepo: true, projects: ['backend/*', 'frontend'], }), - ).resolves.toEqual([ - { - name: join('backend', 'api'), - bin: 'npx --no-install code-pushup', - directory: join(MEMFS_VOLUME, 'backend', 'api'), - }, - { - name: join('backend', 'auth'), - bin: 'npx --no-install code-pushup', - directory: join(MEMFS_VOLUME, 'backend', 'auth'), - }, - { - name: 'frontend', - bin: 'npx --no-install code-pushup', - directory: join(MEMFS_VOLUME, 'frontend'), - }, - ] satisfies ProjectConfig[]); + ).resolves.toEqual({ + tool: null, + projects: [ + { + name: join('backend', 'api'), + bin: 'npx --no-install code-pushup', + directory: join(MEMFS_VOLUME, 'backend', 'api'), + }, + { + name: join('backend', 'auth'), + bin: 'npx --no-install code-pushup', + directory: join(MEMFS_VOLUME, 'backend', 'auth'), + }, + { + name: 'frontend', + bin: 'npx --no-install code-pushup', + directory: join(MEMFS_VOLUME, 'frontend'), + }, + ], + } satisfies MonorepoProjects); }); it('should list all folders with a package.json when no tool detected and no patterns provided', async () => { @@ -265,28 +305,31 @@ describe('listMonorepoProjects', () => { monorepo: true, projects: null, }), - ).resolves.toEqual([ - { - name: 'my-app', - bin: 'npx --no-install code-pushup', - directory: join(MEMFS_VOLUME), - }, - { - name: 'migrate', - bin: 'npx --no-install code-pushup', - directory: join(MEMFS_VOLUME, 'scripts', 'db', 'migrate'), - }, - { - name: 'seed', - bin: 'npx --no-install code-pushup', - directory: join(MEMFS_VOLUME, 'scripts', 'db', 'seed'), - }, - { - name: 'generate-token', - bin: 'npx --no-install code-pushup', - directory: join(MEMFS_VOLUME, 'scripts', 'generate-token'), - }, - ] satisfies ProjectConfig[]); + ).resolves.toEqual({ + tool: null, + projects: [ + { + name: 'my-app', + bin: 'npx --no-install code-pushup', + directory: join(MEMFS_VOLUME), + }, + { + name: 'migrate', + bin: 'npx --no-install code-pushup', + directory: join(MEMFS_VOLUME, 'scripts', 'db', 'migrate'), + }, + { + name: 'seed', + bin: 'npx --no-install code-pushup', + directory: join(MEMFS_VOLUME, 'scripts', 'db', 'seed'), + }, + { + name: 'generate-token', + bin: 'npx --no-install code-pushup', + directory: join(MEMFS_VOLUME, 'scripts', 'generate-token'), + }, + ], + } satisfies MonorepoProjects); }); it('should prefer tool provided as input (PNPM) over tool which would be auto-detected otherwise (Turborepo)', async () => { @@ -319,11 +362,27 @@ describe('listMonorepoProjects', () => { await expect( listMonorepoProjects({ ...MONOREPO_SETTINGS, monorepo: 'pnpm' }), - ).resolves.toEqual([ - { name: 'backoffice', bin: 'pnpm -F backoffice exec code-pushup' }, - { name: 'frontoffice', bin: 'pnpm -F frontoffice exec code-pushup' }, - { name: '@repo/models', bin: 'pnpm -F @repo/models exec code-pushup' }, - { name: '@repo/ui', bin: 'pnpm -F @repo/ui exec code-pushup' }, - ] satisfies ProjectConfig[]); + ).resolves.toEqual({ + tool: 'pnpm', + projects: [ + { + name: 'backoffice', + bin: 'pnpm --filter=backoffice exec code-pushup', + }, + { + name: 'frontoffice', + bin: 'pnpm --filter=frontoffice exec code-pushup', + }, + { + name: '@repo/models', + bin: 'pnpm --filter=@repo/models exec code-pushup', + }, + { + name: '@repo/ui', + bin: 'pnpm --filter=@repo/ui exec code-pushup', + }, + ], + runManyCommand: expect.any(Function), + } satisfies MonorepoProjects); }); }); diff --git a/packages/ci/src/lib/monorepo/tools.ts b/packages/ci/src/lib/monorepo/tools.ts index 435315eb0..2dc26b6ea 100644 --- a/packages/ci/src/lib/monorepo/tools.ts +++ b/packages/ci/src/lib/monorepo/tools.ts @@ -7,11 +7,16 @@ export type MonorepoToolHandler = { tool: MonorepoTool; isConfigured: (options: MonorepoHandlerOptions) => Promise; listProjects: (options: MonorepoHandlerOptions) => Promise; + createRunManyCommand: ( + options: MonorepoHandlerOptions, + onlyProjects?: string[], + ) => string | Promise; }; export type MonorepoHandlerOptions = { task: string; cwd: string; + parallel: boolean | number; observer?: ProcessObserver; nxProjectsFilter: string | string[]; }; diff --git a/packages/ci/src/lib/run.ts b/packages/ci/src/lib/run.ts index 1d5b865be..766e62fc7 100644 --- a/packages/ci/src/lib/run.ts +++ b/packages/ci/src/lib/run.ts @@ -50,7 +50,7 @@ export async function runInCI( if (settings.monorepo) { logger.info('Running Code PushUp in monorepo mode'); - const projects = await listMonorepoProjects(settings); + const { projects } = await listMonorepoProjects(settings); const projectResults = await projects.reduce>( async (acc, project) => [ ...(await acc), From e7090ebaeb1a5fe3a113f8cb6ab4f46159d5402d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Wed, 4 Dec 2024 17:07:15 +0100 Subject: [PATCH 04/38] feat(ci): add parallel option --- packages/ci/README.md | 22 +++++++++++++++++++ packages/ci/src/lib/constants.ts | 1 + packages/ci/src/lib/models.ts | 1 + packages/ci/src/lib/monorepo/list-projects.ts | 2 +- 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/ci/README.md b/packages/ci/README.md index 577ad8d34..7b38e18dc 100644 --- a/packages/ci/README.md +++ b/packages/ci/README.md @@ -97,6 +97,7 @@ Optionally, you can override default options for further customization: | Property | Type | Default | Description | | :----------------- | :------------------------ | :------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- | | `monorepo` | `boolean \| MonorepoTool` | `false` | Enables [monorepo mode](#monorepo-mode) | +| `parallel` | `boolean \| number` | `false` | Enables parallel execution in [monorepo mode](#monorepo-mode) | | `projects` | `string[] \| null` | `null` | Custom projects configuration for [monorepo mode](#monorepo-mode) | | `task` | `string` | `'code-pushup'` | Name of command to run Code PushUp per project in [monorepo mode](#monorepo-mode) | | `nxProjectsFilter` | `string \| string[]` | `'--with-target={task}'` | Arguments passed to [`nx show projects`](https://nx.dev/nx-api/nx/documents/show#projects), only relevant for Nx in [monorepo mode](#monorepo-mode) [^2] | @@ -193,6 +194,27 @@ await runInCI(refs, api, { }); ``` +### Parallel tasks + +By default, tasks are run sequentially for each project in the monorepo. +The `parallel` option enables parallel execution for tools which support it (Nx, Turborepo, PNPM, Yarn 2+). + +```ts +await runInCI(refs, api, { + monorepo: true, + parallel: true, +}); +``` + +The maximum number of concurrent tasks can be set by passing in a number instead of a boolean: + +```ts +await runInCI(refs, api, { + monorepo: true, + parallel: 3, +}); +``` + ### Monorepo result In monorepo mode, the resolved object includes the merged diff at the top-level, as well as a list of projects. diff --git a/packages/ci/src/lib/constants.ts b/packages/ci/src/lib/constants.ts index c7ddde7f9..081929382 100644 --- a/packages/ci/src/lib/constants.ts +++ b/packages/ci/src/lib/constants.ts @@ -2,6 +2,7 @@ import type { Settings } from './models.js'; export const DEFAULT_SETTINGS: Settings = { monorepo: false, + parallel: false, // TODO: default to true once battle-tested? projects: null, task: 'code-pushup', bin: 'npx --no-install code-pushup', diff --git a/packages/ci/src/lib/models.ts b/packages/ci/src/lib/models.ts index 54c9991f1..7bb5cf54b 100644 --- a/packages/ci/src/lib/models.ts +++ b/packages/ci/src/lib/models.ts @@ -8,6 +8,7 @@ import type { MonorepoTool } from './monorepo/index.js'; */ export type Options = { monorepo?: boolean | MonorepoTool; + parallel?: boolean | number; projects?: string[] | null; task?: string; nxProjectsFilter?: string | string[]; diff --git a/packages/ci/src/lib/monorepo/list-projects.ts b/packages/ci/src/lib/monorepo/list-projects.ts index 3caa64086..20dedb077 100644 --- a/packages/ci/src/lib/monorepo/list-projects.ts +++ b/packages/ci/src/lib/monorepo/list-projects.ts @@ -86,7 +86,7 @@ function createMonorepoHandlerOptions( return { task: settings.task, cwd: settings.directory, - parallel: false, // TODO: add to settings + parallel: settings.parallel, nxProjectsFilter: settings.nxProjectsFilter, ...(!settings.silent && { observer: { From c5611a06d24a405eed5ffa68092c2da87c2803db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Wed, 4 Dec 2024 18:19:26 +0100 Subject: [PATCH 05/38] test(ci): unit test individual monorepo handlers --- .../lib/monorepo/handlers/npm.unit.test.ts | 178 ++++++++++++++ .../src/lib/monorepo/handlers/nx.unit.test.ts | 140 +++++++++++ packages/ci/src/lib/monorepo/handlers/pnpm.ts | 1 - .../lib/monorepo/handlers/pnpm.unit.test.ts | 187 +++++++++++++++ .../ci/src/lib/monorepo/handlers/turbo.ts | 2 +- .../lib/monorepo/handlers/turbo.unit.test.ts | 185 ++++++++++++++ .../lib/monorepo/handlers/yarn.unit.test.ts | 226 ++++++++++++++++++ 7 files changed, 917 insertions(+), 2 deletions(-) create mode 100644 packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts create mode 100644 packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts create mode 100644 packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts create mode 100644 packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts create mode 100644 packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts diff --git a/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts new file mode 100644 index 000000000..6ab8e8455 --- /dev/null +++ b/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts @@ -0,0 +1,178 @@ +import { vol } from 'memfs'; +import type { PackageJson } from 'type-fest'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import type { MonorepoHandlerOptions, ProjectConfig } from '../tools'; +import { npmHandler } from './npm'; + +describe('npmHandler', () => { + const options = { + cwd: MEMFS_VOLUME, + task: 'code-pushup', + } as MonorepoHandlerOptions; + + const pkgJsonContent = (content: PackageJson): string => + JSON.stringify(content); + + describe('isConfigured', () => { + it('should detect NPM workspaces when package-lock.json exists and "workspaces" set in package.json', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['packages/*'], + }), + 'package-lock.json': '', + }, + MEMFS_VOLUME, + ); + await expect(npmHandler.isConfigured(options)).resolves.toBe(true); + }); + + it('should NOT detect NPM workspaces when "workspaces" not set in package.json', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({}), + 'package-lock.json': '', + }, + MEMFS_VOLUME, + ); + await expect(npmHandler.isConfigured(options)).resolves.toBe(false); + }); + + it("should NOT detect NPM workspaces when package-lock.json doesn't exist", async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['packages/*'], + }), + 'yarn.lock': '', + }, + MEMFS_VOLUME, + ); + await expect(npmHandler.isConfigured(options)).resolves.toBe(false); + }); + }); + + describe('listProjects', () => { + it('should list all NPM workspaces with code-pushup script', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['apps/*', 'libs/*'], + }), + 'package-lock.json': '', + 'apps/backend/package.json': pkgJsonContent({ + name: 'backend', + scripts: { 'code-pushup': 'code-pushup --no-progress' }, + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + 'apps/frontend/package.json': pkgJsonContent({ + name: 'frontend', + // missing script + }), + 'libs/shared/package.json': pkgJsonContent({ + name: 'shared', + scripts: { 'code-pushup': 'code-pushup --no-progress' }, + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + }, + MEMFS_VOLUME, + ); + + await expect(npmHandler.listProjects(options)).resolves.toEqual([ + { + name: 'backend', + bin: 'npm --workspace=backend run code-pushup --', + }, + { + name: 'shared', + bin: 'npm --workspace=shared run code-pushup --', + }, + ] satisfies ProjectConfig[]); + }); + + it('should list all NPM workspaces with code-pushup dependency', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['apps/*', 'libs/*'], + }), + 'package-lock.json': '', + 'apps/backend/package.json': pkgJsonContent({ + name: 'backend', + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + 'apps/frontend/package.json': pkgJsonContent({ + name: 'frontend', + // missing dependency + }), + 'libs/shared/package.json': pkgJsonContent({ + name: 'shared', + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + }, + MEMFS_VOLUME, + ); + + await expect(npmHandler.listProjects(options)).resolves.toEqual([ + { + name: 'backend', + bin: 'npm --workspace=backend exec code-pushup --', + }, + { + name: 'shared', + bin: 'npm --workspace=shared exec code-pushup --', + }, + ] satisfies ProjectConfig[]); + }); + + it('should list all NPM workspaces when code-pushup installed at root level', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['apps/*', 'libs/*'], + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + 'package-lock.json': '', + 'apps/backend/package.json': pkgJsonContent({ + name: 'backend', + }), + 'apps/frontend/package.json': pkgJsonContent({ + name: 'frontend', + }), + 'libs/shared/package.json': pkgJsonContent({ + name: 'shared', + }), + }, + MEMFS_VOLUME, + ); + + await expect(npmHandler.listProjects(options)).resolves.toEqual([ + { + name: 'backend', + bin: 'npm --workspace=backend exec code-pushup --', + }, + { + name: 'frontend', + bin: 'npm --workspace=frontend exec code-pushup --', + }, + { + name: 'shared', + bin: 'npm --workspace=shared exec code-pushup --', + }, + ] satisfies ProjectConfig[]); + }); + }); + + describe('createRunManyCommand', () => { + it('should create command to run npm script for all workspaces', () => { + expect(npmHandler.createRunManyCommand(options)).toBe( + 'npm run code-pushup --workspaces --if-present --', + ); + }); + }); +}); diff --git a/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts new file mode 100644 index 000000000..2ba543517 --- /dev/null +++ b/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts @@ -0,0 +1,140 @@ +import { vol } from 'memfs'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import * as utils from '@code-pushup/utils'; +import type { MonorepoHandlerOptions, ProjectConfig } from '../tools'; +import { nxHandler } from './nx'; + +describe('nxHandler', () => { + const options: MonorepoHandlerOptions = { + cwd: MEMFS_VOLUME, + task: 'code-pushup', + parallel: false, + nxProjectsFilter: '--with-target={task}', + }; + + describe('isConfigured', () => { + it('should detect Nx when nx.json exists and `nx report` succeeds', async () => { + vol.fromJSON({ 'nx.json': '{}' }, MEMFS_VOLUME); + vi.spyOn(utils, 'executeProcess').mockResolvedValue({ + code: 0, + stdout: 'NX Report complete - copy this into the issue template', + } as utils.ProcessResult); + + await expect(nxHandler.isConfigured(options)).resolves.toBe(true); + }); + + it("should NOT detect Nx when nx.json doesn't exist", async () => { + vol.fromJSON({ 'turbo.json': '{}' }, MEMFS_VOLUME); + vi.spyOn(utils, 'executeProcess').mockResolvedValue({ + code: 0, + } as utils.ProcessResult); + + await expect(nxHandler.isConfigured(options)).resolves.toBe(false); + }); + + it('should NOT detect Nx when `nx report` fails with non-zero exit code', async () => { + vol.fromJSON({ 'nx.json': '' }, MEMFS_VOLUME); + vi.spyOn(utils, 'executeProcess').mockResolvedValue({ + code: 1, + stderr: 'Error: ValueExpected in nx.json', + } as utils.ProcessResult); + + await expect(nxHandler.isConfigured(options)).resolves.toBe(false); + }); + }); + + describe('listProjects', () => { + beforeEach(() => { + vi.spyOn(utils, 'executeProcess').mockResolvedValue({ + stdout: '["backend","frontend"]', + } as utils.ProcessResult); + }); + + it('should list projects from `nx show projects`', async () => { + await expect(nxHandler.listProjects(options)).resolves.toEqual([ + { name: 'backend', bin: 'npx nx run backend:code-pushup --' }, + { name: 'frontend', bin: 'npx nx run frontend:code-pushup --' }, + ] satisfies ProjectConfig[]); + }); + + it('should forward nxProjectsFilter option to `nx show projects`', async () => { + await nxHandler.listProjects({ + ...options, + nxProjectsFilter: ['--affected', '--exclude=*-e2e'], + }); + + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: 'npx', + args: [ + 'nx', + 'show', + 'projects', + '--affected', + '--exclude=*-e2e', + '--json', + ], + cwd: MEMFS_VOLUME, + } satisfies utils.ProcessConfig); + }); + + it('should replace {task} in nxProjectsFilter with task option in `nx show projects` arguments', async () => { + await nxHandler.listProjects({ + ...options, + task: 'code-pushup', + nxProjectsFilter: '--with-target={task}', + }); + + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: 'npx', + args: ['nx', 'show', 'projects', '--with-target=code-pushup', '--json'], + cwd: MEMFS_VOLUME, + } satisfies utils.ProcessConfig); + }); + + it('should throw if `nx show projects` outputs invalid JSON', async () => { + vi.spyOn(utils, 'executeProcess').mockResolvedValue({ + stdout: 'backend\nfrontend\n', + } as utils.ProcessResult); + + await expect(nxHandler.listProjects(options)).rejects.toThrow( + "Invalid non-JSON output from 'nx show projects' - SyntaxError: Unexpected token", + ); + }); + + it("should throw if `nx show projects` JSON output isn't array of strings", async () => { + vi.spyOn(utils, 'executeProcess').mockResolvedValue({ + stdout: '"backend"', + } as utils.ProcessResult); + + await expect(nxHandler.listProjects(options)).rejects.toThrow( + 'Invalid JSON output from \'nx show projects\', expected array of strings, received "backend"', + ); + }); + }); + + describe('createRunManyCommand', () => { + it('should run script for all projects sequentially by default', () => { + expect(nxHandler.createRunManyCommand(options)).toBe( + 'npx nx run-many --targets=code-pushup --parallel=false --', + ); + }); + + it('should set parallel flag with default number of tasks', () => { + expect( + nxHandler.createRunManyCommand({ ...options, parallel: true }), + ).toBe('npx nx run-many --targets=code-pushup --parallel=true --'); + }); + + it('should set parallel flag with custom number of tasks', () => { + expect(nxHandler.createRunManyCommand({ ...options, parallel: 5 })).toBe( + 'npx nx run-many --targets=code-pushup --parallel=5 --', + ); + }); + + it('should filter projects by list of project names', () => { + expect(nxHandler.createRunManyCommand(options, ['web', 'cms'])).toBe( + 'npx nx run-many --targets=code-pushup --projects=web,cms --parallel=false --', + ); + }); + }); +}); diff --git a/packages/ci/src/lib/monorepo/handlers/pnpm.ts b/packages/ci/src/lib/monorepo/handlers/pnpm.ts index 3083ecc64..e2549663b 100644 --- a/packages/ci/src/lib/monorepo/handlers/pnpm.ts +++ b/packages/ci/src/lib/monorepo/handlers/pnpm.ts @@ -56,7 +56,6 @@ export const pnpmHandler: MonorepoToolHandler = { '--recursive', `--workspace-concurrency=${workspaceConcurrency}`, ...(onlyProjects?.map(project => `--filter=${project}`) ?? []), - 'run', options.task, ].join(' '); }, diff --git a/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts new file mode 100644 index 000000000..cc616ceaf --- /dev/null +++ b/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts @@ -0,0 +1,187 @@ +import { vol } from 'memfs'; +import type { PackageJson } from 'type-fest'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import type { MonorepoHandlerOptions, ProjectConfig } from '../tools'; +import { pnpmHandler } from './pnpm'; + +describe('pnpmHandler', () => { + const options = { + cwd: MEMFS_VOLUME, + task: 'code-pushup', + parallel: false, + } as MonorepoHandlerOptions; + + const pkgJsonContent = (content: PackageJson): string => + JSON.stringify(content); + + describe('isConfigured', () => { + it('should detect PNPM workspace when pnpm-workspace.yaml and package.json files exist', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({}), + 'pnpm-workspace.yaml': 'packages:\n- apps/*\n- libs/*\n\n', + }, + MEMFS_VOLUME, + ); + await expect(pnpmHandler.isConfigured(options)).resolves.toBe(true); + }); + + it("should NOT detect PNPM workspace when pnpm-workspace.yaml doesn't exist", async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({}), + 'pnpm-lock.yaml': '', + }, + MEMFS_VOLUME, + ); + await expect(pnpmHandler.isConfigured(options)).resolves.toBe(false); + }); + + it("should NOT detect PNPM workspace when root package.json doesn't exist", async () => { + vol.fromJSON( + { + 'packages/cli/package.json': pkgJsonContent({}), + 'packages/cli/pnpm-lock.yaml': '', + 'packages/core/package.json': pkgJsonContent({}), + 'packages/core/pnpm-lock.yaml': '', + }, + MEMFS_VOLUME, + ); + await expect(pnpmHandler.isConfigured(options)).resolves.toBe(false); + }); + }); + + describe('listProjects', () => { + it('should list all PNPM workspace packages with code-pushup script', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({}), + 'pnpm-workspace.yaml': 'packages:\n- apps/*\n- libs/*\n\n', + 'apps/backend/package.json': pkgJsonContent({ + name: 'backend', + scripts: { 'code-pushup': 'code-pushup --no-progress' }, + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + 'apps/frontend/package.json': pkgJsonContent({ + name: 'frontend', + // missing script + }), + 'libs/shared/package.json': pkgJsonContent({ + name: 'shared', + scripts: { 'code-pushup': 'code-pushup --no-progress' }, + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + }, + MEMFS_VOLUME, + ); + + await expect(pnpmHandler.listProjects(options)).resolves.toEqual([ + { + name: 'backend', + bin: 'pnpm --filter=backend run code-pushup', + }, + { + name: 'shared', + bin: 'pnpm --filter=shared run code-pushup', + }, + ] satisfies ProjectConfig[]); + }); + + it('should list all PNPM workspace packages with code-pushup dependency', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({}), + 'pnpm-workspace.yaml': 'packages:\n- apps/*\n- libs/*\n\n', + 'apps/backend/package.json': pkgJsonContent({ + name: 'backend', + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + 'apps/frontend/package.json': pkgJsonContent({ + name: 'frontend', + // missing dependency + }), + 'libs/shared/package.json': pkgJsonContent({ + name: 'shared', + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + }, + MEMFS_VOLUME, + ); + + await expect(pnpmHandler.listProjects(options)).resolves.toEqual([ + { + name: 'backend', + bin: 'pnpm --filter=backend exec code-pushup', + }, + { + name: 'shared', + bin: 'pnpm --filter=shared exec code-pushup', + }, + ] satisfies ProjectConfig[]); + }); + + it('should list all PNPM workspace packages when code-pushup installed at root level', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['apps/*', 'libs/*'], + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + 'pnpm-workspace.yaml': 'packages:\n- apps/*\n- libs/*\n\n', + 'apps/backend/package.json': pkgJsonContent({ + name: 'backend', + }), + 'apps/frontend/package.json': pkgJsonContent({ + name: 'frontend', + }), + 'libs/shared/package.json': pkgJsonContent({ + name: 'shared', + }), + }, + MEMFS_VOLUME, + ); + + await expect(pnpmHandler.listProjects(options)).resolves.toEqual([ + { + name: 'backend', + bin: 'pnpm --filter=backend exec code-pushup', + }, + { + name: 'frontend', + bin: 'pnpm --filter=frontend exec code-pushup', + }, + { + name: 'shared', + bin: 'pnpm --filter=shared exec code-pushup', + }, + ] satisfies ProjectConfig[]); + }); + }); + + describe('createRunManyCommand', () => { + it('should run script for all workspace packages sequentially by default', () => { + expect(pnpmHandler.createRunManyCommand(options)).toBe( + 'pnpm --recursive --workspace-concurrency=1 code-pushup', + ); + }); + + it('should set parallel flag with default number of jobs', () => { + expect( + pnpmHandler.createRunManyCommand({ ...options, parallel: true }), + ).toBe('pnpm --recursive --workspace-concurrency=4 code-pushup'); + }); + + it('should set parallel flag with custom number of jobs', () => { + expect( + pnpmHandler.createRunManyCommand({ ...options, parallel: 5 }), + ).toBe('pnpm --recursive --workspace-concurrency=5 code-pushup'); + }); + + it('should filter workspace packages by list of project names', () => { + expect(pnpmHandler.createRunManyCommand(options, ['core', 'utils'])).toBe( + 'pnpm --recursive --workspace-concurrency=1 --filter=core --filter=utils code-pushup', + ); + }); + }); +}); diff --git a/packages/ci/src/lib/monorepo/handlers/turbo.ts b/packages/ci/src/lib/monorepo/handlers/turbo.ts index 1c8e3c126..ebefa25f5 100644 --- a/packages/ci/src/lib/monorepo/handlers/turbo.ts +++ b/packages/ci/src/lib/monorepo/handlers/turbo.ts @@ -39,7 +39,7 @@ export const turboHandler: MonorepoToolHandler = { } } throw new Error( - `Package manager for Turborepo not found, expected one of ${WORKSPACE_HANDLERS.map( + `Package manager with workspace configuration not found in Turborepo, expected one of ${WORKSPACE_HANDLERS.map( ({ tool }) => tool, ).join('/')}`, ); diff --git a/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts new file mode 100644 index 000000000..37edad7f2 --- /dev/null +++ b/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts @@ -0,0 +1,185 @@ +import { vol } from 'memfs'; +import type { PackageJson } from 'type-fest'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import type { MonorepoHandlerOptions, ProjectConfig } from '../tools'; +import { turboHandler } from './turbo'; + +describe('turboHandler', () => { + const options = { + cwd: MEMFS_VOLUME, + task: 'code-pushup', + parallel: false, + } as MonorepoHandlerOptions; + + const pkgJsonContent = (content: PackageJson): string => + JSON.stringify(content); + const turboJsonContent = (content: { tasks: Record }) => + JSON.stringify(content); + + describe('isConfigured', () => { + it('should detect Turborepo when turbo.json exists and has code-pushup task', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({}), + 'turbo.json': turboJsonContent({ + tasks: { + 'code-pushup': { + env: ['CP_API_KEY'], + outputs: ['.code-pushup'], + }, + }, + }), + }, + MEMFS_VOLUME, + ); + await expect(turboHandler.isConfigured(options)).resolves.toBe(true); + }); + + it("should NOT detect Turborepo when turbo.json doesn't exist", async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({}), + 'pnpm-lock.yaml': '', + }, + MEMFS_VOLUME, + ); + await expect(turboHandler.isConfigured(options)).resolves.toBe(false); + }); + + it("should NOT detect Turborepo when turbo.json doesn't include code-pushup task", async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({}), + 'turbo.json': turboJsonContent({ + tasks: { + build: { + dependsOn: ['^build'], + outputs: ['dist/**'], + }, + lint: {}, + test: {}, + dev: { + cache: false, + persistent: true, + }, + }, + }), + }, + MEMFS_VOLUME, + ); + await expect(turboHandler.isConfigured(options)).resolves.toBe(false); + }); + }); + + describe('listProjects', () => { + it.each([ + [ + 'PNPM workspace', + { + 'package.json': pkgJsonContent({}), + 'pnpm-lock.yaml': '', + 'pnpm-workspace.yaml': 'packages:\n- packages/*\n\n', + }, + ], + [ + 'Yarn workspaces', + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['packages/*'], + }), + 'yarn.lock': '', + }, + ], + [ + 'NPM workspaces', + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['packages/*'], + }), + 'package-lock.json': '', + }, + ], + ])( + 'should detect %s and list all packages with code-pushup script', + async (_, packageManagerFiles) => { + vol.fromJSON( + { + ...packageManagerFiles, + 'turbo.json': turboJsonContent({ tasks: { 'code-pushup': {} } }), + 'e2e/package.json': pkgJsonContent({ name: 'e2e' }), // not in workspace patterns + 'packages/cli/package.json': pkgJsonContent({ + name: '@example/cli', + scripts: { 'code-pushup': 'code-pushup --no-progress' }, + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + 'packages/core/package.json': pkgJsonContent({ + name: '@example/core', + scripts: { 'code-pushup': 'code-pushup --no-progress' }, + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + 'packages/utils/package.json': pkgJsonContent({ + name: '@example/utils', + // missing script + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + }, + MEMFS_VOLUME, + ); + + await expect(turboHandler.listProjects(options)).resolves.toEqual([ + { + name: '@example/cli', + bin: 'npx turbo run code-pushup --filter=@example/cli --', + }, + { + name: '@example/core', + bin: 'npx turbo run code-pushup --filter=@example/core --', + }, + ] satisfies ProjectConfig[]); + }, + ); + + it('should throw if no supported package manager configured', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({}), + 'package-lock.json': '', + 'turbo.json': turboJsonContent({ tasks: { 'code-pushup': {} } }), + }, + MEMFS_VOLUME, + ); + + await expect(turboHandler.listProjects(options)).rejects.toThrow( + 'Package manager with workspace configuration not found in Turborepo, expected one of pnpm/yarn/npm', + ); + }); + }); + + describe('createRunManyCommand', () => { + it('should run script for all projects sequentially by default', () => { + expect(turboHandler.createRunManyCommand(options)).toBe( + 'npx turbo run code-pushup --concurrency=1 --', + ); + }); + + it('should set parallel flag with default number of jobs', () => { + expect( + turboHandler.createRunManyCommand({ ...options, parallel: true }), + ).toBe('npx turbo run code-pushup --concurrency=10 --'); + }); + + it('should set parallel flag with custom number of jobs', () => { + expect( + turboHandler.createRunManyCommand({ ...options, parallel: 5 }), + ).toBe('npx turbo run code-pushup --concurrency=5 --'); + }); + + it('should filter projects by list of project names', () => { + expect(turboHandler.createRunManyCommand(options, ['web', 'cms'])).toBe( + 'npx turbo run code-pushup --filter=web --filter=cms --concurrency=1 --', + ); + }); + }); +}); diff --git a/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts new file mode 100644 index 000000000..3847df82a --- /dev/null +++ b/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts @@ -0,0 +1,226 @@ +import { vol } from 'memfs'; +import type { PackageJson } from 'type-fest'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import * as utils from '@code-pushup/utils'; +import type { MonorepoHandlerOptions, ProjectConfig } from '../tools'; +import { yarnHandler } from './yarn'; + +describe('yarnHandler', () => { + const options = { + cwd: MEMFS_VOLUME, + task: 'code-pushup', + parallel: false, + } as MonorepoHandlerOptions; + + const pkgJsonContent = (content: PackageJson): string => + JSON.stringify(content); + + describe('isConfigured', () => { + it('should detect Yarn workspaces when yarn.lock exists and "workspaces" set in package.json', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['packages/*'], + }), + 'yarn.lock': '', + }, + MEMFS_VOLUME, + ); + await expect(yarnHandler.isConfigured(options)).resolves.toBe(true); + }); + + it('should NOT detect Yarn workspaces when "workspaces" not set in package.json', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({}), + 'yarn.lock': '', + }, + MEMFS_VOLUME, + ); + await expect(yarnHandler.isConfigured(options)).resolves.toBe(false); + }); + + it("should NOT detect Yarn workspaces when yarn.lock doesn't exist", async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['packages/*'], + }), + 'package-lock.json': '', + }, + MEMFS_VOLUME, + ); + await expect(yarnHandler.isConfigured(options)).resolves.toBe(false); + }); + }); + + describe('listProjects', () => { + it('should list all Yarn workspaces with code-pushup script', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['apps/*', 'libs/*'], + }), + 'yarn.lock': '', + 'apps/backend/package.json': pkgJsonContent({ + name: 'backend', + scripts: { 'code-pushup': 'code-pushup --no-progress' }, + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + 'apps/frontend/package.json': pkgJsonContent({ + name: 'frontend', + // missing script + }), + 'libs/shared/package.json': pkgJsonContent({ + name: 'shared', + scripts: { 'code-pushup': 'code-pushup --no-progress' }, + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + }, + MEMFS_VOLUME, + ); + + await expect(yarnHandler.listProjects(options)).resolves.toEqual([ + { + name: 'backend', + bin: 'yarn workspace backend run code-pushup', + }, + { + name: 'shared', + bin: 'yarn workspace shared run code-pushup', + }, + ] satisfies ProjectConfig[]); + }); + + it('should list all Yarn workspaces with code-pushup dependency', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['apps/*', 'libs/*'], + }), + 'yarn.lock': '', + 'apps/backend/package.json': pkgJsonContent({ + name: 'backend', + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + 'apps/frontend/package.json': pkgJsonContent({ + name: 'frontend', + // missing dependency + }), + 'libs/shared/package.json': pkgJsonContent({ + name: 'shared', + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + }, + MEMFS_VOLUME, + ); + + await expect(yarnHandler.listProjects(options)).resolves.toEqual([ + { + name: 'backend', + bin: 'yarn workspace backend exec code-pushup', + }, + { + name: 'shared', + bin: 'yarn workspace shared exec code-pushup', + }, + ] satisfies ProjectConfig[]); + }); + + it('should list all Yarn workspaces when code-pushup installed at root level', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['apps/*', 'libs/*'], + devDependencies: { '@code-pushup/cli': 'latest' }, + }), + 'yarn.lock': '', + 'apps/backend/package.json': pkgJsonContent({ + name: 'backend', + }), + 'apps/frontend/package.json': pkgJsonContent({ + name: 'frontend', + }), + 'libs/shared/package.json': pkgJsonContent({ + name: 'shared', + }), + }, + MEMFS_VOLUME, + ); + + await expect(yarnHandler.listProjects(options)).resolves.toEqual([ + { + name: 'backend', + bin: 'yarn workspace backend exec code-pushup', + }, + { + name: 'frontend', + bin: 'yarn workspace frontend exec code-pushup', + }, + { + name: 'shared', + bin: 'yarn workspace shared exec code-pushup', + }, + ] satisfies ProjectConfig[]); + }); + }); + + describe('createRunManyCommand', () => { + // eslint-disable-next-line vitest/max-nested-describe + describe('classic Yarn (v1)', () => { + beforeEach(() => { + vi.spyOn(utils, 'executeProcess').mockResolvedValue({ + stdout: '1.22.19', + } as utils.ProcessResult); + }); + + it('should run script for all workspaces sequentially', async () => { + await expect(yarnHandler.createRunManyCommand(options)).resolves.toBe( + 'yarn workspaces run code-pushup', + ); + }); + }); + + // eslint-disable-next-line vitest/max-nested-describe + describe('modern Yarn (v2+)', () => { + beforeEach(() => { + vi.spyOn(utils, 'executeProcess').mockResolvedValue({ + stdout: '4.5.0', + } as utils.ProcessResult); + }); + + it('should run script for all workspaces sequentially by default', async () => { + await expect(yarnHandler.createRunManyCommand(options)).resolves.toBe( + 'yarn workspaces foreach --all code-pushup', + ); + }); + + it('should set parallel flag with default number of jobs', async () => { + await expect( + yarnHandler.createRunManyCommand({ ...options, parallel: true }), + ).resolves.toBe('yarn workspaces foreach --parallel --all code-pushup'); + }); + + it('should set parallel flag with custom number of jobs', async () => { + await expect( + yarnHandler.createRunManyCommand({ ...options, parallel: 5 }), + ).resolves.toBe( + 'yarn workspaces foreach --parallel --jobs=5 --all code-pushup', + ); + }); + + it('should filter workspaces by list of project names', async () => { + await expect( + yarnHandler.createRunManyCommand(options, ['core', 'utils']), + ).resolves.toBe( + 'yarn workspaces foreach --include=core --include=utils code-pushup', + ); + }); + }); + }); +}); From a89d5d07ab1ed4a9db2d2b90bddb7ee799ec40c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Thu, 5 Dec 2024 10:50:43 +0100 Subject: [PATCH 06/38] feat(ci): filter nx run-many by projects from nx show projects as fallback --- .../lib/monorepo/handlers/npm.unit.test.ts | 15 ++++++- packages/ci/src/lib/monorepo/handlers/nx.ts | 9 ++-- .../src/lib/monorepo/handlers/nx.unit.test.ts | 43 ++++++++++++++----- packages/ci/src/lib/monorepo/handlers/pnpm.ts | 4 +- .../lib/monorepo/handlers/pnpm.unit.test.ts | 32 +++++++++++--- .../ci/src/lib/monorepo/handlers/turbo.ts | 4 +- .../lib/monorepo/handlers/turbo.unit.test.ts | 35 ++++++++++++--- packages/ci/src/lib/monorepo/handlers/yarn.ts | 4 +- .../lib/monorepo/handlers/yarn.unit.test.ts | 43 ++++++++++++++----- packages/ci/src/lib/monorepo/list-projects.ts | 5 ++- packages/ci/src/lib/monorepo/tools.ts | 7 ++- 11 files changed, 154 insertions(+), 47 deletions(-) diff --git a/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts index 6ab8e8455..642e2dca4 100644 --- a/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts @@ -1,7 +1,11 @@ import { vol } from 'memfs'; import type { PackageJson } from 'type-fest'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; -import type { MonorepoHandlerOptions, ProjectConfig } from '../tools'; +import type { + MonorepoHandlerOptions, + MonorepoHandlerProjectsContext, + ProjectConfig, +} from '../tools'; import { npmHandler } from './npm'; describe('npmHandler', () => { @@ -169,8 +173,15 @@ describe('npmHandler', () => { }); describe('createRunManyCommand', () => { + const projects: MonorepoHandlerProjectsContext = { + all: [ + { name: 'api', bin: 'npm --workspace=api run code-pushup --' }, + { name: 'ui', bin: 'npm --workspace=ui run code-pushup --' }, + ], + }; + it('should create command to run npm script for all workspaces', () => { - expect(npmHandler.createRunManyCommand(options)).toBe( + expect(npmHandler.createRunManyCommand(options, projects)).toBe( 'npm run code-pushup --workspaces --if-present --', ); }); diff --git a/packages/ci/src/lib/monorepo/handlers/nx.ts b/packages/ci/src/lib/monorepo/handlers/nx.ts index 19d26128f..32195097b 100644 --- a/packages/ci/src/lib/monorepo/handlers/nx.ts +++ b/packages/ci/src/lib/monorepo/handlers/nx.ts @@ -47,15 +47,16 @@ export const nxHandler: MonorepoToolHandler = { })); }, - createRunManyCommand(options, onlyProjects) { + createRunManyCommand(options, projects) { + const projectNames: string[] = + projects.only ?? projects.all.map(({ name }) => name); return [ 'npx', 'nx', - 'run-many', // TODO: allow affected instead of run-many? + 'run-many', `--targets=${options.task}`, - // TODO: add options.nxRunManyFilter? (e.g. --exclude=...) - ...(onlyProjects ? [`--projects=${onlyProjects.join(',')}`] : []), `--parallel=${options.parallel}`, + `--projects=${projectNames.join(',')}`, '--', ].join(' '); }, diff --git a/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts index 2ba543517..0b75e0293 100644 --- a/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts @@ -1,7 +1,11 @@ import { vol } from 'memfs'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import * as utils from '@code-pushup/utils'; -import type { MonorepoHandlerOptions, ProjectConfig } from '../tools'; +import type { + MonorepoHandlerOptions, + MonorepoHandlerProjectsContext, + ProjectConfig, +} from '../tools'; import { nxHandler } from './nx'; describe('nxHandler', () => { @@ -113,27 +117,46 @@ describe('nxHandler', () => { }); describe('createRunManyCommand', () => { - it('should run script for all projects sequentially by default', () => { - expect(nxHandler.createRunManyCommand(options)).toBe( - 'npx nx run-many --targets=code-pushup --parallel=false --', + const projects: MonorepoHandlerProjectsContext = { + all: [ + { name: 'backend', bin: 'npx nx run backend:code-pushup --' }, + { name: 'frontend', bin: 'npx nx run frontend:code-pushup --' }, + ], + }; + + it('should run script for all listed projects sequentially by default', () => { + expect(nxHandler.createRunManyCommand(options, projects)).toBe( + 'npx nx run-many --targets=code-pushup --parallel=false --projects=backend,frontend --', ); }); it('should set parallel flag with default number of tasks', () => { expect( - nxHandler.createRunManyCommand({ ...options, parallel: true }), - ).toBe('npx nx run-many --targets=code-pushup --parallel=true --'); + nxHandler.createRunManyCommand( + { ...options, parallel: true }, + projects, + ), + ).toBe( + 'npx nx run-many --targets=code-pushup --parallel=true --projects=backend,frontend --', + ); }); it('should set parallel flag with custom number of tasks', () => { - expect(nxHandler.createRunManyCommand({ ...options, parallel: 5 })).toBe( - 'npx nx run-many --targets=code-pushup --parallel=5 --', + expect( + nxHandler.createRunManyCommand({ ...options, parallel: 5 }, projects), + ).toBe( + 'npx nx run-many --targets=code-pushup --parallel=5 --projects=backend,frontend --', ); }); it('should filter projects by list of project names', () => { - expect(nxHandler.createRunManyCommand(options, ['web', 'cms'])).toBe( - 'npx nx run-many --targets=code-pushup --projects=web,cms --parallel=false --', + expect( + nxHandler.createRunManyCommand(options, { + ...projects, + only: ['frontend'], + }), + ).toBe( + 'npx nx run-many --targets=code-pushup --parallel=false --projects=frontend --', ); }); }); diff --git a/packages/ci/src/lib/monorepo/handlers/pnpm.ts b/packages/ci/src/lib/monorepo/handlers/pnpm.ts index e2549663b..b43cfd73e 100644 --- a/packages/ci/src/lib/monorepo/handlers/pnpm.ts +++ b/packages/ci/src/lib/monorepo/handlers/pnpm.ts @@ -44,7 +44,7 @@ export const pnpmHandler: MonorepoToolHandler = { })); }, - createRunManyCommand(options, onlyProjects) { + createRunManyCommand(options, projects) { const workspaceConcurrency: number = options.parallel === true ? DEFAULT_WORKSPACE_CONCURRENCY @@ -55,7 +55,7 @@ export const pnpmHandler: MonorepoToolHandler = { 'pnpm', '--recursive', `--workspace-concurrency=${workspaceConcurrency}`, - ...(onlyProjects?.map(project => `--filter=${project}`) ?? []), + ...(projects.only?.map(project => `--filter=${project}`) ?? []), options.task, ].join(' '); }, diff --git a/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts index cc616ceaf..49bf978b9 100644 --- a/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts @@ -1,7 +1,11 @@ import { vol } from 'memfs'; import type { PackageJson } from 'type-fest'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; -import type { MonorepoHandlerOptions, ProjectConfig } from '../tools'; +import type { + MonorepoHandlerOptions, + MonorepoHandlerProjectsContext, + ProjectConfig, +} from '../tools'; import { pnpmHandler } from './pnpm'; describe('pnpmHandler', () => { @@ -160,27 +164,43 @@ describe('pnpmHandler', () => { }); describe('createRunManyCommand', () => { + const projects: MonorepoHandlerProjectsContext = { + all: [ + { name: 'backend', bin: 'pnpm --filter=backend run code-pushup' }, + { name: 'frontend', bin: 'pnpm --filter=frontend run code-pushup' }, + { name: 'shared', bin: 'pnpm --filter=shared run code-pushup' }, + ], + }; + it('should run script for all workspace packages sequentially by default', () => { - expect(pnpmHandler.createRunManyCommand(options)).toBe( + expect(pnpmHandler.createRunManyCommand(options, projects)).toBe( 'pnpm --recursive --workspace-concurrency=1 code-pushup', ); }); it('should set parallel flag with default number of jobs', () => { expect( - pnpmHandler.createRunManyCommand({ ...options, parallel: true }), + pnpmHandler.createRunManyCommand( + { ...options, parallel: true }, + projects, + ), ).toBe('pnpm --recursive --workspace-concurrency=4 code-pushup'); }); it('should set parallel flag with custom number of jobs', () => { expect( - pnpmHandler.createRunManyCommand({ ...options, parallel: 5 }), + pnpmHandler.createRunManyCommand({ ...options, parallel: 5 }, projects), ).toBe('pnpm --recursive --workspace-concurrency=5 code-pushup'); }); it('should filter workspace packages by list of project names', () => { - expect(pnpmHandler.createRunManyCommand(options, ['core', 'utils'])).toBe( - 'pnpm --recursive --workspace-concurrency=1 --filter=core --filter=utils code-pushup', + expect( + pnpmHandler.createRunManyCommand(options, { + ...projects, + only: ['frontend', 'shared'], + }), + ).toBe( + 'pnpm --recursive --workspace-concurrency=1 --filter=frontend --filter=shared code-pushup', ); }); }); diff --git a/packages/ci/src/lib/monorepo/handlers/turbo.ts b/packages/ci/src/lib/monorepo/handlers/turbo.ts index ebefa25f5..be986657b 100644 --- a/packages/ci/src/lib/monorepo/handlers/turbo.ts +++ b/packages/ci/src/lib/monorepo/handlers/turbo.ts @@ -45,7 +45,7 @@ export const turboHandler: MonorepoToolHandler = { ); }, - createRunManyCommand(options, onlyProjects) { + createRunManyCommand(options, projects) { const concurrency: number = options.parallel === true ? DEFAULT_CONCURRENCY @@ -57,7 +57,7 @@ export const turboHandler: MonorepoToolHandler = { 'turbo', 'run', options.task, - ...(onlyProjects?.map(project => `--filter=${project}`) ?? []), + ...(projects.only?.map(project => `--filter=${project}`) ?? []), `--concurrency=${concurrency}`, '--', ].join(' '); diff --git a/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts index 37edad7f2..4f6f2e6ad 100644 --- a/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts @@ -1,7 +1,11 @@ import { vol } from 'memfs'; import type { PackageJson } from 'type-fest'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; -import type { MonorepoHandlerOptions, ProjectConfig } from '../tools'; +import type { + MonorepoHandlerOptions, + MonorepoHandlerProjectsContext, + ProjectConfig, +} from '../tools'; import { turboHandler } from './turbo'; describe('turboHandler', () => { @@ -158,27 +162,46 @@ describe('turboHandler', () => { }); describe('createRunManyCommand', () => { + const projects: MonorepoHandlerProjectsContext = { + all: [ + { name: 'api', bin: 'npx turbo run code-pushup --filter=api --' }, + { name: 'cms', bin: 'npx turbo run code-pushup --filter=cms --' }, + { name: 'web', bin: 'npx turbo run code-pushup --filter=web --' }, + ], + }; + it('should run script for all projects sequentially by default', () => { - expect(turboHandler.createRunManyCommand(options)).toBe( + expect(turboHandler.createRunManyCommand(options, projects)).toBe( 'npx turbo run code-pushup --concurrency=1 --', ); }); it('should set parallel flag with default number of jobs', () => { expect( - turboHandler.createRunManyCommand({ ...options, parallel: true }), + turboHandler.createRunManyCommand( + { ...options, parallel: true }, + projects, + ), ).toBe('npx turbo run code-pushup --concurrency=10 --'); }); it('should set parallel flag with custom number of jobs', () => { expect( - turboHandler.createRunManyCommand({ ...options, parallel: 5 }), + turboHandler.createRunManyCommand( + { ...options, parallel: 5 }, + projects, + ), ).toBe('npx turbo run code-pushup --concurrency=5 --'); }); it('should filter projects by list of project names', () => { - expect(turboHandler.createRunManyCommand(options, ['web', 'cms'])).toBe( - 'npx turbo run code-pushup --filter=web --filter=cms --concurrency=1 --', + expect( + turboHandler.createRunManyCommand(options, { + ...projects, + only: ['cms', 'web'], + }), + ).toBe( + 'npx turbo run code-pushup --filter=cms --filter=web --concurrency=1 --', ); }); }); diff --git a/packages/ci/src/lib/monorepo/handlers/yarn.ts b/packages/ci/src/lib/monorepo/handlers/yarn.ts index e2022fb03..47705c24f 100644 --- a/packages/ci/src/lib/monorepo/handlers/yarn.ts +++ b/packages/ci/src/lib/monorepo/handlers/yarn.ts @@ -35,7 +35,7 @@ export const yarnHandler: MonorepoToolHandler = { })); }, - async createRunManyCommand(options, onlyProjects) { + async createRunManyCommand(options, projects) { const { stdout } = await executeProcess({ command: 'yarn', args: ['-v'] }); const isV1 = stdout.startsWith('1.'); @@ -52,7 +52,7 @@ export const yarnHandler: MonorepoToolHandler = { ...(typeof options.parallel === 'number' ? [`--jobs=${options.parallel}`] : []), - ...(onlyProjects?.map(project => `--include=${project}`) ?? ['--all']), + ...(projects.only?.map(project => `--include=${project}`) ?? ['--all']), options.task, ].join(' '); }, diff --git a/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts index 3847df82a..8e78fd5a4 100644 --- a/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts @@ -2,7 +2,11 @@ import { vol } from 'memfs'; import type { PackageJson } from 'type-fest'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import * as utils from '@code-pushup/utils'; -import type { MonorepoHandlerOptions, ProjectConfig } from '../tools'; +import type { + MonorepoHandlerOptions, + MonorepoHandlerProjectsContext, + ProjectConfig, +} from '../tools'; import { yarnHandler } from './yarn'; describe('yarnHandler', () => { @@ -171,6 +175,14 @@ describe('yarnHandler', () => { }); describe('createRunManyCommand', () => { + const projects: MonorepoHandlerProjectsContext = { + all: [ + { name: 'api', bin: 'yarn workspace api run code-pushup' }, + { name: 'cms', bin: 'yarn workspace cms run code-pushup' }, + { name: 'web', bin: 'yarn workspace web run code-pushup' }, + ], + }; + // eslint-disable-next-line vitest/max-nested-describe describe('classic Yarn (v1)', () => { beforeEach(() => { @@ -180,9 +192,9 @@ describe('yarnHandler', () => { }); it('should run script for all workspaces sequentially', async () => { - await expect(yarnHandler.createRunManyCommand(options)).resolves.toBe( - 'yarn workspaces run code-pushup', - ); + await expect( + yarnHandler.createRunManyCommand(options, projects), + ).resolves.toBe('yarn workspaces run code-pushup'); }); }); @@ -195,20 +207,26 @@ describe('yarnHandler', () => { }); it('should run script for all workspaces sequentially by default', async () => { - await expect(yarnHandler.createRunManyCommand(options)).resolves.toBe( - 'yarn workspaces foreach --all code-pushup', - ); + await expect( + yarnHandler.createRunManyCommand(options, projects), + ).resolves.toBe('yarn workspaces foreach --all code-pushup'); }); it('should set parallel flag with default number of jobs', async () => { await expect( - yarnHandler.createRunManyCommand({ ...options, parallel: true }), + yarnHandler.createRunManyCommand( + { ...options, parallel: true }, + projects, + ), ).resolves.toBe('yarn workspaces foreach --parallel --all code-pushup'); }); it('should set parallel flag with custom number of jobs', async () => { await expect( - yarnHandler.createRunManyCommand({ ...options, parallel: 5 }), + yarnHandler.createRunManyCommand( + { ...options, parallel: 5 }, + projects, + ), ).resolves.toBe( 'yarn workspaces foreach --parallel --jobs=5 --all code-pushup', ); @@ -216,9 +234,12 @@ describe('yarnHandler', () => { it('should filter workspaces by list of project names', async () => { await expect( - yarnHandler.createRunManyCommand(options, ['core', 'utils']), + yarnHandler.createRunManyCommand(options, { + ...projects, + only: ['api', 'cms'], + }), ).resolves.toBe( - 'yarn workspaces foreach --include=core --include=utils code-pushup', + 'yarn workspaces foreach --include=api --include=cms code-pushup', ); }); }); diff --git a/packages/ci/src/lib/monorepo/list-projects.ts b/packages/ci/src/lib/monorepo/list-projects.ts index 20dedb077..2647188c9 100644 --- a/packages/ci/src/lib/monorepo/list-projects.ts +++ b/packages/ci/src/lib/monorepo/list-projects.ts @@ -33,7 +33,10 @@ export async function listMonorepoProjects( tool, projects, runManyCommand: onlyProjects => - handler.createRunManyCommand(options, onlyProjects), + handler.createRunManyCommand(options, { + all: projects, + ...(onlyProjects?.length && { only: onlyProjects }), + }), }; } diff --git a/packages/ci/src/lib/monorepo/tools.ts b/packages/ci/src/lib/monorepo/tools.ts index 2dc26b6ea..ef5554bbc 100644 --- a/packages/ci/src/lib/monorepo/tools.ts +++ b/packages/ci/src/lib/monorepo/tools.ts @@ -9,7 +9,7 @@ export type MonorepoToolHandler = { listProjects: (options: MonorepoHandlerOptions) => Promise; createRunManyCommand: ( options: MonorepoHandlerOptions, - onlyProjects?: string[], + projects: MonorepoHandlerProjectsContext, ) => string | Promise; }; @@ -21,6 +21,11 @@ export type MonorepoHandlerOptions = { nxProjectsFilter: string | string[]; }; +export type MonorepoHandlerProjectsContext = { + only?: string[]; + all: ProjectConfig[]; +}; + export type ProjectConfig = { name: string; bin: string; From ef1258cd36397e518e94fbb54ed7de7d469d161d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Thu, 5 Dec 2024 13:39:38 +0100 Subject: [PATCH 07/38] test(ci): unit test package helpers --- .../ci/src/lib/monorepo/packages.unit.test.ts | 377 ++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 packages/ci/src/lib/monorepo/packages.unit.test.ts diff --git a/packages/ci/src/lib/monorepo/packages.unit.test.ts b/packages/ci/src/lib/monorepo/packages.unit.test.ts new file mode 100644 index 000000000..0767a6b09 --- /dev/null +++ b/packages/ci/src/lib/monorepo/packages.unit.test.ts @@ -0,0 +1,377 @@ +import { vol } from 'memfs'; +import { basename, join } from 'node:path'; +import type { PackageJson } from 'type-fest'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { + hasCodePushUpDependency, + hasDependency, + hasScript, + hasWorkspacesEnabled, + listPackages, + listWorkspaces, + readRootPackageJson, +} from './packages'; + +const pkgJsonContent = (content: PackageJson) => + JSON.stringify(content, null, 2); + +describe('listPackages', () => { + it('should search for all npm packages recursively by default', async () => { + vol.fromJSON( + { + 'e2e/package.json': pkgJsonContent({ name: 'e2e' }), + 'package.json': pkgJsonContent({ name: 'example-monorepo' }), + 'packages/cli/package.json': pkgJsonContent({ name: '@example/cli' }), + 'packages/core/package.json': pkgJsonContent({ name: '@example/core' }), + }, + MEMFS_VOLUME, + ); + + await expect(listPackages(MEMFS_VOLUME)).resolves.toEqual([ + { + name: 'e2e', + directory: join(MEMFS_VOLUME, 'e2e'), + packageJson: { name: 'e2e' }, + }, + { + name: 'example-monorepo', + directory: MEMFS_VOLUME, + packageJson: { name: 'example-monorepo' }, + }, + { + name: '@example/cli', + directory: join(MEMFS_VOLUME, 'packages', 'cli'), + packageJson: { name: '@example/cli' }, + }, + { + name: '@example/core', + directory: join(MEMFS_VOLUME, 'packages', 'core'), + packageJson: { name: '@example/core' }, + }, + ]); + }); + + it('should search for package.json files with custom glob patterns', async () => { + vol.fromJSON( + { + 'e2e/package.json': pkgJsonContent({ name: 'e2e' }), + 'package.json': pkgJsonContent({ name: 'example-monorepo' }), // not in patterns + 'packages/cli/package.json': pkgJsonContent({ name: '@example/cli' }), + 'packages/core/package.json': pkgJsonContent({ name: '@example/core' }), + 'scripts/docs/index.js': 'console.log("not yet implemented")', // no package.json + }, + MEMFS_VOLUME, + ); + + await expect( + listPackages(MEMFS_VOLUME, ['packages/*', 'e2e', 'scripts/*']), + ).resolves.toEqual([ + expect.objectContaining({ name: 'e2e' }), + expect.objectContaining({ name: '@example/cli' }), + expect.objectContaining({ name: '@example/core' }), + ]); + }); + + it('should sort packages by package.json path', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ name: 'example-monorepo' }), + 'packages/core/package.json': pkgJsonContent({ name: '@example/core' }), + 'e2e/package.json': pkgJsonContent({ name: 'e2e' }), + 'packages/cli/package.json': pkgJsonContent({ name: '@example/cli' }), + }, + MEMFS_VOLUME, + ); + + await expect(listPackages(MEMFS_VOLUME)).resolves.toEqual([ + expect.objectContaining({ name: 'e2e' }), + expect.objectContaining({ name: 'example-monorepo' }), + expect.objectContaining({ name: '@example/cli' }), + expect.objectContaining({ name: '@example/core' }), + ]); + }); + + it('should use parent folder name if "name" missing in package.json', async () => { + vol.fromJSON( + { + 'e2e/package.json': pkgJsonContent({}), + 'package.json': pkgJsonContent({}), + 'packages/cli/package.json': pkgJsonContent({ name: '@example/cli' }), + 'packages/core/package.json': pkgJsonContent({ name: '@example/core' }), + 'packages/utils/package.json': pkgJsonContent({}), + }, + MEMFS_VOLUME, + ); + + await expect(listPackages(MEMFS_VOLUME)).resolves.toEqual([ + expect.objectContaining({ name: 'e2e' }), + expect.objectContaining({ name: basename(MEMFS_VOLUME) }), + expect.objectContaining({ name: '@example/cli' }), + expect.objectContaining({ name: '@example/core' }), + expect.objectContaining({ name: 'utils' }), + ]); + }); +}); + +describe('listWorkspaces', () => { + it('should list workspaces named in root package.json', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['ui', 'api'], + }), + 'api/package.json': pkgJsonContent({ name: 'api' }), + 'e2e/package.json': pkgJsonContent({ name: 'e2e' }), // not in workspaces + 'ui/package.json': pkgJsonContent({ name: 'ui' }), + }, + MEMFS_VOLUME, + ); + + await expect(listWorkspaces(MEMFS_VOLUME)).resolves.toEqual({ + workspaces: [ + { + name: 'api', + directory: join(MEMFS_VOLUME, 'api'), + packageJson: { name: 'api' }, + }, + { + name: 'ui', + directory: join(MEMFS_VOLUME, 'ui'), + packageJson: { name: 'ui' }, + }, + ], + rootPackageJson: { + private: true, + workspaces: ['ui', 'api'], + }, + }); + }); + + it('should list workspaces matched by glob in root package.json', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['packages/*'], + }), + 'e2e/package.json': pkgJsonContent({ name: 'e2e' }), // not in workspaces + 'packages/cli/package.json': pkgJsonContent({ name: 'cli' }), + 'packages/core/package.json': pkgJsonContent({ name: 'core' }), + }, + MEMFS_VOLUME, + ); + + await expect(listWorkspaces(MEMFS_VOLUME)).resolves.toEqual({ + workspaces: [ + { + name: 'cli', + directory: join(MEMFS_VOLUME, 'packages', 'cli'), + packageJson: { name: 'cli' }, + }, + { + name: 'core', + directory: join(MEMFS_VOLUME, 'packages', 'core'), + packageJson: { name: 'core' }, + }, + ], + rootPackageJson: { + private: true, + workspaces: ['packages/*'], + }, + }); + }); + + it('should parse patterns from workspaces object config in root package.json', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: { + packages: ['apps/*'], + nohoist: ['**/mobile'], + }, + }), + 'apps/desktop/package.json': pkgJsonContent({ name: 'desktop' }), + 'apps/mobile/package.json': pkgJsonContent({ name: 'mobile' }), + }, + MEMFS_VOLUME, + ); + + await expect(listWorkspaces(MEMFS_VOLUME)).resolves.toEqual({ + workspaces: [ + { + name: 'desktop', + directory: join(MEMFS_VOLUME, 'apps', 'desktop'), + packageJson: { name: 'desktop' }, + }, + { + name: 'mobile', + directory: join(MEMFS_VOLUME, 'apps', 'mobile'), + packageJson: { name: 'mobile' }, + }, + ], + rootPackageJson: { + private: true, + workspaces: { + packages: ['apps/*'], + nohoist: ['**/mobile'], + }, + }, + }); + }); +}); + +describe('hasWorkspacesEnabled', () => { + it('should identify as NOT enabled if "workspaces" missing in root package.json', async () => { + vol.fromJSON( + { 'package.json': pkgJsonContent({ name: 'example', private: true }) }, + MEMFS_VOLUME, + ); + await expect(hasWorkspacesEnabled(MEMFS_VOLUME)).resolves.toBe(false); + }); + + it('should identify as NOT enabled if `"private": true` missing in root package.json', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + name: 'example', + workspaces: ['packages/*'], + }), + }, + MEMFS_VOLUME, + ); + await expect(hasWorkspacesEnabled(MEMFS_VOLUME)).resolves.toBe(false); + }); + + it('should identify as enabled if private and workspaces array set in root package.json', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: ['packages/*'], + }), + }, + MEMFS_VOLUME, + ); + await expect(hasWorkspacesEnabled(MEMFS_VOLUME)).resolves.toBe(true); + }); + + it('should identify as enabled if private and workspaces object set in root package.json', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({ + private: true, + workspaces: { + packages: ['packages/*'], + nohoist: ['**/react-native'], + }, + }), + }, + MEMFS_VOLUME, + ); + await expect(hasWorkspacesEnabled(MEMFS_VOLUME)).resolves.toBe(true); + }); +}); + +describe('readRootPackageJson', () => { + it('should read and parse package.json from current working directory', async () => { + vol.fromJSON( + { 'package.json': pkgJsonContent({ name: 'example' }) }, + MEMFS_VOLUME, + ); + await expect(readRootPackageJson(MEMFS_VOLUME)).resolves.toEqual({ + name: 'example', + }); + }); + + it("should throw if root package.json doesn't exist", async () => { + vol.fromJSON( + { + 'api/package.json': pkgJsonContent({ name: 'api' }), + 'ui/package.json': pkgJsonContent({ name: 'ui' }), + }, + MEMFS_VOLUME, + ); + await expect(readRootPackageJson(MEMFS_VOLUME)).rejects.toThrow( + 'no such file or directory', + ); + }); + + it("should throw if package.json exists but isn't valid JSON", async () => { + vol.fromJSON({ 'package.json': '' }, MEMFS_VOLUME); + await expect(readRootPackageJson(MEMFS_VOLUME)).rejects.toThrow( + 'Unexpected end of JSON input', + ); + }); +}); + +describe('hasScript', () => { + it('should return true if script in package.json "scripts"', () => { + expect( + hasScript( + { scripts: { 'code-pushup': 'code-pushup --no-progress' } }, + 'code-pushup', + ), + ).toBe(true); + }); + + it('should return false if script not in package.json "scripts"', () => { + expect(hasScript({}, 'code-pushup')).toBe(false); + }); +}); + +describe('hasDependency', () => { + it('should return true if package name in "dependencies"', () => { + expect(hasDependency({ dependencies: { react: '^19.0.0' } }, 'react')).toBe( + true, + ); + }); + + it('should return true if package name in "devDependencies"', () => { + expect(hasDependency({ devDependencies: { nx: '20.1.3' } }, 'nx')).toBe( + true, + ); + }); + + it('should return false if package name is neither in "dependencies" nor "devDependencies"', () => { + expect( + hasDependency( + { + dependencies: { react: '^19.0.0' }, + devDependencies: { typescript: '5.5.4' }, + }, + 'svelte', + ), + ).toBe(false); + }); +}); + +describe('hasCodePushUpDependency', () => { + it('should return true if @code-pushup/cli in "devDependencies"', () => { + expect( + hasCodePushUpDependency({ + devDependencies: { '@code-pushup/cli': '^0.55.0' }, + }), + ).toBe(true); + }); + + it('should return true if @code-pushup/cli in "dependencies"', () => { + expect( + hasCodePushUpDependency({ + dependencies: { '@code-pushup/cli': 'latest' }, + }), + ).toBe(true); + }); + + it('should return false if @code-pushup/cli is neither in "dependencies" nor "devDependencies"', () => { + expect( + hasCodePushUpDependency({ + dependencies: { + '@code-pushup/models': 'latest', + '@code-pushup/utils': 'latest', + }, + }), + ).toBe(false); + }); +}); From ff62fda4cced100fa0bc146192ab026c12b782ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Thu, 5 Dec 2024 16:14:09 +0100 Subject: [PATCH 08/38] test(ci-e2e): run actual git diff, only replace FETCH_HEAD --- e2e/ci-e2e/tests/ci.e2e.test.ts | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/e2e/ci-e2e/tests/ci.e2e.test.ts b/e2e/ci-e2e/tests/ci.e2e.test.ts index f631dac12..747e51f22 100644 --- a/e2e/ci-e2e/tests/ci.e2e.test.ts +++ b/e2e/ci-e2e/tests/ci.e2e.test.ts @@ -1,12 +1,7 @@ import { cp, readFile, rename } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - type DiffResult, - type FetchResult, - type SimpleGit, - simpleGit, -} from 'simple-git'; +import { type FetchResult, type SimpleGit, simpleGit } from 'simple-git'; import { afterEach } from 'vitest'; import { type Comment, @@ -53,12 +48,6 @@ describe('CI package', () => { git = await initGitRepo(simpleGit, { baseDir: ciSetupRepoDir }); - vi.spyOn(git, 'fetch').mockResolvedValue({} as FetchResult); - vi.spyOn(git, 'diffSummary').mockResolvedValue({ - files: [{ file: 'index.ts', binary: false }], - } as DiffResult); - vi.spyOn(git, 'diff').mockResolvedValue(''); - await git.add('index.js'); await git.add('code-pushup.config.ts'); await git.commit('Initial commit'); @@ -134,6 +123,25 @@ describe('CI package', () => { beforeEach(async () => { await git.checkoutLocalBranch('feature-1'); + // git fetch and FETCH_HEAD must be simulated because of missing remote + const originalDiffSummary = git.diffSummary.bind(git); + const originalDiff = git.diff.bind(git); + vi.spyOn(git, 'fetch').mockResolvedValue({} as FetchResult); + vi.spyOn(git, 'diffSummary').mockImplementation(args => + originalDiffSummary( + (args as unknown as string[]).map(arg => + arg === 'FETCH_HEAD' ? 'feature-1' : arg, + ), + ), + ); + vi.spyOn(git, 'diff').mockImplementation(args => + originalDiff( + (args as string[]).map(arg => + arg === 'FETCH_HEAD' ? 'feature-1' : arg, + ), + ), + ); + await rename( join(ciSetupRepoDir, 'index.js'), join(ciSetupRepoDir, 'index.ts'), From 0952ed6ba9f89a0b7c554b2d682b0c3f56906ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Thu, 5 Dec 2024 17:09:37 +0100 Subject: [PATCH 09/38] test(ci-e2e): extract repo setup --- e2e/ci-e2e/mocks/setup.ts | 75 +++++++++++++++++++++++++ e2e/ci-e2e/tests/ci.e2e.test.ts | 97 ++++++++------------------------- 2 files changed, 99 insertions(+), 73 deletions(-) create mode 100644 e2e/ci-e2e/mocks/setup.ts diff --git a/e2e/ci-e2e/mocks/setup.ts b/e2e/ci-e2e/mocks/setup.ts new file mode 100644 index 000000000..6a03ef9f4 --- /dev/null +++ b/e2e/ci-e2e/mocks/setup.ts @@ -0,0 +1,75 @@ +import { cp } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + type FetchResult, + type Response, + type SimpleGit, + simpleGit, +} from 'simple-git'; +import { nxTargetProject } from '@code-pushup/test-nx-utils'; +import { teardownTestFolder } from '@code-pushup/test-setup'; +import { + E2E_ENVIRONMENTS_DIR, + TEST_OUTPUT_DIR, + initGitRepo, +} from '@code-pushup/test-utils'; + +export type TestRepo = Awaited>; + +export async function setupTestRepo(folder: string) { + const fixturesDir = join( + fileURLToPath(dirname(import.meta.url)), + 'fixtures', + folder, + ); + const baseDir = join( + process.cwd(), + E2E_ENVIRONMENTS_DIR, + nxTargetProject(), + TEST_OUTPUT_DIR, + folder, + ); + const outputDir = join(baseDir, '.code-pushup'); + + await cp(fixturesDir, baseDir, { recursive: true }); + + const git = await initGitRepo(simpleGit, { baseDir }); + await simulateGitFetch(git); + + await git.add('.'); + await git.commit('Initial commit'); + + return { + git, + baseDir, + outputDir, + cleanup: () => teardownTestFolder(baseDir), + }; +} + +// git fetch and FETCH_HEAD must be simulated because of missing remote +async function simulateGitFetch(git: SimpleGit) { + let fetchHead: string = await git.branchLocal().then(resp => resp.current); + + vi.spyOn(git, 'fetch').mockImplementation((...args) => { + fetchHead = (args as unknown as [string, string, string[]])[1]; + return Promise.resolve({}) as Response; + }); + + const originalDiffSummary = git.diffSummary.bind(git); + const originalDiff = git.diff.bind(git); + + vi.spyOn(git, 'diffSummary').mockImplementation(args => + originalDiffSummary( + (args as unknown as string[]).map(arg => + arg === 'FETCH_HEAD' ? fetchHead : arg, + ), + ), + ); + vi.spyOn(git, 'diff').mockImplementation(args => + originalDiff( + (args as string[]).map(arg => (arg === 'FETCH_HEAD' ? fetchHead : arg)), + ), + ); +} diff --git a/e2e/ci-e2e/tests/ci.e2e.test.ts b/e2e/ci-e2e/tests/ci.e2e.test.ts index 747e51f22..174353aad 100644 --- a/e2e/ci-e2e/tests/ci.e2e.test.ts +++ b/e2e/ci-e2e/tests/ci.e2e.test.ts @@ -1,7 +1,6 @@ -import { cp, readFile, rename } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { type FetchResult, type SimpleGit, simpleGit } from 'simple-git'; +import { readFile, rename } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { SimpleGit } from 'simple-git'; import { afterEach } from 'vitest'; import { type Comment, @@ -11,54 +10,22 @@ import { type RunResult, runInCI, } from '@code-pushup/ci'; -import { nxTargetProject } from '@code-pushup/test-nx-utils'; -import { teardownTestFolder } from '@code-pushup/test-setup'; -import { - E2E_ENVIRONMENTS_DIR, - TEST_OUTPUT_DIR, - TEST_SNAPSHOTS_DIR, - initGitRepo, -} from '@code-pushup/test-utils'; +import { TEST_SNAPSHOTS_DIR } from '@code-pushup/test-utils'; +import { type TestRepo, setupTestRepo } from '../mocks/setup'; describe('CI package', () => { - const fixturesDir = join( - fileURLToPath(dirname(import.meta.url)), - '..', - 'mocks', - 'fixtures', - 'ci-test-repo', - ); - const ciSetupRepoDir = join( - process.cwd(), - E2E_ENVIRONMENTS_DIR, - nxTargetProject(), - TEST_OUTPUT_DIR, - 'ci-test-repo', - ); - const outputDir = join(ciSetupRepoDir, '.code-pushup'); - - const options = { - directory: ciSetupRepoDir, - } satisfies Options; - + let repo: TestRepo; let git: SimpleGit; + let options: Options; beforeEach(async () => { - await cp(fixturesDir, ciSetupRepoDir, { recursive: true }); - - git = await initGitRepo(simpleGit, { baseDir: ciSetupRepoDir }); - - await git.add('index.js'); - await git.add('code-pushup.config.ts'); - await git.commit('Initial commit'); + repo = await setupTestRepo('ci-test-repo'); + git = repo.git; + options = { directory: repo.baseDir }; }); afterEach(async () => { - await teardownTestFolder(ciSetupRepoDir); - }); - - afterAll(async () => { - await teardownTestFolder(ciSetupRepoDir); + await repo.cleanup(); }); describe('push event', () => { @@ -78,13 +45,13 @@ describe('CI package', () => { mode: 'standalone', files: { report: { - json: join(outputDir, 'report.json'), - md: join(outputDir, 'report.md'), + json: join(repo.outputDir, 'report.json'), + md: join(repo.outputDir, 'report.md'), }, }, } satisfies RunResult); - const jsonPromise = readFile(join(outputDir, 'report.json'), 'utf8'); + const jsonPromise = readFile(join(repo.outputDir, 'report.json'), 'utf8'); await expect(jsonPromise).resolves.toBeTruthy(); const report = JSON.parse(await jsonPromise) as Report; expect(report).toEqual( @@ -123,28 +90,9 @@ describe('CI package', () => { beforeEach(async () => { await git.checkoutLocalBranch('feature-1'); - // git fetch and FETCH_HEAD must be simulated because of missing remote - const originalDiffSummary = git.diffSummary.bind(git); - const originalDiff = git.diff.bind(git); - vi.spyOn(git, 'fetch').mockResolvedValue({} as FetchResult); - vi.spyOn(git, 'diffSummary').mockImplementation(args => - originalDiffSummary( - (args as unknown as string[]).map(arg => - arg === 'FETCH_HEAD' ? 'feature-1' : arg, - ), - ), - ); - vi.spyOn(git, 'diff').mockImplementation(args => - originalDiff( - (args as string[]).map(arg => - arg === 'FETCH_HEAD' ? 'feature-1' : arg, - ), - ), - ); - await rename( - join(ciSetupRepoDir, 'index.js'), - join(ciSetupRepoDir, 'index.ts'), + join(repo.baseDir, 'index.js'), + join(repo.baseDir, 'index.ts'), ); await git.add('index.ts'); @@ -163,17 +111,20 @@ describe('CI package', () => { newIssues: [], files: { report: { - json: join(outputDir, 'report.json'), - md: join(outputDir, 'report.md'), + json: join(repo.outputDir, 'report.json'), + md: join(repo.outputDir, 'report.md'), }, diff: { - json: join(outputDir, 'report-diff.json'), - md: join(outputDir, 'report-diff.md'), + json: join(repo.outputDir, 'report-diff.json'), + md: join(repo.outputDir, 'report-diff.md'), }, }, } satisfies RunResult); - const mdPromise = readFile(join(outputDir, 'report-diff.md'), 'utf8'); + const mdPromise = readFile( + join(repo.outputDir, 'report-diff.md'), + 'utf8', + ); await expect(mdPromise).resolves.toBeTruthy(); const md = await mdPromise; await expect( From aafcd962b80de237161d6cb517f640fe7dcca108 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Thu, 5 Dec 2024 17:13:55 +0100 Subject: [PATCH 10/38] test(ci-e2e): extract mock api provider --- e2e/ci-e2e/mocks/api.ts | 14 ++++++++++++++ e2e/ci-e2e/tests/ci.e2e.test.ts | 21 ++++----------------- 2 files changed, 18 insertions(+), 17 deletions(-) create mode 100644 e2e/ci-e2e/mocks/api.ts diff --git a/e2e/ci-e2e/mocks/api.ts b/e2e/ci-e2e/mocks/api.ts new file mode 100644 index 000000000..a59a12d8f --- /dev/null +++ b/e2e/ci-e2e/mocks/api.ts @@ -0,0 +1,14 @@ +import type { Comment, ProviderAPIClient } from '@code-pushup/ci'; + +export const MOCK_COMMENT: Comment = { + id: 42, + body: '... ', + url: 'https://github.com///pull/1#issuecomment-42', +}; + +export const MOCK_API: ProviderAPIClient = { + maxCommentChars: 1_000_000, + createComment: () => Promise.resolve(MOCK_COMMENT), + updateComment: () => Promise.resolve(MOCK_COMMENT), + listComments: () => Promise.resolve([]), +}; diff --git a/e2e/ci-e2e/tests/ci.e2e.test.ts b/e2e/ci-e2e/tests/ci.e2e.test.ts index 174353aad..49f569886 100644 --- a/e2e/ci-e2e/tests/ci.e2e.test.ts +++ b/e2e/ci-e2e/tests/ci.e2e.test.ts @@ -3,14 +3,13 @@ import { join } from 'node:path'; import type { SimpleGit } from 'simple-git'; import { afterEach } from 'vitest'; import { - type Comment, type GitRefs, type Options, - type ProviderAPIClient, type RunResult, runInCI, } from '@code-pushup/ci'; import { TEST_SNAPSHOTS_DIR } from '@code-pushup/test-utils'; +import { MOCK_API, MOCK_COMMENT } from '../mocks/api'; import { type TestRepo, setupTestRepo } from '../mocks/setup'; describe('CI package', () => { @@ -37,7 +36,7 @@ describe('CI package', () => { await expect( runInCI( { head: { ref: 'main', sha: await git.revparse('main') } }, - {} as ProviderAPIClient, + MOCK_API, options, git, ), @@ -73,18 +72,6 @@ describe('CI package', () => { }); describe('pull request event', () => { - const comment: Comment = { - id: 42, - body: '... ', - url: 'https://github.com///pull/1#issuecomment-42', - }; - const api: ProviderAPIClient = { - maxCommentChars: 1_000_000, - createComment: () => Promise.resolve(comment), - updateComment: () => Promise.resolve(comment), - listComments: () => Promise.resolve([]), - }; - let refs: GitRefs; beforeEach(async () => { @@ -105,9 +92,9 @@ describe('CI package', () => { }); it('should compare reports', async () => { - await expect(runInCI(refs, api, options, git)).resolves.toEqual({ + await expect(runInCI(refs, MOCK_API, options, git)).resolves.toEqual({ mode: 'standalone', - commentId: comment.id, + commentId: MOCK_COMMENT.id, newIssues: [], files: { report: { From a5b802de59be50e85e668c6b1593e71f8175b8f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Thu, 5 Dec 2024 18:09:31 +0100 Subject: [PATCH 11/38] fix(ci): handle non-JSON prefix/suffix lines from print-config --- packages/ci/src/lib/cli/commands/print-config.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/ci/src/lib/cli/commands/print-config.ts b/packages/ci/src/lib/cli/commands/print-config.ts index b2e69182a..dbf280770 100644 --- a/packages/ci/src/lib/cli/commands/print-config.ts +++ b/packages/ci/src/lib/cli/commands/print-config.ts @@ -15,9 +15,19 @@ export async function runPrintConfig({ if (!silent) { console.info(stdout); } + + // workaround for 1st lines like `> nx run utils:code-pushup -- print-config` + const lines = stdout.split(/\r?\n/); + const jsonLines = lines.slice(lines.indexOf('{'), lines.indexOf('}') + 1); + const stdoutSanitized = jsonLines.join('\n'); + try { - return JSON.parse(stdout) as unknown; + return JSON.parse(stdoutSanitized) as unknown; } catch (error) { + if (silent) { + console.info('Invalid output from print-config:'); + console.info(stdout); + } throw new Error( `Error parsing output of print-config command - ${stringifyError(error)}`, ); From c9bfd4176a7834ed62383c1ef6d0a0bc7f1e102d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Thu, 5 Dec 2024 18:11:05 +0100 Subject: [PATCH 12/38] test(ci-e2e): rename standalone test fixture --- .../fixtures/{ci-test-repo => basic}/code-pushup.config.ts | 0 e2e/ci-e2e/mocks/fixtures/basic/index.js | 1 + e2e/ci-e2e/mocks/fixtures/ci-test-repo/index.js | 1 - e2e/ci-e2e/tests/{ci.e2e.test.ts => basic.e2e.test.ts} | 4 ++-- 4 files changed, 3 insertions(+), 3 deletions(-) rename e2e/ci-e2e/mocks/fixtures/{ci-test-repo => basic}/code-pushup.config.ts (100%) create mode 100644 e2e/ci-e2e/mocks/fixtures/basic/index.js delete mode 100644 e2e/ci-e2e/mocks/fixtures/ci-test-repo/index.js rename e2e/ci-e2e/tests/{ci.e2e.test.ts => basic.e2e.test.ts} (97%) diff --git a/e2e/ci-e2e/mocks/fixtures/ci-test-repo/code-pushup.config.ts b/e2e/ci-e2e/mocks/fixtures/basic/code-pushup.config.ts similarity index 100% rename from e2e/ci-e2e/mocks/fixtures/ci-test-repo/code-pushup.config.ts rename to e2e/ci-e2e/mocks/fixtures/basic/code-pushup.config.ts diff --git a/e2e/ci-e2e/mocks/fixtures/basic/index.js b/e2e/ci-e2e/mocks/fixtures/basic/index.js new file mode 100644 index 000000000..e9fe0090d --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/basic/index.js @@ -0,0 +1 @@ +console.log('Hello, world!'); diff --git a/e2e/ci-e2e/mocks/fixtures/ci-test-repo/index.js b/e2e/ci-e2e/mocks/fixtures/ci-test-repo/index.js deleted file mode 100644 index 7f97cd8a0..000000000 --- a/e2e/ci-e2e/mocks/fixtures/ci-test-repo/index.js +++ /dev/null @@ -1 +0,0 @@ -console.log("Hello, world!") diff --git a/e2e/ci-e2e/tests/ci.e2e.test.ts b/e2e/ci-e2e/tests/basic.e2e.test.ts similarity index 97% rename from e2e/ci-e2e/tests/ci.e2e.test.ts rename to e2e/ci-e2e/tests/basic.e2e.test.ts index 49f569886..b1685e0a7 100644 --- a/e2e/ci-e2e/tests/ci.e2e.test.ts +++ b/e2e/ci-e2e/tests/basic.e2e.test.ts @@ -12,13 +12,13 @@ import { TEST_SNAPSHOTS_DIR } from '@code-pushup/test-utils'; import { MOCK_API, MOCK_COMMENT } from '../mocks/api'; import { type TestRepo, setupTestRepo } from '../mocks/setup'; -describe('CI package', () => { +describe('CI - standalone mode', () => { let repo: TestRepo; let git: SimpleGit; let options: Options; beforeEach(async () => { - repo = await setupTestRepo('ci-test-repo'); + repo = await setupTestRepo('basic'); git = repo.git; options = { directory: repo.baseDir }; }); From c813dd7764c75666828b0675090a505e73ac3660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Thu, 5 Dec 2024 18:36:46 +0100 Subject: [PATCH 13/38] feat(ci): sort nx projects alphabetically --- packages/ci/src/lib/monorepo/handlers/nx.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ci/src/lib/monorepo/handlers/nx.ts b/packages/ci/src/lib/monorepo/handlers/nx.ts index 32195097b..bb09c8d8e 100644 --- a/packages/ci/src/lib/monorepo/handlers/nx.ts +++ b/packages/ci/src/lib/monorepo/handlers/nx.ts @@ -41,7 +41,7 @@ export const nxHandler: MonorepoToolHandler = { observer: options.observer, }); const projects = parseProjects(stdout); - return projects.map(project => ({ + return projects.toSorted().map(project => ({ name: project, bin: `npx nx run ${project}:${options.task} --`, })); From b21c65490317b8578297381782dbff9ff2ce7fdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Thu, 5 Dec 2024 18:37:38 +0100 Subject: [PATCH 14/38] test(ci-e2e): set up nx monorepo fixture and test push event --- .../apps/api/code-pushup.config.js | 3 + .../nx-monorepo/apps/api/project.json | 9 ++ .../nx-monorepo/apps/api/src/index.js | 1 + .../apps/cms/code-pushup.config.js | 3 + .../nx-monorepo/apps/cms/project.json | 9 ++ .../nx-monorepo/apps/cms/src/index.js | 1 + .../apps/web/code-pushup.config.js | 3 + .../nx-monorepo/apps/web/project.json | 9 ++ .../nx-monorepo/apps/web/src/index.ts | 1 + .../nx-monorepo/code-pushup.preset.js | 66 ++++++++++++++ .../nx-monorepo/libs/ui/code-pushup.config.js | 3 + .../fixtures/nx-monorepo/libs/ui/project.json | 9 ++ .../nx-monorepo/libs/ui/src/index.test.ts | 6 ++ .../fixtures/nx-monorepo/libs/ui/src/index.ts | 1 + .../libs/utils/code-pushup.config.js | 3 + .../nx-monorepo/libs/utils/project.json | 9 ++ .../nx-monorepo/libs/utils/src/index.js | 1 + .../nx-monorepo/libs/utils/src/index.test.js | 6 ++ e2e/ci-e2e/mocks/fixtures/nx-monorepo/nx.json | 1 + e2e/ci-e2e/mocks/setup.ts | 2 - e2e/ci-e2e/tests/basic.e2e.test.ts | 19 ++-- e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts | 91 +++++++++++++++++++ 22 files changed, 246 insertions(+), 10 deletions(-) create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/api/code-pushup.config.js create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/api/project.json create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/api/src/index.js create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/cms/code-pushup.config.js create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/cms/project.json create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/cms/src/index.js create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/web/code-pushup.config.js create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/web/project.json create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/web/src/index.ts create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/code-pushup.preset.js create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/code-pushup.config.js create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/project.json create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/src/index.test.ts create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/src/index.ts create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/code-pushup.config.js create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/project.json create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/src/index.js create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/src/index.test.js create mode 100644 e2e/ci-e2e/mocks/fixtures/nx-monorepo/nx.json create mode 100644 e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/api/code-pushup.config.js b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/api/code-pushup.config.js new file mode 100644 index 000000000..984fdd122 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/api/code-pushup.config.js @@ -0,0 +1,3 @@ +import { resolveConfig } from '../../code-pushup.preset.js'; + +export default resolveConfig(import.meta.url); diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/api/project.json b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/api/project.json new file mode 100644 index 000000000..9a9308248 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/api/project.json @@ -0,0 +1,9 @@ +{ + "name": "api", + "projectType": "application", + "targets": { + "code-pushup": { + "command": "npx @code-pushup/cli --no-progress --config=apps/api/code-pushup.config.js" + } + } +} diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/api/src/index.js b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/api/src/index.js new file mode 100644 index 000000000..e9fe0090d --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/api/src/index.js @@ -0,0 +1 @@ +console.log('Hello, world!'); diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/cms/code-pushup.config.js b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/cms/code-pushup.config.js new file mode 100644 index 000000000..984fdd122 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/cms/code-pushup.config.js @@ -0,0 +1,3 @@ +import { resolveConfig } from '../../code-pushup.preset.js'; + +export default resolveConfig(import.meta.url); diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/cms/project.json b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/cms/project.json new file mode 100644 index 000000000..dfc038de3 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/cms/project.json @@ -0,0 +1,9 @@ +{ + "name": "cms", + "projectType": "application", + "targets": { + "code-pushup": { + "command": "npx @code-pushup/cli --no-progress --config=apps/cms/code-pushup.config.js" + } + } +} diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/cms/src/index.js b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/cms/src/index.js new file mode 100644 index 000000000..e9fe0090d --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/cms/src/index.js @@ -0,0 +1 @@ +console.log('Hello, world!'); diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/web/code-pushup.config.js b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/web/code-pushup.config.js new file mode 100644 index 000000000..984fdd122 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/web/code-pushup.config.js @@ -0,0 +1,3 @@ +import { resolveConfig } from '../../code-pushup.preset.js'; + +export default resolveConfig(import.meta.url); diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/web/project.json b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/web/project.json new file mode 100644 index 000000000..992d0e57e --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/web/project.json @@ -0,0 +1,9 @@ +{ + "name": "web", + "projectType": "application", + "targets": { + "code-pushup": { + "command": "npx @code-pushup/cli --no-progress --config=apps/web/code-pushup.config.js" + } + } +} diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/web/src/index.ts b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/web/src/index.ts new file mode 100644 index 000000000..e9fe0090d --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/apps/web/src/index.ts @@ -0,0 +1 @@ +console.log('Hello, world!'); diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/code-pushup.preset.js b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/code-pushup.preset.js new file mode 100644 index 000000000..43acc1537 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/code-pushup.preset.js @@ -0,0 +1,66 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { crawlFileSystem } from '@code-pushup/utils'; + +export function resolveConfig(url) { + const directory = fileURLToPath(dirname(url)); + return { + persist: { + outputDir: join(directory, '.code-pushup'), + }, + plugins: [ + { + slug: 'ts-migration', + title: 'TypeScript migration', + icon: 'typescript', + audits: [ + { + slug: 'ts-files', + title: 'Source files converted from JavaScript to TypeScript', + }, + ], + runner: async () => { + const paths = await crawlFileSystem({ + directory, + pattern: /\.[jt]s$/, + }); + const jsPaths = paths.filter(path => path.endsWith('.js')); + const tsPaths = paths.filter(path => path.endsWith('.ts')); + const jsFileCount = jsPaths.length; + const tsFileCount = tsPaths.length; + const ratio = tsFileCount / (jsFileCount + tsFileCount); + const percentage = Math.round(ratio * 100); + return [ + { + slug: 'ts-files', + value: percentage, + score: ratio, + displayValue: `${percentage}% converted`, + details: { + issues: jsPaths.map(file => ({ + message: 'Use .ts file extension instead of .js', + severity: 'warning', + source: { file }, + })), + }, + }, + ]; + }, + }, + ], + categories: [ + { + slug: 'ts-migration', + title: 'TypeScript migration', + refs: [ + { + type: 'audit', + plugin: 'ts-migration', + slug: 'ts-files', + weight: 1, + }, + ], + }, + ], + }; +} diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/code-pushup.config.js b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/code-pushup.config.js new file mode 100644 index 000000000..984fdd122 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/code-pushup.config.js @@ -0,0 +1,3 @@ +import { resolveConfig } from '../../code-pushup.preset.js'; + +export default resolveConfig(import.meta.url); diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/project.json b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/project.json new file mode 100644 index 000000000..aec5ff267 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/project.json @@ -0,0 +1,9 @@ +{ + "name": "ui", + "projectType": "library", + "targets": { + "code-pushup": { + "command": "npx @code-pushup/cli --no-progress --config=libs/ui/code-pushup.config.js" + } + } +} diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/src/index.test.ts b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/src/index.test.ts new file mode 100644 index 000000000..76b6b6045 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/src/index.test.ts @@ -0,0 +1,6 @@ +import assert from 'node:assert'; +import test from 'node:test'; + +test('1984', () => { + assert.equal(2 + 2, 5); +}); diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/src/index.ts b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/src/index.ts new file mode 100644 index 000000000..e9fe0090d --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/ui/src/index.ts @@ -0,0 +1 @@ +console.log('Hello, world!'); diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/code-pushup.config.js b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/code-pushup.config.js new file mode 100644 index 000000000..984fdd122 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/code-pushup.config.js @@ -0,0 +1,3 @@ +import { resolveConfig } from '../../code-pushup.preset.js'; + +export default resolveConfig(import.meta.url); diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/project.json b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/project.json new file mode 100644 index 000000000..fc0812779 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/project.json @@ -0,0 +1,9 @@ +{ + "name": "utils", + "projectType": "library", + "targets": { + "code-pushup": { + "command": "npx @code-pushup/cli --no-progress --config=libs/utils/code-pushup.config.js" + } + } +} diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/src/index.js b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/src/index.js new file mode 100644 index 000000000..e9fe0090d --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/src/index.js @@ -0,0 +1 @@ +console.log('Hello, world!'); diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/src/index.test.js b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/src/index.test.js new file mode 100644 index 000000000..76b6b6045 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/libs/utils/src/index.test.js @@ -0,0 +1,6 @@ +import assert from 'node:assert'; +import test from 'node:test'; + +test('1984', () => { + assert.equal(2 + 2, 5); +}); diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/nx.json b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/nx.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/nx.json @@ -0,0 +1 @@ +{} diff --git a/e2e/ci-e2e/mocks/setup.ts b/e2e/ci-e2e/mocks/setup.ts index 6a03ef9f4..1f7bc1c89 100644 --- a/e2e/ci-e2e/mocks/setup.ts +++ b/e2e/ci-e2e/mocks/setup.ts @@ -30,7 +30,6 @@ export async function setupTestRepo(folder: string) { TEST_OUTPUT_DIR, folder, ); - const outputDir = join(baseDir, '.code-pushup'); await cp(fixturesDir, baseDir, { recursive: true }); @@ -43,7 +42,6 @@ export async function setupTestRepo(folder: string) { return { git, baseDir, - outputDir, cleanup: () => teardownTestFolder(baseDir), }; } diff --git a/e2e/ci-e2e/tests/basic.e2e.test.ts b/e2e/ci-e2e/tests/basic.e2e.test.ts index b1685e0a7..22db1c6a7 100644 --- a/e2e/ci-e2e/tests/basic.e2e.test.ts +++ b/e2e/ci-e2e/tests/basic.e2e.test.ts @@ -44,13 +44,16 @@ describe('CI - standalone mode', () => { mode: 'standalone', files: { report: { - json: join(repo.outputDir, 'report.json'), - md: join(repo.outputDir, 'report.md'), + json: join(repo.baseDir, '.code-pushup/report.json'), + md: join(repo.baseDir, '.code-pushup/report.md'), }, }, } satisfies RunResult); - const jsonPromise = readFile(join(repo.outputDir, 'report.json'), 'utf8'); + const jsonPromise = readFile( + join(repo.baseDir, '.code-pushup/report.json'), + 'utf8', + ); await expect(jsonPromise).resolves.toBeTruthy(); const report = JSON.parse(await jsonPromise) as Report; expect(report).toEqual( @@ -98,18 +101,18 @@ describe('CI - standalone mode', () => { newIssues: [], files: { report: { - json: join(repo.outputDir, 'report.json'), - md: join(repo.outputDir, 'report.md'), + json: join(repo.baseDir, '.code-pushup/report.json'), + md: join(repo.baseDir, '.code-pushup/report.md'), }, diff: { - json: join(repo.outputDir, 'report-diff.json'), - md: join(repo.outputDir, 'report-diff.md'), + json: join(repo.baseDir, '.code-pushup/report-diff.json'), + md: join(repo.baseDir, '.code-pushup/report-diff.md'), }, }, } satisfies RunResult); const mdPromise = readFile( - join(repo.outputDir, 'report-diff.md'), + join(repo.baseDir, '.code-pushup/report-diff.md'), 'utf8', ); await expect(mdPromise).resolves.toBeTruthy(); diff --git a/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts b/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts new file mode 100644 index 000000000..7dcf93fc8 --- /dev/null +++ b/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts @@ -0,0 +1,91 @@ +import { join } from 'node:path'; +import type { SimpleGit } from 'simple-git'; +import { afterEach } from 'vitest'; +import { type Options, type RunResult, runInCI } from '@code-pushup/ci'; +import { readJsonFile } from '@code-pushup/utils'; +import { MOCK_API } from '../mocks/api'; +import { type TestRepo, setupTestRepo } from '../mocks/setup'; + +describe('CI - monorepo mode (Nx)', () => { + let repo: TestRepo; + let git: SimpleGit; + let options: Options; + + beforeEach(async () => { + repo = await setupTestRepo('nx-monorepo'); + git = repo.git; + options = { + monorepo: true, + directory: repo.baseDir, + }; + }); + + afterEach(async () => { + await repo.cleanup(); + }); + + describe('push event', () => { + beforeEach(async () => { + await git.checkout('main'); + }); + + it('should collect reports for all projects', async () => { + await expect( + runInCI( + { head: { ref: 'main', sha: await git.revparse('main') } }, + MOCK_API, + options, + git, + ), + ).resolves.toEqual({ + mode: 'monorepo', + projects: expect.arrayContaining([ + { + name: 'api', + files: { + report: { + json: join(repo.baseDir, 'apps/api/.code-pushup/report.json'), + md: join(repo.baseDir, 'apps/api/.code-pushup/report.md'), + }, + }, + }, + ]), + } satisfies RunResult); + + await expect( + readJsonFile(join(repo.baseDir, 'apps/api/.code-pushup/report.json')), + ).resolves.toEqual( + expect.objectContaining({ + plugins: [ + expect.objectContaining({ + audits: [ + expect.objectContaining({ + score: 0, + displayValue: '0% converted', + }), + ], + }), + ], + }), + ); + await expect( + readJsonFile(join(repo.baseDir, 'libs/ui/.code-pushup/report.json')), + ).resolves.toEqual( + expect.objectContaining({ + plugins: [ + expect.objectContaining({ + audits: [ + expect.objectContaining({ + score: expect.closeTo(0.666), + displayValue: '67% converted', + }), + ], + }), + ], + }), + ); + }); + }); + + // TODO: pull request event +}); From c493a123f84b3020764733ee168c82159dc0c04c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 6 Dec 2024 10:10:32 +0100 Subject: [PATCH 15/38] fix(ci): ensure valid output directory for reports and merged diff --- packages/ci/src/lib/cli/commands/merge-diffs.ts | 2 +- packages/ci/src/lib/run.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ci/src/lib/cli/commands/merge-diffs.ts b/packages/ci/src/lib/cli/commands/merge-diffs.ts index b6a4284f2..90c2025b8 100644 --- a/packages/ci/src/lib/cli/commands/merge-diffs.ts +++ b/packages/ci/src/lib/cli/commands/merge-diffs.ts @@ -10,7 +10,7 @@ export async function runMergeDiffs( files: string[], { bin, config, directory, silent }: CommandContext, ): Promise { - const outputDir = join(process.cwd(), DEFAULT_PERSIST_OUTPUT_DIR); + const outputDir = join(directory, DEFAULT_PERSIST_OUTPUT_DIR); const filename = `merged-${DEFAULT_PERSIST_FILENAME}`; const { stdout } = await executeProcess({ diff --git a/packages/ci/src/lib/run.ts b/packages/ci/src/lib/run.ts index 766e62fc7..9e09b3faf 100644 --- a/packages/ci/src/lib/run.ts +++ b/packages/ci/src/lib/run.ts @@ -156,6 +156,7 @@ async function runOnProject(args: RunOnProjectArgs): Promise { const reportsDir = path.join(settings.directory, '.code-pushup'); const currPath = path.join(reportsDir, 'curr-report.json'); const prevPath = path.join(reportsDir, 'prev-report.json'); + await fs.mkdir(reportsDir, { recursive: true }); await fs.writeFile(currPath, currReport); await fs.writeFile(prevPath, prevReport); logger.debug(`Saved reports to ${currPath} and ${prevPath}`); From accf9a92b465ab159776092beddd644c140cdb6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 6 Dec 2024 10:25:40 +0100 Subject: [PATCH 16/38] test(ci-e2e): add e2e test for pull request flow in monorepo mode --- .../{report-diff.md => basic-report-diff.md} | 0 .../__snapshots__/nx-monorepo-report-diff.md | 64 +++++++++++++ e2e/ci-e2e/tests/basic.e2e.test.ts | 2 +- e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts | 96 ++++++++++++++++++- 4 files changed, 157 insertions(+), 5 deletions(-) rename e2e/ci-e2e/tests/__snapshots__/{report-diff.md => basic-report-diff.md} (100%) create mode 100644 e2e/ci-e2e/tests/__snapshots__/nx-monorepo-report-diff.md diff --git a/e2e/ci-e2e/tests/__snapshots__/report-diff.md b/e2e/ci-e2e/tests/__snapshots__/basic-report-diff.md similarity index 100% rename from e2e/ci-e2e/tests/__snapshots__/report-diff.md rename to e2e/ci-e2e/tests/__snapshots__/basic-report-diff.md diff --git a/e2e/ci-e2e/tests/__snapshots__/nx-monorepo-report-diff.md b/e2e/ci-e2e/tests/__snapshots__/nx-monorepo-report-diff.md new file mode 100644 index 000000000..45ca3a465 --- /dev/null +++ b/e2e/ci-e2e/tests/__snapshots__/nx-monorepo-report-diff.md @@ -0,0 +1,64 @@ +# Code PushUp + +🤨 Code PushUp report has both **improvements and regressions** – compared target commit `` with source commit ``. + +## 💼 Project `api` + +🥳 Code PushUp report has **improved**. + +| 🏷️ Category | ⭐ Previous score | ⭐ Current score | 🔄 Score change | +| :------------------- | :--------------: | :-------------: | :------------------------------------------------------------: | +| TypeScript migration | 🔴 0 | 🟡 **50** | ![↑ +50](https://img.shields.io/badge/%E2%86%91%20%2B50-green) | + +
+👍 1 audit improved + +### 🛡️ Audits + +| 🔌 Plugin | 🛡️ Audit | 📏 Previous value | 📏 Current value | 🔄 Value change | +| :------------------- | :--------------------------------------------------- | :---------------: | :------------------: | :--------------------------------------------------------------------------------: | +| TypeScript migration | Source files converted from JavaScript to TypeScript | 🟥 0% converted | 🟨 **50% converted** | ![↑ +∞ %](https://img.shields.io/badge/%E2%86%91%20%2B%E2%88%9E%E2%80%89%25-green) | + +
+ +## 💼 Project `web` + +😟 Code PushUp report has **regressed**. + +| 🏷️ Category | ⭐ Previous score | ⭐ Current score | 🔄 Score change | +| :------------------- | :--------------: | :-------------: | :----------------------------------------------------------------: | +| TypeScript migration | 🟡 50 | 🔴 **0** | ![↓ −50](https://img.shields.io/badge/%E2%86%93%20%E2%88%9250-red) | + +
+👎 1 audit regressed + +### 🛡️ Audits + +| 🔌 Plugin | 🛡️ Audit | 📏 Previous value | 📏 Current value | 🔄 Value change | +| :------------------- | :--------------------------------------------------- | :---------------: | :-----------------: | :--------------------------------------------------------------------------------: | +| TypeScript migration | Source files converted from JavaScript to TypeScript | 🟨 50% converted | 🟥 **0% converted** | ![↓ −100 %](https://img.shields.io/badge/%E2%86%93%20%E2%88%92100%E2%80%89%25-red) | + +
+ +## 💼 Project `ui` + +🥳 Code PushUp report has **improved**. + +| 🏷️ Category | ⭐ Previous score | ⭐ Current score | 🔄 Score change | +| :------------------- | :--------------: | :-------------: | :----------------------------------------------------------------: | +| TypeScript migration | 🟡 67 | 🟢 **100** | ![↑ +33.3](https://img.shields.io/badge/%E2%86%91%20%2B33.3-green) | + +
+👍 1 audit improved + +### 🛡️ Audits + +| 🔌 Plugin | 🛡️ Audit | 📏 Previous value | 📏 Current value | 🔄 Value change | +| :------------------- | :--------------------------------------------------- | :---------------: | :-------------------: | :------------------------------------------------------------------------------: | +| TypeScript migration | Source files converted from JavaScript to TypeScript | 🟨 67% converted | 🟩 **100% converted** | ![↑ +49.3 %](https://img.shields.io/badge/%E2%86%91%20%2B49.3%E2%80%89%25-green) | + +
+ +--- + +2 other projects are unchanged. diff --git a/e2e/ci-e2e/tests/basic.e2e.test.ts b/e2e/ci-e2e/tests/basic.e2e.test.ts index 22db1c6a7..fe921eaea 100644 --- a/e2e/ci-e2e/tests/basic.e2e.test.ts +++ b/e2e/ci-e2e/tests/basic.e2e.test.ts @@ -119,7 +119,7 @@ describe('CI - standalone mode', () => { const md = await mdPromise; await expect( md.replace(/[\da-f]{40}/g, '``'), - ).toMatchFileSnapshot(join(TEST_SNAPSHOTS_DIR, 'report-diff.md')); + ).toMatchFileSnapshot(join(TEST_SNAPSHOTS_DIR, 'basic-report-diff.md')); }); }); }); diff --git a/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts b/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts index 7dcf93fc8..6666fa768 100644 --- a/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts +++ b/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts @@ -1,9 +1,17 @@ +import { readFile, rename, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { SimpleGit } from 'simple-git'; import { afterEach } from 'vitest'; -import { type Options, type RunResult, runInCI } from '@code-pushup/ci'; +import { + type GitRefs, + type Options, + type ProjectRunResult, + type RunResult, + runInCI, +} from '@code-pushup/ci'; +import { TEST_SNAPSHOTS_DIR } from '@code-pushup/test-utils'; import { readJsonFile } from '@code-pushup/utils'; -import { MOCK_API } from '../mocks/api'; +import { MOCK_API, MOCK_COMMENT } from '../mocks/api'; import { type TestRepo, setupTestRepo } from '../mocks/setup'; describe('CI - monorepo mode (Nx)', () => { @@ -39,7 +47,7 @@ describe('CI - monorepo mode (Nx)', () => { ), ).resolves.toEqual({ mode: 'monorepo', - projects: expect.arrayContaining([ + projects: expect.arrayContaining([ { name: 'api', files: { @@ -87,5 +95,85 @@ describe('CI - monorepo mode (Nx)', () => { }); }); - // TODO: pull request event + describe('pull request event', () => { + let refs: GitRefs; + + beforeEach(async () => { + await git.checkoutLocalBranch('feature-1'); + + await rename( + join(repo.baseDir, 'apps/api/src/index.js'), + join(repo.baseDir, 'apps/api/src/index.ts'), + ); + await rename( + join(repo.baseDir, 'apps/web/src/index.ts'), + join(repo.baseDir, 'apps/web/src/index.js'), + ); + await rename( + join(repo.baseDir, 'libs/ui/code-pushup.config.js'), + join(repo.baseDir, 'libs/ui/code-pushup.config.ts'), + ); + await writeFile( + join(repo.baseDir, 'libs/ui/project.json'), + ( + await readFile(join(repo.baseDir, 'libs/ui/project.json'), 'utf8') + ).replace('code-pushup.config.js', 'code-pushup.config.ts'), + ); + + await git.add('.'); + await git.commit('Convert JS->TS for api and ui, TS->JS for web'); + + refs = { + head: { ref: 'feature-1', sha: await git.revparse('feature-1') }, + base: { ref: 'main', sha: await git.revparse('main') }, + }; + }); + + it('should compare reports for all projects, detect new issues and merge into Markdown comment', async () => { + await expect(runInCI(refs, MOCK_API, options, git)).resolves.toEqual({ + mode: 'monorepo', + commentId: MOCK_COMMENT.id, + diffPath: join(repo.baseDir, '.code-pushup/merged-report-diff.md'), + projects: expect.arrayContaining([ + { + name: 'web', + files: { + report: { + json: join(repo.baseDir, 'apps/web/.code-pushup/report.json'), + md: join(repo.baseDir, 'apps/web/.code-pushup/report.md'), + }, + diff: { + json: join( + repo.baseDir, + 'apps/web/.code-pushup/report-diff.json', + ), + md: join(repo.baseDir, 'apps/web/.code-pushup/report-diff.md'), + }, + }, + newIssues: [ + { + message: 'Use .ts file extension instead of .js', + severity: 'warning', + source: { file: 'apps/web/src/index.js' }, + plugin: expect.objectContaining({ slug: 'ts-migration' }), + audit: expect.objectContaining({ slug: 'ts-files' }), + }, + ], + }, + ]), + } satisfies RunResult); + + const mdPromise = readFile( + join(repo.baseDir, '.code-pushup/merged-report-diff.md'), + 'utf8', + ); + await expect(mdPromise).resolves.toBeTruthy(); + const md = await mdPromise; + await expect( + md.replace(/[\da-f]{40}/g, '``'), + ).toMatchFileSnapshot( + join(TEST_SNAPSHOTS_DIR, 'nx-monorepo-report-diff.md'), + ); + }); + }); }); From d589f97a15269aefaa0593892261373e82cd30ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 6 Dec 2024 11:43:06 +0100 Subject: [PATCH 17/38] feat(ci): copy merged-report-diff.md from project to root --- packages/ci/src/lib/run.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/ci/src/lib/run.ts b/packages/ci/src/lib/run.ts index 9e09b3faf..97979b189 100644 --- a/packages/ci/src/lib/run.ts +++ b/packages/ci/src/lib/run.ts @@ -1,7 +1,12 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { type SimpleGit, simpleGit } from 'simple-git'; -import type { CoreConfig, Report, ReportsDiff } from '@code-pushup/models'; +import { + type CoreConfig, + DEFAULT_PERSIST_OUTPUT_DIR, + type Report, + type ReportsDiff, +} from '@code-pushup/models'; import { stringifyError } from '@code-pushup/utils'; import { type CommandContext, @@ -62,12 +67,19 @@ export async function runInCI( .map(({ files }) => files.diff?.json) .filter((file): file is string => file != null); if (diffJsonPaths.length > 0) { - const diffPath = await runMergeDiffs( + const tmpDiffPath = await runMergeDiffs( diffJsonPaths, createCommandContext(settings, projects[0]), ); - logger.debug(`Merged ${diffJsonPaths.length} diffs into ${diffPath}`); - const commentId = await commentOnPR(diffPath, api, logger); + logger.debug(`Merged ${diffJsonPaths.length} diffs into ${tmpDiffPath}`); + const diffPath = path.join( + settings.directory, + DEFAULT_PERSIST_OUTPUT_DIR, + path.basename(tmpDiffPath), + ); + await fs.cp(tmpDiffPath, diffPath); + logger.debug(`Copied ${tmpDiffPath} to ${diffPath}`); + const commentId = await commentOnPR(tmpDiffPath, api, logger); return { mode: 'monorepo', projects: projectResults, From a8a6fc845f417f4d9c3677ef6991f2eafe7b72dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 6 Dec 2024 12:11:13 +0100 Subject: [PATCH 18/38] fix(ci): resolve outputDir correctly by running workspace commands in project dir --- packages/ci/src/lib/monorepo/handlers/npm.ts | 7 ++- .../lib/monorepo/handlers/npm.unit.test.ts | 34 +++++++--- packages/ci/src/lib/monorepo/handlers/pnpm.ts | 7 ++- .../lib/monorepo/handlers/pnpm.unit.test.ts | 40 +++++++++--- .../ci/src/lib/monorepo/handlers/turbo.ts | 5 +- .../lib/monorepo/handlers/turbo.unit.test.ts | 25 ++++++-- packages/ci/src/lib/monorepo/handlers/yarn.ts | 7 ++- .../lib/monorepo/handlers/yarn.unit.test.ts | 40 +++++++++--- .../lib/monorepo/list-projects.unit.test.ts | 63 ++++++++++++++----- 9 files changed, 168 insertions(+), 60 deletions(-) diff --git a/packages/ci/src/lib/monorepo/handlers/npm.ts b/packages/ci/src/lib/monorepo/handlers/npm.ts index c232d8382..d6d0161e7 100644 --- a/packages/ci/src/lib/monorepo/handlers/npm.ts +++ b/packages/ci/src/lib/monorepo/handlers/npm.ts @@ -27,11 +27,12 @@ export const npmHandler: MonorepoToolHandler = { hasCodePushUpDependency(packageJson) || hasCodePushUpDependency(rootPackageJson), ) - .map(({ name, packageJson }) => ({ + .map(({ name, directory, packageJson }) => ({ name, + directory, bin: hasScript(packageJson, options.task) - ? `npm --workspace=${name} run ${options.task} --` - : `npm --workspace=${name} exec ${options.task} --`, + ? `npm run ${options.task} --` + : `npm exec ${options.task} --`, })); }, diff --git a/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts index 642e2dca4..63325f92d 100644 --- a/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts @@ -1,4 +1,5 @@ import { vol } from 'memfs'; +import { join } from 'node:path'; import type { PackageJson } from 'type-fest'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import type { @@ -88,11 +89,13 @@ describe('npmHandler', () => { await expect(npmHandler.listProjects(options)).resolves.toEqual([ { name: 'backend', - bin: 'npm --workspace=backend run code-pushup --', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'npm run code-pushup --', }, { name: 'shared', - bin: 'npm --workspace=shared run code-pushup --', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'npm run code-pushup --', }, ] satisfies ProjectConfig[]); }); @@ -124,11 +127,13 @@ describe('npmHandler', () => { await expect(npmHandler.listProjects(options)).resolves.toEqual([ { name: 'backend', - bin: 'npm --workspace=backend exec code-pushup --', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'npm exec code-pushup --', }, { name: 'shared', - bin: 'npm --workspace=shared exec code-pushup --', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'npm exec code-pushup --', }, ] satisfies ProjectConfig[]); }); @@ -158,15 +163,18 @@ describe('npmHandler', () => { await expect(npmHandler.listProjects(options)).resolves.toEqual([ { name: 'backend', - bin: 'npm --workspace=backend exec code-pushup --', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'npm exec code-pushup --', }, { name: 'frontend', - bin: 'npm --workspace=frontend exec code-pushup --', + directory: join(MEMFS_VOLUME, 'apps', 'frontend'), + bin: 'npm exec code-pushup --', }, { name: 'shared', - bin: 'npm --workspace=shared exec code-pushup --', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'npm exec code-pushup --', }, ] satisfies ProjectConfig[]); }); @@ -175,8 +183,16 @@ describe('npmHandler', () => { describe('createRunManyCommand', () => { const projects: MonorepoHandlerProjectsContext = { all: [ - { name: 'api', bin: 'npm --workspace=api run code-pushup --' }, - { name: 'ui', bin: 'npm --workspace=ui run code-pushup --' }, + { + name: 'api', + directory: join(MEMFS_VOLUME, 'api'), + bin: 'npm run code-pushup --', + }, + { + name: 'ui', + directory: join(MEMFS_VOLUME, 'ui'), + bin: 'npm run code-pushup --', + }, ], }; diff --git a/packages/ci/src/lib/monorepo/handlers/pnpm.ts b/packages/ci/src/lib/monorepo/handlers/pnpm.ts index b43cfd73e..b7dbe69fe 100644 --- a/packages/ci/src/lib/monorepo/handlers/pnpm.ts +++ b/packages/ci/src/lib/monorepo/handlers/pnpm.ts @@ -36,11 +36,12 @@ export const pnpmHandler: MonorepoToolHandler = { hasCodePushUpDependency(packageJson) || hasCodePushUpDependency(rootPackageJson), ) - .map(({ name, packageJson }) => ({ + .map(({ name, directory, packageJson }) => ({ name, + directory, bin: hasScript(packageJson, options.task) - ? `pnpm --filter=${name} run ${options.task}` - : `pnpm --filter=${name} exec ${options.task}`, + ? `pnpm run ${options.task}` + : `pnpm exec ${options.task}`, })); }, diff --git a/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts index 49bf978b9..b2d25de14 100644 --- a/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts @@ -1,4 +1,5 @@ import { vol } from 'memfs'; +import { join } from 'node:path'; import type { PackageJson } from 'type-fest'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import type { @@ -82,11 +83,13 @@ describe('pnpmHandler', () => { await expect(pnpmHandler.listProjects(options)).resolves.toEqual([ { name: 'backend', - bin: 'pnpm --filter=backend run code-pushup', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'pnpm run code-pushup', }, { name: 'shared', - bin: 'pnpm --filter=shared run code-pushup', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'pnpm run code-pushup', }, ] satisfies ProjectConfig[]); }); @@ -115,11 +118,13 @@ describe('pnpmHandler', () => { await expect(pnpmHandler.listProjects(options)).resolves.toEqual([ { name: 'backend', - bin: 'pnpm --filter=backend exec code-pushup', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'pnpm exec code-pushup', }, { name: 'shared', - bin: 'pnpm --filter=shared exec code-pushup', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'pnpm exec code-pushup', }, ] satisfies ProjectConfig[]); }); @@ -149,15 +154,18 @@ describe('pnpmHandler', () => { await expect(pnpmHandler.listProjects(options)).resolves.toEqual([ { name: 'backend', - bin: 'pnpm --filter=backend exec code-pushup', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'pnpm exec code-pushup', }, { name: 'frontend', - bin: 'pnpm --filter=frontend exec code-pushup', + directory: join(MEMFS_VOLUME, 'apps', 'frontend'), + bin: 'pnpm exec code-pushup', }, { name: 'shared', - bin: 'pnpm --filter=shared exec code-pushup', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'pnpm exec code-pushup', }, ] satisfies ProjectConfig[]); }); @@ -166,9 +174,21 @@ describe('pnpmHandler', () => { describe('createRunManyCommand', () => { const projects: MonorepoHandlerProjectsContext = { all: [ - { name: 'backend', bin: 'pnpm --filter=backend run code-pushup' }, - { name: 'frontend', bin: 'pnpm --filter=frontend run code-pushup' }, - { name: 'shared', bin: 'pnpm --filter=shared run code-pushup' }, + { + name: 'backend', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'pnpm run code-pushup', + }, + { + name: 'frontend', + directory: join(MEMFS_VOLUME, 'apps', 'frontend'), + bin: 'pnpm run code-pushup', + }, + { + name: 'shared', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'pnpm run code-pushup', + }, ], }; diff --git a/packages/ci/src/lib/monorepo/handlers/turbo.ts b/packages/ci/src/lib/monorepo/handlers/turbo.ts index be986657b..9cbcc39a8 100644 --- a/packages/ci/src/lib/monorepo/handlers/turbo.ts +++ b/packages/ci/src/lib/monorepo/handlers/turbo.ts @@ -32,9 +32,10 @@ export const turboHandler: MonorepoToolHandler = { const projects = await handler.listProjects(options); return projects .filter(({ bin }) => bin.includes(`run ${options.task}`)) // must have package.json script - .map(({ name }) => ({ + .map(({ name, directory }) => ({ name, - bin: `npx turbo run ${options.task} --filter=${name} --`, + directory, + bin: `npx turbo run ${options.task} --`, })); } } diff --git a/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts index 4f6f2e6ad..6d6e13ccc 100644 --- a/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts @@ -1,4 +1,5 @@ import { vol } from 'memfs'; +import { join } from 'node:path'; import type { PackageJson } from 'type-fest'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import type { @@ -135,11 +136,13 @@ describe('turboHandler', () => { await expect(turboHandler.listProjects(options)).resolves.toEqual([ { name: '@example/cli', - bin: 'npx turbo run code-pushup --filter=@example/cli --', + directory: join(MEMFS_VOLUME, 'packages', 'cli'), + bin: 'npx turbo run code-pushup --', }, { name: '@example/core', - bin: 'npx turbo run code-pushup --filter=@example/core --', + directory: join(MEMFS_VOLUME, 'packages', 'core'), + bin: 'npx turbo run code-pushup --', }, ] satisfies ProjectConfig[]); }, @@ -164,9 +167,21 @@ describe('turboHandler', () => { describe('createRunManyCommand', () => { const projects: MonorepoHandlerProjectsContext = { all: [ - { name: 'api', bin: 'npx turbo run code-pushup --filter=api --' }, - { name: 'cms', bin: 'npx turbo run code-pushup --filter=cms --' }, - { name: 'web', bin: 'npx turbo run code-pushup --filter=web --' }, + { + name: 'api', + directory: join(MEMFS_VOLUME, 'api'), + bin: 'npx turbo run code-pushup --', + }, + { + name: 'cms', + directory: join(MEMFS_VOLUME, 'cms'), + bin: 'npx turbo run code-pushup --', + }, + { + name: 'web', + directory: join(MEMFS_VOLUME, 'web'), + bin: 'npx turbo run code-pushup --', + }, ], }; diff --git a/packages/ci/src/lib/monorepo/handlers/yarn.ts b/packages/ci/src/lib/monorepo/handlers/yarn.ts index 47705c24f..8351afebb 100644 --- a/packages/ci/src/lib/monorepo/handlers/yarn.ts +++ b/packages/ci/src/lib/monorepo/handlers/yarn.ts @@ -27,11 +27,12 @@ export const yarnHandler: MonorepoToolHandler = { hasCodePushUpDependency(packageJson) || hasCodePushUpDependency(rootPackageJson), ) - .map(({ name, packageJson }) => ({ + .map(({ name, directory, packageJson }) => ({ name, + directory, bin: hasScript(packageJson, options.task) - ? `yarn workspace ${name} run ${options.task}` - : `yarn workspace ${name} exec ${options.task}`, + ? `yarn run ${options.task}` + : `yarn exec ${options.task}`, })); }, diff --git a/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts index 8e78fd5a4..5551e0e21 100644 --- a/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts @@ -1,4 +1,5 @@ import { vol } from 'memfs'; +import { join } from 'node:path'; import type { PackageJson } from 'type-fest'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import * as utils from '@code-pushup/utils'; @@ -90,11 +91,13 @@ describe('yarnHandler', () => { await expect(yarnHandler.listProjects(options)).resolves.toEqual([ { name: 'backend', - bin: 'yarn workspace backend run code-pushup', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'yarn run code-pushup', }, { name: 'shared', - bin: 'yarn workspace shared run code-pushup', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'yarn run code-pushup', }, ] satisfies ProjectConfig[]); }); @@ -126,11 +129,13 @@ describe('yarnHandler', () => { await expect(yarnHandler.listProjects(options)).resolves.toEqual([ { name: 'backend', - bin: 'yarn workspace backend exec code-pushup', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'yarn exec code-pushup', }, { name: 'shared', - bin: 'yarn workspace shared exec code-pushup', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'yarn exec code-pushup', }, ] satisfies ProjectConfig[]); }); @@ -160,15 +165,18 @@ describe('yarnHandler', () => { await expect(yarnHandler.listProjects(options)).resolves.toEqual([ { name: 'backend', - bin: 'yarn workspace backend exec code-pushup', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'yarn exec code-pushup', }, { name: 'frontend', - bin: 'yarn workspace frontend exec code-pushup', + directory: join(MEMFS_VOLUME, 'apps', 'frontend'), + bin: 'yarn exec code-pushup', }, { name: 'shared', - bin: 'yarn workspace shared exec code-pushup', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'yarn exec code-pushup', }, ] satisfies ProjectConfig[]); }); @@ -177,9 +185,21 @@ describe('yarnHandler', () => { describe('createRunManyCommand', () => { const projects: MonorepoHandlerProjectsContext = { all: [ - { name: 'api', bin: 'yarn workspace api run code-pushup' }, - { name: 'cms', bin: 'yarn workspace cms run code-pushup' }, - { name: 'web', bin: 'yarn workspace web run code-pushup' }, + { + name: 'api', + directory: join(MEMFS_VOLUME, 'api'), + bin: 'yarn run code-pushup', + }, + { + name: 'cms', + directory: join(MEMFS_VOLUME, 'cms'), + bin: 'yarn run code-pushup', + }, + { + name: 'web', + directory: join(MEMFS_VOLUME, 'web'), + bin: 'yarn run code-pushup', + }, ], }; diff --git a/packages/ci/src/lib/monorepo/list-projects.unit.test.ts b/packages/ci/src/lib/monorepo/list-projects.unit.test.ts index d75a1b0d5..e68aec614 100644 --- a/packages/ci/src/lib/monorepo/list-projects.unit.test.ts +++ b/packages/ci/src/lib/monorepo/list-projects.unit.test.ts @@ -117,10 +117,26 @@ describe('listMonorepoProjects', () => { await expect(listMonorepoProjects(MONOREPO_SETTINGS)).resolves.toEqual({ tool: 'turbo', projects: [ - { name: 'api', bin: 'npx turbo run code-pushup --filter=api --' }, - { name: 'auth', bin: 'npx turbo run code-pushup --filter=auth --' }, - { name: 'cms', bin: 'npx turbo run code-pushup --filter=cms --' }, - { name: 'web', bin: 'npx turbo run code-pushup --filter=web --' }, + { + name: 'api', + directory: join(MEMFS_VOLUME, 'backend', 'api'), + bin: 'npx turbo run code-pushup --', + }, + { + name: 'auth', + directory: join(MEMFS_VOLUME, 'backend', 'auth'), + bin: 'npx turbo run code-pushup --', + }, + { + name: 'cms', + directory: join(MEMFS_VOLUME, 'frontend', 'cms'), + bin: 'npx turbo run code-pushup --', + }, + { + name: 'web', + directory: join(MEMFS_VOLUME, 'frontend', 'web'), + bin: 'npx turbo run code-pushup --', + }, ], runManyCommand: expect.any(Function), } satisfies MonorepoProjects); @@ -155,15 +171,18 @@ describe('listMonorepoProjects', () => { projects: [ { name: 'backend', - bin: 'pnpm --filter=backend run code-pushup', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'pnpm run code-pushup', }, { name: 'frontend', - bin: 'pnpm --filter=frontend run code-pushup', + directory: join(MEMFS_VOLUME, 'apps', 'frontend'), + bin: 'pnpm run code-pushup', }, { name: '@repo/utils', - bin: 'pnpm --filter=@repo/utils run code-pushup', + directory: join(MEMFS_VOLUME, 'libs', 'utils'), + bin: 'pnpm run code-pushup', }, ], runManyCommand: expect.any(Function), @@ -196,8 +215,16 @@ describe('listMonorepoProjects', () => { await expect(listMonorepoProjects(MONOREPO_SETTINGS)).resolves.toEqual({ tool: 'yarn', projects: [ - { name: 'cli', bin: 'yarn workspace cli exec code-pushup' }, - { name: 'core', bin: 'yarn workspace core exec code-pushup' }, + { + name: 'cli', + directory: join(MEMFS_VOLUME, 'packages', 'cli'), + bin: 'yarn exec code-pushup', + }, + { + name: 'core', + directory: join(MEMFS_VOLUME, 'packages', 'core'), + bin: 'yarn exec code-pushup', + }, ], runManyCommand: expect.any(Function), } satisfies MonorepoProjects); @@ -227,11 +254,13 @@ describe('listMonorepoProjects', () => { projects: [ { name: 'backend', - bin: 'npm --workspace=backend exec code-pushup --', + directory: join(MEMFS_VOLUME, 'packages', 'backend'), + bin: 'npm exec code-pushup --', }, { name: 'frontend', - bin: 'npm --workspace=frontend exec code-pushup --', + directory: join(MEMFS_VOLUME, 'packages', 'frontend'), + bin: 'npm exec code-pushup --', }, ], runManyCommand: expect.any(Function), @@ -367,19 +396,23 @@ describe('listMonorepoProjects', () => { projects: [ { name: 'backoffice', - bin: 'pnpm --filter=backoffice exec code-pushup', + directory: join(MEMFS_VOLUME, 'apps', 'backoffice'), + bin: 'pnpm exec code-pushup', }, { name: 'frontoffice', - bin: 'pnpm --filter=frontoffice exec code-pushup', + directory: join(MEMFS_VOLUME, 'apps', 'frontoffice'), + bin: 'pnpm exec code-pushup', }, { name: '@repo/models', - bin: 'pnpm --filter=@repo/models exec code-pushup', + directory: join(MEMFS_VOLUME, 'packages', 'models'), + bin: 'pnpm exec code-pushup', }, { name: '@repo/ui', - bin: 'pnpm --filter=@repo/ui exec code-pushup', + directory: join(MEMFS_VOLUME, 'packages', 'ui'), + bin: 'pnpm exec code-pushup', }, ], runManyCommand: expect.any(Function), From 2c8d1987e4ea816153671eb0671af1510243097a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 6 Dec 2024 12:16:16 +0100 Subject: [PATCH 19/38] fix(ci): only copy merged-report-diff.md when paths are different --- packages/ci/src/lib/run.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ci/src/lib/run.ts b/packages/ci/src/lib/run.ts index 97979b189..8ef4f49e8 100644 --- a/packages/ci/src/lib/run.ts +++ b/packages/ci/src/lib/run.ts @@ -77,8 +77,10 @@ export async function runInCI( DEFAULT_PERSIST_OUTPUT_DIR, path.basename(tmpDiffPath), ); - await fs.cp(tmpDiffPath, diffPath); - logger.debug(`Copied ${tmpDiffPath} to ${diffPath}`); + if (tmpDiffPath !== diffPath) { + await fs.cp(tmpDiffPath, diffPath); + logger.debug(`Copied ${tmpDiffPath} to ${diffPath}`); + } const commentId = await commentOnPR(tmpDiffPath, api, logger); return { mode: 'monorepo', From e286887e68a1c3143ba8d46eb318a56ba93ccdcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 6 Dec 2024 12:16:39 +0100 Subject: [PATCH 20/38] test(ci-e2e): add e2e test for npm workspaces --- .../npm-workspaces/code-pushup/categories.js | 9 + .../code-pushup/ts-migration.plugin.js | 44 +++++ .../fixtures/npm-workspaces/package-lock.json | 42 +++++ .../fixtures/npm-workspaces/package.json | 6 + .../packages/cli/code-pushup.config.js | 7 + .../npm-workspaces/packages/cli/package.json | 10 ++ .../npm-workspaces/packages/cli/src/bin.js | 1 + .../packages/core/code-pushup.config.js | 7 + .../npm-workspaces/packages/core/package.json | 10 ++ .../npm-workspaces/packages/core/src/index.js | 1 + .../packages/utils/code-pushup.config.js | 7 + .../packages/utils/package.json | 7 + .../packages/utils/src/index.js | 1 + .../npm-workspaces-report-diff.md | 45 +++++ e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts | 160 ++++++++++++++++++ 15 files changed, 357 insertions(+) create mode 100644 e2e/ci-e2e/mocks/fixtures/npm-workspaces/code-pushup/categories.js create mode 100644 e2e/ci-e2e/mocks/fixtures/npm-workspaces/code-pushup/ts-migration.plugin.js create mode 100644 e2e/ci-e2e/mocks/fixtures/npm-workspaces/package-lock.json create mode 100644 e2e/ci-e2e/mocks/fixtures/npm-workspaces/package.json create mode 100644 e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/cli/code-pushup.config.js create mode 100644 e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/cli/package.json create mode 100644 e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/cli/src/bin.js create mode 100644 e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/core/code-pushup.config.js create mode 100644 e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/core/package.json create mode 100644 e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/core/src/index.js create mode 100644 e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/utils/code-pushup.config.js create mode 100644 e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/utils/package.json create mode 100644 e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/utils/src/index.js create mode 100644 e2e/ci-e2e/tests/__snapshots__/npm-workspaces-report-diff.md create mode 100644 e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts diff --git a/e2e/ci-e2e/mocks/fixtures/npm-workspaces/code-pushup/categories.js b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/code-pushup/categories.js new file mode 100644 index 000000000..ff9c9a258 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/code-pushup/categories.js @@ -0,0 +1,9 @@ +export const DEFAULT_CATEGORIES = [ + { + slug: 'ts-migration', + title: 'TypeScript migration', + refs: [ + { type: 'audit', plugin: 'ts-migration', slug: 'ts-files', weight: 1 }, + ], + }, +]; diff --git a/e2e/ci-e2e/mocks/fixtures/npm-workspaces/code-pushup/ts-migration.plugin.js b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/code-pushup/ts-migration.plugin.js new file mode 100644 index 000000000..7d802f09c --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/code-pushup/ts-migration.plugin.js @@ -0,0 +1,44 @@ +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { crawlFileSystem } from '@code-pushup/utils'; + +export default function tsMigrationPlugin(url) { + return { + slug: 'ts-migration', + title: 'TypeScript migration', + icon: 'typescript', + audits: [ + { + slug: 'ts-files', + title: 'Source files converted from JavaScript to TypeScript', + }, + ], + runner: async () => { + const paths = await crawlFileSystem({ + directory: fileURLToPath(dirname(url)), + pattern: /\.[jt]s$/, + }); + const jsPaths = paths.filter(path => path.endsWith('.js')); + const tsPaths = paths.filter(path => path.endsWith('.ts')); + const jsFileCount = jsPaths.length; + const tsFileCount = tsPaths.length; + const ratio = tsFileCount / (jsFileCount + tsFileCount); + const percentage = Math.round(ratio * 100); + return [ + { + slug: 'ts-files', + value: percentage, + score: ratio, + displayValue: `${percentage}% converted`, + details: { + issues: jsPaths.map(file => ({ + message: 'Use .ts file extension instead of .js', + severity: 'warning', + source: { file }, + })), + }, + }, + ]; + }, + }; +} diff --git a/e2e/ci-e2e/mocks/fixtures/npm-workspaces/package-lock.json b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/package-lock.json new file mode 100644 index 000000000..a8e618099 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/package-lock.json @@ -0,0 +1,42 @@ +{ + "name": "npm-workspaces", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "workspaces": [ + "packages/*" + ] + }, + "node_modules/@example/cli": { + "resolved": "packages/cli", + "link": true + }, + "node_modules/@example/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@example/utils": { + "resolved": "packages/utils", + "link": true + }, + "packages/cli": { + "name": "@example/cli", + "version": "1.2.3", + "dependencies": { + "@example/core": "1.2.3" + } + }, + "packages/core": { + "name": "@example/core", + "version": "1.2.3", + "dependencies": { + "@example/utils": "1.2.3" + } + }, + "packages/utils": { + "name": "@example/utils", + "version": "1.2.3" + } + } +} diff --git a/e2e/ci-e2e/mocks/fixtures/npm-workspaces/package.json b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/package.json new file mode 100644 index 000000000..9f0acdba5 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "workspaces": [ + "packages/*" + ] +} diff --git a/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/cli/code-pushup.config.js b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/cli/code-pushup.config.js new file mode 100644 index 000000000..796df75d0 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/cli/code-pushup.config.js @@ -0,0 +1,7 @@ +import { DEFAULT_CATEGORIES } from '../../code-pushup/categories.js'; +import tsMigrationPlugin from '../../code-pushup/ts-migration.plugin.js'; + +export default { + plugins: [tsMigrationPlugin(import.meta.url)], + categories: DEFAULT_CATEGORIES, +}; diff --git a/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/cli/package.json b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/cli/package.json new file mode 100644 index 000000000..8be8e49a2 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/cli/package.json @@ -0,0 +1,10 @@ +{ + "name": "@example/cli", + "version": "1.2.3", + "scripts": { + "code-pushup": "code-pushup --no-progress" + }, + "dependencies": { + "@example/core": "1.2.3" + } +} diff --git a/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/cli/src/bin.js b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/cli/src/bin.js new file mode 100644 index 000000000..a76cfe4d7 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/cli/src/bin.js @@ -0,0 +1 @@ +console.log('Hello, world'); diff --git a/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/core/code-pushup.config.js b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/core/code-pushup.config.js new file mode 100644 index 000000000..796df75d0 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/core/code-pushup.config.js @@ -0,0 +1,7 @@ +import { DEFAULT_CATEGORIES } from '../../code-pushup/categories.js'; +import tsMigrationPlugin from '../../code-pushup/ts-migration.plugin.js'; + +export default { + plugins: [tsMigrationPlugin(import.meta.url)], + categories: DEFAULT_CATEGORIES, +}; diff --git a/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/core/package.json b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/core/package.json new file mode 100644 index 000000000..7d1338389 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/core/package.json @@ -0,0 +1,10 @@ +{ + "name": "@example/core", + "version": "1.2.3", + "scripts": { + "code-pushup": "code-pushup --no-progress" + }, + "dependencies": { + "@example/utils": "1.2.3" + } +} diff --git a/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/core/src/index.js b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/core/src/index.js new file mode 100644 index 000000000..a76cfe4d7 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/core/src/index.js @@ -0,0 +1 @@ +console.log('Hello, world'); diff --git a/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/utils/code-pushup.config.js b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/utils/code-pushup.config.js new file mode 100644 index 000000000..796df75d0 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/utils/code-pushup.config.js @@ -0,0 +1,7 @@ +import { DEFAULT_CATEGORIES } from '../../code-pushup/categories.js'; +import tsMigrationPlugin from '../../code-pushup/ts-migration.plugin.js'; + +export default { + plugins: [tsMigrationPlugin(import.meta.url)], + categories: DEFAULT_CATEGORIES, +}; diff --git a/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/utils/package.json b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/utils/package.json new file mode 100644 index 000000000..b739b5d9d --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/utils/package.json @@ -0,0 +1,7 @@ +{ + "name": "@example/utils", + "version": "1.2.3", + "scripts": { + "code-pushup": "code-pushup --no-progress" + } +} diff --git a/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/utils/src/index.js b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/utils/src/index.js new file mode 100644 index 000000000..a76cfe4d7 --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/npm-workspaces/packages/utils/src/index.js @@ -0,0 +1 @@ +console.log('Hello, world'); diff --git a/e2e/ci-e2e/tests/__snapshots__/npm-workspaces-report-diff.md b/e2e/ci-e2e/tests/__snapshots__/npm-workspaces-report-diff.md new file mode 100644 index 000000000..ca5179383 --- /dev/null +++ b/e2e/ci-e2e/tests/__snapshots__/npm-workspaces-report-diff.md @@ -0,0 +1,45 @@ +# Code PushUp + +🥳 Code PushUp report has **improved** – compared target commit `` with source commit ``. + +## 💼 Project `@example/core` + +🥳 Code PushUp report has **improved**. + +| 🏷️ Category | ⭐ Previous score | ⭐ Current score | 🔄 Score change | +| :------------------- | :--------------: | :-------------: | :--------------------------------------------------------------: | +| TypeScript migration | 🔴 0 | 🟢 **100** | ![↑ +100](https://img.shields.io/badge/%E2%86%91%20%2B100-green) | + +
+👍 1 audit improved + +### 🛡️ Audits + +| 🔌 Plugin | 🛡️ Audit | 📏 Previous value | 📏 Current value | 🔄 Value change | +| :------------------- | :--------------------------------------------------- | :---------------: | :-------------------: | :--------------------------------------------------------------------------------: | +| TypeScript migration | Source files converted from JavaScript to TypeScript | 🟥 0% converted | 🟩 **100% converted** | ![↑ +∞ %](https://img.shields.io/badge/%E2%86%91%20%2B%E2%88%9E%E2%80%89%25-green) | + +
+ +## 💼 Project `@example/cli` + +🥳 Code PushUp report has **improved**. + +| 🏷️ Category | ⭐ Previous score | ⭐ Current score | 🔄 Score change | +| :------------------- | :--------------: | :-------------: | :------------------------------------------------------------: | +| TypeScript migration | 🔴 0 | 🟡 **50** | ![↑ +50](https://img.shields.io/badge/%E2%86%91%20%2B50-green) | + +
+👍 1 audit improved + +### 🛡️ Audits + +| 🔌 Plugin | 🛡️ Audit | 📏 Previous value | 📏 Current value | 🔄 Value change | +| :------------------- | :--------------------------------------------------- | :---------------: | :------------------: | :--------------------------------------------------------------------------------: | +| TypeScript migration | Source files converted from JavaScript to TypeScript | 🟥 0% converted | 🟨 **50% converted** | ![↑ +∞ %](https://img.shields.io/badge/%E2%86%91%20%2B%E2%88%9E%E2%80%89%25-green) | + +
+ +--- + +1 other project is unchanged. diff --git a/e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts b/e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts new file mode 100644 index 000000000..111302438 --- /dev/null +++ b/e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts @@ -0,0 +1,160 @@ +import { readFile, rename } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { SimpleGit } from 'simple-git'; +import { afterEach } from 'vitest'; +import { + type GitRefs, + type Options, + type ProjectRunResult, + type RunResult, + runInCI, +} from '@code-pushup/ci'; +import { TEST_SNAPSHOTS_DIR } from '@code-pushup/test-utils'; +import { readJsonFile } from '@code-pushup/utils'; +import { MOCK_API, MOCK_COMMENT } from '../mocks/api'; +import { type TestRepo, setupTestRepo } from '../mocks/setup'; + +describe('CI - monorepo mode (npm workspaces)', () => { + let repo: TestRepo; + let git: SimpleGit; + let options: Options; + + beforeEach(async () => { + repo = await setupTestRepo('npm-workspaces'); + git = repo.git; + options = { + monorepo: true, + directory: repo.baseDir, + }; + }); + + afterEach(async () => { + await repo.cleanup(); + }); + + describe('push event', () => { + beforeEach(async () => { + await git.checkout('main'); + }); + + it('should collect reports for all projects', async () => { + await expect( + runInCI( + { head: { ref: 'main', sha: await git.revparse('main') } }, + MOCK_API, + options, + git, + ), + ).resolves.toEqual({ + mode: 'monorepo', + projects: expect.arrayContaining([ + { + name: '@example/cli', + files: { + report: { + json: join( + repo.baseDir, + 'packages/cli/.code-pushup/report.json', + ), + md: join(repo.baseDir, 'packages/cli/.code-pushup/report.md'), + }, + }, + }, + ]), + } satisfies RunResult); + + await expect( + readJsonFile( + join(repo.baseDir, 'packages/cli/.code-pushup/report.json'), + ), + ).resolves.toEqual( + expect.objectContaining({ + plugins: [ + expect.objectContaining({ + audits: [ + expect.objectContaining({ + score: 0, + displayValue: '0% converted', + }), + ], + }), + ], + }), + ); + }); + }); + + describe('pull request event', () => { + let refs: GitRefs; + + beforeEach(async () => { + await git.checkoutLocalBranch('feature-1'); + + await rename( + join(repo.baseDir, 'packages/cli/src/bin.js'), + join(repo.baseDir, 'packages/cli/src/bin.ts'), + ); + await rename( + join(repo.baseDir, 'packages/core/src/index.js'), + join(repo.baseDir, 'packages/core/src/index.ts'), + ); + await rename( + join(repo.baseDir, 'packages/core/code-pushup.config.js'), + join(repo.baseDir, 'packages/core/code-pushup.config.ts'), + ); + + await git.add('.'); + await git.commit('Convert JS files to TS'); + + refs = { + head: { ref: 'feature-1', sha: await git.revparse('feature-1') }, + base: { ref: 'main', sha: await git.revparse('main') }, + }; + }); + + it('should compare reports for all packages and merge into Markdown comment', async () => { + await expect(runInCI(refs, MOCK_API, options, git)).resolves.toEqual({ + mode: 'monorepo', + commentId: MOCK_COMMENT.id, + diffPath: join(repo.baseDir, '.code-pushup/merged-report-diff.md'), + projects: expect.arrayContaining([ + { + name: '@example/core', + files: { + report: { + json: join( + repo.baseDir, + 'packages/core/.code-pushup/report.json', + ), + md: join(repo.baseDir, 'packages/core/.code-pushup/report.md'), + }, + diff: { + json: join( + repo.baseDir, + 'packages/core/.code-pushup/report-diff.json', + ), + md: join( + repo.baseDir, + 'packages/core/.code-pushup/report-diff.md', + ), + }, + }, + newIssues: [], + }, + ]), + } satisfies RunResult); + + const mdPromise = readFile( + join(repo.baseDir, '.code-pushup/merged-report-diff.md'), + 'utf8', + ); + await expect(mdPromise).resolves.toBeTruthy(); + const md = await mdPromise; + await expect( + md.replace(/[\da-f]{40}/g, '``'), + ).toMatchFileSnapshot( + join(TEST_SNAPSHOTS_DIR, 'npm-workspaces-report-diff.md'), + ); + }); + }); +}); From ec7248936f4fd091c964bc8e7bb66e7b77655f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 6 Dec 2024 12:19:56 +0100 Subject: [PATCH 21/38] test(ci-e2e): silence CLI stdout in tests to reduce logs --- e2e/ci-e2e/tests/basic.e2e.test.ts | 5 ++++- e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts | 1 + e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/e2e/ci-e2e/tests/basic.e2e.test.ts b/e2e/ci-e2e/tests/basic.e2e.test.ts index fe921eaea..c4f0bb302 100644 --- a/e2e/ci-e2e/tests/basic.e2e.test.ts +++ b/e2e/ci-e2e/tests/basic.e2e.test.ts @@ -20,7 +20,10 @@ describe('CI - standalone mode', () => { beforeEach(async () => { repo = await setupTestRepo('basic'); git = repo.git; - options = { directory: repo.baseDir }; + options = { + directory: repo.baseDir, + silent: true, // comment out for debugging + }; }); afterEach(async () => { diff --git a/e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts b/e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts index 111302438..46410189f 100644 --- a/e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts +++ b/e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts @@ -25,6 +25,7 @@ describe('CI - monorepo mode (npm workspaces)', () => { options = { monorepo: true, directory: repo.baseDir, + silent: true, // comment out for debugging }; }); diff --git a/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts b/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts index 6666fa768..dbb7a33c3 100644 --- a/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts +++ b/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts @@ -25,6 +25,7 @@ describe('CI - monorepo mode (Nx)', () => { options = { monorepo: true, directory: repo.baseDir, + silent: true, // comment out for debugging }; }); From 29205f059d348b24cc466c62eb65dd0634310daa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 6 Dec 2024 17:27:22 +0100 Subject: [PATCH 22/38] test(ci): prepare integration test setup to reuse for monorepo mode --- e2e/ci-e2e/mocks/setup.ts | 34 +- packages/ci/.eslintrc.json | 4 +- .../ci/mocks/fixtures/code-pushup.config.ts | 44 -- .../mocks/fixtures/{ => outputs}/config.json | 0 .../diff-project.json} | 0 .../diff-project.md} | 0 .../report.json => outputs/report-after.json} | 0 .../report.md => outputs/report-after.md} | 0 .../report-before.json} | 0 .../report.md => outputs/report-before.md} | 0 .../lib/monorepo/handlers/yarn.unit.test.ts | 2 - packages/ci/src/lib/run.integration.test.ts | 474 +++++++++--------- testing/test-utils/src/lib/utils/git.ts | 34 +- 13 files changed, 272 insertions(+), 320 deletions(-) delete mode 100644 packages/ci/mocks/fixtures/code-pushup.config.ts rename packages/ci/mocks/fixtures/{ => outputs}/config.json (100%) rename packages/ci/mocks/fixtures/{report-diff.json => outputs/diff-project.json} (100%) rename packages/ci/mocks/fixtures/{report-diff.md => outputs/diff-project.md} (100%) rename packages/ci/mocks/fixtures/{feature-1/report.json => outputs/report-after.json} (100%) rename packages/ci/mocks/fixtures/{feature-1/report.md => outputs/report-after.md} (100%) rename packages/ci/mocks/fixtures/{main/report.json => outputs/report-before.json} (100%) rename packages/ci/mocks/fixtures/{main/report.md => outputs/report-before.md} (100%) diff --git a/e2e/ci-e2e/mocks/setup.ts b/e2e/ci-e2e/mocks/setup.ts index 1f7bc1c89..1e40bae25 100644 --- a/e2e/ci-e2e/mocks/setup.ts +++ b/e2e/ci-e2e/mocks/setup.ts @@ -1,18 +1,14 @@ import { cp } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - type FetchResult, - type Response, - type SimpleGit, - simpleGit, -} from 'simple-git'; +import { simpleGit } from 'simple-git'; import { nxTargetProject } from '@code-pushup/test-nx-utils'; import { teardownTestFolder } from '@code-pushup/test-setup'; import { E2E_ENVIRONMENTS_DIR, TEST_OUTPUT_DIR, initGitRepo, + simulateGitFetch, } from '@code-pushup/test-utils'; export type TestRepo = Awaited>; @@ -45,29 +41,3 @@ export async function setupTestRepo(folder: string) { cleanup: () => teardownTestFolder(baseDir), }; } - -// git fetch and FETCH_HEAD must be simulated because of missing remote -async function simulateGitFetch(git: SimpleGit) { - let fetchHead: string = await git.branchLocal().then(resp => resp.current); - - vi.spyOn(git, 'fetch').mockImplementation((...args) => { - fetchHead = (args as unknown as [string, string, string[]])[1]; - return Promise.resolve({}) as Response; - }); - - const originalDiffSummary = git.diffSummary.bind(git); - const originalDiff = git.diff.bind(git); - - vi.spyOn(git, 'diffSummary').mockImplementation(args => - originalDiffSummary( - (args as unknown as string[]).map(arg => - arg === 'FETCH_HEAD' ? fetchHead : arg, - ), - ), - ); - vi.spyOn(git, 'diff').mockImplementation(args => - originalDiff( - (args as string[]).map(arg => (arg === 'FETCH_HEAD' ? fetchHead : arg)), - ), - ); -} diff --git a/packages/ci/.eslintrc.json b/packages/ci/.eslintrc.json index 6864853e6..e4fcd6c29 100644 --- a/packages/ci/.eslintrc.json +++ b/packages/ci/.eslintrc.json @@ -7,7 +7,9 @@ "parserOptions": { "project": ["packages/ci/tsconfig.*?.json"] }, - "rules": {} + "rules": { + "vitest/max-nested-describe": ["warn", { "max": 3 }] + } }, { "files": ["*.json"], diff --git a/packages/ci/mocks/fixtures/code-pushup.config.ts b/packages/ci/mocks/fixtures/code-pushup.config.ts deleted file mode 100644 index 2ae40ebdb..000000000 --- a/packages/ci/mocks/fixtures/code-pushup.config.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { glob } from 'glob'; -import type { CoreConfig } from '@code-pushup/models'; - -const config: CoreConfig = { - plugins: [ - { - slug: 'ts-migration', - title: 'TypeScript migration', - icon: 'typescript', - audits: [ - { - slug: 'ts-files', - title: 'Source files converted from JavaScript to TypeScript', - }, - ], - runner: async () => { - const paths = await glob('**/*.{js,ts}'); - const jsPaths = paths.filter(path => path.endsWith('.js')); - const tsPaths = paths.filter(path => path.endsWith('.ts')); - const jsFileCount = jsPaths.length; - const tsFileCount = tsPaths.length; - const ratio = tsFileCount / (jsFileCount + tsFileCount); - const percentage = Math.round(ratio * 100); - return [ - { - slug: 'ts-files', - value: percentage, - score: ratio, - displayValue: `${percentage}% converted`, - details: { - issues: jsPaths.map(file => ({ - message: 'Use .ts file extension instead of .js', - severity: 'warning', - source: { file }, - })), - }, - }, - ]; - }, - }, - ], -}; - -export default config; diff --git a/packages/ci/mocks/fixtures/config.json b/packages/ci/mocks/fixtures/outputs/config.json similarity index 100% rename from packages/ci/mocks/fixtures/config.json rename to packages/ci/mocks/fixtures/outputs/config.json diff --git a/packages/ci/mocks/fixtures/report-diff.json b/packages/ci/mocks/fixtures/outputs/diff-project.json similarity index 100% rename from packages/ci/mocks/fixtures/report-diff.json rename to packages/ci/mocks/fixtures/outputs/diff-project.json diff --git a/packages/ci/mocks/fixtures/report-diff.md b/packages/ci/mocks/fixtures/outputs/diff-project.md similarity index 100% rename from packages/ci/mocks/fixtures/report-diff.md rename to packages/ci/mocks/fixtures/outputs/diff-project.md diff --git a/packages/ci/mocks/fixtures/feature-1/report.json b/packages/ci/mocks/fixtures/outputs/report-after.json similarity index 100% rename from packages/ci/mocks/fixtures/feature-1/report.json rename to packages/ci/mocks/fixtures/outputs/report-after.json diff --git a/packages/ci/mocks/fixtures/feature-1/report.md b/packages/ci/mocks/fixtures/outputs/report-after.md similarity index 100% rename from packages/ci/mocks/fixtures/feature-1/report.md rename to packages/ci/mocks/fixtures/outputs/report-after.md diff --git a/packages/ci/mocks/fixtures/main/report.json b/packages/ci/mocks/fixtures/outputs/report-before.json similarity index 100% rename from packages/ci/mocks/fixtures/main/report.json rename to packages/ci/mocks/fixtures/outputs/report-before.json diff --git a/packages/ci/mocks/fixtures/main/report.md b/packages/ci/mocks/fixtures/outputs/report-before.md similarity index 100% rename from packages/ci/mocks/fixtures/main/report.md rename to packages/ci/mocks/fixtures/outputs/report-before.md diff --git a/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts index 5551e0e21..3f3541945 100644 --- a/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts @@ -203,7 +203,6 @@ describe('yarnHandler', () => { ], }; - // eslint-disable-next-line vitest/max-nested-describe describe('classic Yarn (v1)', () => { beforeEach(() => { vi.spyOn(utils, 'executeProcess').mockResolvedValue({ @@ -218,7 +217,6 @@ describe('yarnHandler', () => { }); }); - // eslint-disable-next-line vitest/max-nested-describe describe('modern Yarn (v2+)', () => { beforeEach(() => { vi.spyOn(utils, 'executeProcess').mockResolvedValue({ diff --git a/packages/ci/src/lib/run.integration.test.ts b/packages/ci/src/lib/run.integration.test.ts index 9b10df09c..3cfb1dd21 100644 --- a/packages/ci/src/lib/run.integration.test.ts +++ b/packages/ci/src/lib/run.integration.test.ts @@ -1,21 +1,10 @@ -import { - copyFile, - mkdir, - readFile, - rename, - rm, - writeFile, -} from 'node:fs/promises'; +import { copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - type DiffResult, - type FetchResult, - type SimpleGit, - simpleGit, -} from 'simple-git'; +import { type SimpleGit, simpleGit } from 'simple-git'; import type { MockInstance } from 'vitest'; -import { initGitRepo } from '@code-pushup/test-utils'; +import { cleanTestFolder, teardownTestFolder } from '@code-pushup/test-setup'; +import { initGitRepo, simulateGitFetch } from '@code-pushup/test-utils'; import * as utils from '@code-pushup/utils'; import type { Comment, @@ -35,8 +24,28 @@ describe('runInCI', () => { 'mocks', 'fixtures', ); + const reportsDir = join(fixturesDir, 'outputs'); const workDir = join('tmp', 'ci', 'run-test'); - const outputDir = join(workDir, '.code-pushup'); + + const fixturePaths = { + reports: { + before: { + json: join(reportsDir, 'report-before.json'), + md: join(reportsDir, 'report-before.md'), + }, + after: { + json: join(reportsDir, 'report-after.json'), + md: join(reportsDir, 'report-after.md'), + }, + }, + diffs: { + project: { + json: join(reportsDir, 'diff-project.json'), + md: join(reportsDir, 'diff-project.md'), + }, + }, + config: join(reportsDir, 'config.json'), + }; const logger: Logger = { error: vi.fn(), @@ -46,11 +55,17 @@ describe('runInCI', () => { }; const options = { - bin: 'code-pushup', + bin: 'npx code-pushup', directory: workDir, logger, } satisfies Options; + const mockComment: Comment = { + id: 42, + body: '... ', + url: 'https://fake.hosted.git/comments/42', + }; + let git: SimpleGit; let cwdSpy: MockInstance< @@ -65,30 +80,28 @@ describe('runInCI', () => { beforeEach(async () => { executeProcessSpy = vi .spyOn(utils, 'executeProcess') - .mockImplementation(async ({ command, args }) => { - if (command === options.bin) { + .mockImplementation(async ({ command, args, cwd }) => { + const outputDir = join(cwd as string, '.code-pushup'); + if (command.includes('code-pushup')) { await mkdir(outputDir, { recursive: true }); let stdout = ''; switch (args![0]) { case 'compare': - await Promise.all( - ['report-diff.json', 'report-diff.md'].map(file => - copyFile(join(fixturesDir, file), join(outputDir, file)), - ), - ); + const diffs = fixturePaths.diffs.project; + await copyFile(diffs.json, join(outputDir, 'report-diff.json')); + await copyFile(diffs.md, join(outputDir, 'report-diff.md')); break; case 'print-config': - stdout = await readFile(join(fixturesDir, 'config.json'), 'utf8'); + stdout = await readFile(fixturePaths.config, 'utf8'); break; case 'merge-diffs': // not tested here break; default: - const reportDir = join(fixturesDir, (await git.branch()).current); - await Promise.all( - ['report.json', 'report.md'].map(file => - copyFile(join(reportDir, file), join(outputDir, file)), - ), - ); + const kind = + (await git.branch()).current === 'main' ? 'before' : 'after'; + const reports = fixturePaths.reports[kind]; + await copyFile(reports.json, join(outputDir, 'report.json')); + await copyFile(reports.md, join(outputDir, 'report.md')); break; } return { code: 0, stdout, stderr: '' } as utils.ProcessResult; @@ -100,24 +113,13 @@ describe('runInCI', () => { cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(workDir); - await rm(workDir, { recursive: true, force: true }); - await mkdir(workDir, { recursive: true }); - await copyFile( - join(fixturesDir, 'code-pushup.config.ts'), - join(workDir, 'code-pushup.config.ts'), - ); - await writeFile(join(workDir, 'index.js'), 'console.log("Hello, world!")'); + await cleanTestFolder(workDir); git = await initGitRepo(simpleGit, { baseDir: workDir }); + await simulateGitFetch(git); - vi.spyOn(git, 'fetch').mockResolvedValue({} as FetchResult); - vi.spyOn(git, 'diffSummary').mockResolvedValue({ - files: [{ file: 'index.ts', binary: false }], - } as DiffResult); - vi.spyOn(git, 'diff').mockResolvedValue(''); - + await writeFile(join(workDir, 'index.js'), 'console.log("Hello, world!")'); await git.add('index.js'); - await git.add('code-pushup.config.ts'); await git.commit('Initial commit'); }); @@ -125,216 +127,208 @@ describe('runInCI', () => { cwdSpy.mockRestore(); executeProcessSpy.mockRestore(); - await rm(workDir, { recursive: true, force: true }); + await teardownTestFolder(workDir); }); - describe('push event', () => { - beforeEach(async () => { - await git.checkout('main'); - }); + describe('standalone mode', () => { + const outputDir = join(workDir, '.code-pushup'); + + describe('push event', () => { + beforeEach(async () => { + await git.checkout('main'); + }); - it('should collect report', async () => { - await expect( - runInCI( - { head: { ref: 'main', sha: await git.revparse('main') } }, - {} as ProviderAPIClient, - options, - git, - ), - ).resolves.toEqual({ - mode: 'standalone', - files: { - report: { - json: join(outputDir, 'report.json'), - md: join(outputDir, 'report.md'), + it('should collect report', async () => { + await expect( + runInCI( + { head: { ref: 'main', sha: await git.revparse('main') } }, + {} as ProviderAPIClient, + options, + git, + ), + ).resolves.toEqual({ + mode: 'standalone', + files: { + report: { + json: join(outputDir, 'report.json'), + md: join(outputDir, 'report.md'), + }, }, - }, - } satisfies RunResult); - - expect(utils.executeProcess).toHaveBeenCalledTimes(2); - expect(utils.executeProcess).toHaveBeenNthCalledWith(1, { - command: options.bin, - args: ['print-config'], - cwd: workDir, - } satisfies utils.ProcessConfig); - expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { - command: options.bin, - args: ['--persist.format=json', '--persist.format=md'], - cwd: workDir, - } satisfies utils.ProcessConfig); - - expect(logger.error).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalled(); - expect(logger.debug).toHaveBeenCalled(); + } satisfies RunResult); + + expect(utils.executeProcess).toHaveBeenCalledTimes(2); + expect(utils.executeProcess).toHaveBeenNthCalledWith(1, { + command: options.bin, + args: ['print-config'], + cwd: workDir, + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { + command: options.bin, + args: ['--persist.format=json', '--persist.format=md'], + cwd: workDir, + } satisfies utils.ProcessConfig); + + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalled(); + }); }); - }); - describe('pull request event', () => { - let refs: GitRefs; - let diffMdString: string; + describe('pull request event', () => { + let refs: GitRefs; + let diffMdString: string; - const mockComment: Comment = { - id: 42, - body: '... ', - url: 'https://fake.hosted.git/comments/42', - }; + beforeEach(async () => { + await git.checkoutLocalBranch('feature-1'); - beforeEach(async () => { - await git.checkoutLocalBranch('feature-1'); + await rename(join(workDir, 'index.js'), join(workDir, 'index.ts')); - await rename(join(workDir, 'index.js'), join(workDir, 'index.ts')); + await git.add('index.ts'); + await git.commit('Convert JS file to TS'); - await git.add('index.ts'); - await git.commit('Convert JS file to TS'); + refs = { + head: { ref: 'feature-1', sha: await git.revparse('feature-1') }, + base: { ref: 'main', sha: await git.revparse('main') }, + }; - refs = { - head: { ref: 'feature-1', sha: await git.revparse('feature-1') }, - base: { ref: 'main', sha: await git.revparse('main') }, - }; - - diffMdString = await readFile( - join(fixturesDir, 'report-diff.md'), - 'utf8', - ); - }); + diffMdString = await readFile(fixturePaths.diffs.project.md, 'utf8'); + }); - it('should collect both reports when uncached, compare and create new comment', async () => { - const api: ProviderAPIClient = { - maxCommentChars: 1_000_000, - createComment: vi.fn().mockResolvedValue(mockComment), - updateComment: vi.fn(), - listComments: vi.fn().mockResolvedValue([]), - }; - - await expect(runInCI(refs, api, options, git)).resolves.toEqual({ - mode: 'standalone', - commentId: mockComment.id, - newIssues: [], - files: { - report: { - json: join(outputDir, 'report.json'), - md: join(outputDir, 'report.md'), + it('should collect both reports when uncached, compare and create new comment', async () => { + const api: ProviderAPIClient = { + maxCommentChars: 1_000_000, + createComment: vi.fn().mockResolvedValue(mockComment), + updateComment: vi.fn(), + listComments: vi.fn().mockResolvedValue([]), + }; + + await expect(runInCI(refs, api, options, git)).resolves.toEqual({ + mode: 'standalone', + commentId: mockComment.id, + newIssues: [], + files: { + report: { + json: join(outputDir, 'report.json'), + md: join(outputDir, 'report.md'), + }, + diff: { + json: join(outputDir, 'report-diff.json'), + md: join(outputDir, 'report-diff.md'), + }, }, - diff: { - json: join(outputDir, 'report-diff.json'), - md: join(outputDir, 'report-diff.md'), - }, - }, - } satisfies RunResult); - - expect(api.listComments).toHaveBeenCalledWith(); - expect(api.createComment).toHaveBeenCalledWith( - expect.stringContaining(diffMdString), - ); - expect(api.updateComment).not.toHaveBeenCalled(); - - expect(utils.executeProcess).toHaveBeenCalledTimes(5); - expect(utils.executeProcess).toHaveBeenNthCalledWith(1, { - command: options.bin, - args: ['print-config'], - cwd: workDir, - } satisfies utils.ProcessConfig); - expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { - command: options.bin, - args: ['--persist.format=json', '--persist.format=md'], - cwd: workDir, - } satisfies utils.ProcessConfig); - expect(utils.executeProcess).toHaveBeenNthCalledWith(3, { - command: options.bin, - args: ['print-config'], - cwd: workDir, - } satisfies utils.ProcessConfig); - expect(utils.executeProcess).toHaveBeenNthCalledWith(4, { - command: options.bin, - args: ['--persist.format=json', '--persist.format=md'], - cwd: workDir, - } satisfies utils.ProcessConfig); - expect(utils.executeProcess).toHaveBeenNthCalledWith(5, { - command: options.bin, - args: [ - 'compare', - `--before=${join(outputDir, 'prev-report.json')}`, - `--after=${join(outputDir, 'curr-report.json')}`, - '--persist.format=json', - '--persist.format=md', - ], - cwd: workDir, - } satisfies utils.ProcessConfig); - - expect(logger.error).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalled(); - expect(logger.debug).toHaveBeenCalled(); - }); + } satisfies RunResult); - it('should collect new report and use cached old report, compare and update existing comment', async () => { - const api: ProviderAPIClient = { - maxCommentChars: 1_000_000, - createComment: vi.fn(), - updateComment: vi.fn().mockResolvedValue(mockComment), - listComments: vi.fn().mockResolvedValue([mockComment]), - downloadReportArtifact: vi.fn().mockImplementation(async () => { - const downloadPath = join(workDir, 'downloaded-report.json'); - await copyFile( - join(fixturesDir, 'main', 'report.json'), - downloadPath, - ); - return downloadPath; - }), - }; - - await expect(runInCI(refs, api, options, git)).resolves.toEqual({ - mode: 'standalone', - commentId: mockComment.id, - newIssues: [], - files: { - report: { - json: join(outputDir, 'report.json'), - md: join(outputDir, 'report.md'), - }, - diff: { - json: join(outputDir, 'report-diff.json'), - md: join(outputDir, 'report-diff.md'), + expect(api.listComments).toHaveBeenCalledWith(); + expect(api.createComment).toHaveBeenCalledWith( + expect.stringContaining(diffMdString), + ); + expect(api.updateComment).not.toHaveBeenCalled(); + + expect(utils.executeProcess).toHaveBeenCalledTimes(5); + expect(utils.executeProcess).toHaveBeenNthCalledWith(1, { + command: options.bin, + args: ['print-config'], + cwd: workDir, + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { + command: options.bin, + args: ['--persist.format=json', '--persist.format=md'], + cwd: workDir, + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenNthCalledWith(3, { + command: options.bin, + args: ['print-config'], + cwd: workDir, + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenNthCalledWith(4, { + command: options.bin, + args: ['--persist.format=json', '--persist.format=md'], + cwd: workDir, + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenNthCalledWith(5, { + command: options.bin, + args: [ + 'compare', + `--before=${join(outputDir, 'prev-report.json')}`, + `--after=${join(outputDir, 'curr-report.json')}`, + '--persist.format=json', + '--persist.format=md', + ], + cwd: workDir, + } satisfies utils.ProcessConfig); + + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalled(); + }); + + it('should collect new report and use cached old report, compare and update existing comment', async () => { + const api: ProviderAPIClient = { + maxCommentChars: 1_000_000, + createComment: vi.fn(), + updateComment: vi.fn().mockResolvedValue(mockComment), + listComments: vi.fn().mockResolvedValue([mockComment]), + downloadReportArtifact: vi.fn().mockImplementation(async () => { + const downloadPath = join(workDir, 'downloaded-report.json'); + await copyFile(fixturePaths.reports.before.json, downloadPath); + return downloadPath; + }), + }; + + await expect(runInCI(refs, api, options, git)).resolves.toEqual({ + mode: 'standalone', + commentId: mockComment.id, + newIssues: [], + files: { + report: { + json: join(outputDir, 'report.json'), + md: join(outputDir, 'report.md'), + }, + diff: { + json: join(outputDir, 'report-diff.json'), + md: join(outputDir, 'report-diff.md'), + }, }, - }, - } satisfies RunResult); - - expect(api.listComments).toHaveBeenCalledWith(); - expect(api.updateComment).toHaveBeenCalledWith( - mockComment.id, - expect.stringContaining(diffMdString), - ); - expect(api.createComment).not.toHaveBeenCalled(); - expect(api.downloadReportArtifact).toHaveBeenCalledWith(undefined); - - expect(utils.executeProcess).toHaveBeenCalledTimes(3); - expect(utils.executeProcess).toHaveBeenNthCalledWith(1, { - command: options.bin, - args: ['print-config'], - cwd: workDir, - } satisfies utils.ProcessConfig); - expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { - command: options.bin, - args: ['--persist.format=json', '--persist.format=md'], - cwd: workDir, - } satisfies utils.ProcessConfig); - expect(utils.executeProcess).toHaveBeenNthCalledWith(3, { - command: options.bin, - args: [ - 'compare', - `--before=${join(outputDir, 'prev-report.json')}`, - `--after=${join(outputDir, 'curr-report.json')}`, - '--persist.format=json', - '--persist.format=md', - ], - cwd: workDir, - } satisfies utils.ProcessConfig); - - expect(logger.error).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalled(); - expect(logger.debug).toHaveBeenCalled(); + } satisfies RunResult); + + expect(api.listComments).toHaveBeenCalledWith(); + expect(api.updateComment).toHaveBeenCalledWith( + mockComment.id, + expect.stringContaining(diffMdString), + ); + expect(api.createComment).not.toHaveBeenCalled(); + expect(api.downloadReportArtifact).toHaveBeenCalledWith(undefined); + + expect(utils.executeProcess).toHaveBeenCalledTimes(3); + expect(utils.executeProcess).toHaveBeenNthCalledWith(1, { + command: options.bin, + args: ['print-config'], + cwd: workDir, + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { + command: options.bin, + args: ['--persist.format=json', '--persist.format=md'], + cwd: workDir, + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenNthCalledWith(3, { + command: options.bin, + args: [ + 'compare', + `--before=${join(outputDir, 'prev-report.json')}`, + `--after=${join(outputDir, 'curr-report.json')}`, + '--persist.format=json', + '--persist.format=md', + ], + cwd: workDir, + } satisfies utils.ProcessConfig); + + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalled(); + }); }); }); }); diff --git a/testing/test-utils/src/lib/utils/git.ts b/testing/test-utils/src/lib/utils/git.ts index 99c48a8e4..408ed430a 100644 --- a/testing/test-utils/src/lib/utils/git.ts +++ b/testing/test-utils/src/lib/utils/git.ts @@ -1,6 +1,12 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import type { SimpleGit, SimpleGitFactory } from 'simple-git'; +import type { + FetchResult, + Response, + SimpleGit, + SimpleGitFactory, +} from 'simple-git'; +import { vi } from 'vitest'; export type GitConfig = { name: string; email: string }; @@ -46,3 +52,29 @@ export async function commitFile( } return git; } + +export async function simulateGitFetch(git: SimpleGit) { + // eslint-disable-next-line functional/no-let + let fetchHead: string = await git.branchLocal().then(resp => resp.current); + + vi.spyOn(git, 'fetch').mockImplementation((...args) => { + fetchHead = (args as unknown as [string, string, string[]])[1]; + return Promise.resolve({}) as Response; + }); + + const originalDiffSummary = git.diffSummary.bind(git); + const originalDiff = git.diff.bind(git); + + vi.spyOn(git, 'diffSummary').mockImplementation(args => + originalDiffSummary( + (args as unknown as string[]).map(arg => + arg === 'FETCH_HEAD' ? fetchHead : arg, + ), + ), + ); + vi.spyOn(git, 'diff').mockImplementation(args => + originalDiff( + (args as string[]).map(arg => (arg === 'FETCH_HEAD' ? fetchHead : arg)), + ), + ); +} From 0ef24a95072bdb82e1c729486e202504aea5b8dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Mon, 9 Dec 2024 13:50:21 +0100 Subject: [PATCH 23/38] test(ci): integration test push event for each monorepo tool --- .../monorepos/tools/npm/package-lock.json | 27 +++ .../fixtures/monorepos/tools/npm/package.json | 6 + .../tools/npm/packages/cli/package.json | 6 + .../tools/npm/packages/core/package.json | 6 + .../tools/npm/packages/utils/package.json | 6 + .../fixtures/monorepos/tools/nx/.gitignore | 1 + .../mocks/fixtures/monorepos/tools/nx/nx.json | 1 + .../monorepos/tools/nx/package-lock.json | 6 + .../fixtures/monorepos/tools/nx/package.json | 1 + .../tools/nx/packages/cli/project.json | 8 + .../tools/nx/packages/core/project.json | 8 + .../tools/nx/packages/utils/project.json | 8 + .../monorepos/tools/pnpm/package.json | 3 + .../tools/pnpm/packages/cli/package.json | 6 + .../tools/pnpm/packages/core/package.json | 6 + .../tools/pnpm/packages/utils/package.json | 6 + .../monorepos/tools/pnpm/pnpm-lock.yaml | 14 ++ .../monorepos/tools/pnpm/pnpm-workspace.yaml | 2 + .../monorepos/tools/turbo/package.json | 7 + .../tools/turbo/packages/cli/package.json | 6 + .../tools/turbo/packages/core/package.json | 6 + .../tools/turbo/packages/utils/package.json | 6 + .../fixtures/monorepos/tools/turbo/turbo.json | 8 + .../fixtures/monorepos/tools/turbo/yarn.lock | 4 + .../monorepos/tools/yarn/package.json | 7 + .../tools/yarn/packages/cli/package.json | 6 + .../tools/yarn/packages/core/package.json | 6 + .../tools/yarn/packages/utils/package.json | 6 + .../fixtures/monorepos/tools/yarn/yarn.lock | 30 +++ packages/ci/src/lib/run.integration.test.ts | 174 +++++++++++++++--- 30 files changed, 357 insertions(+), 30 deletions(-) create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/npm/package-lock.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/npm/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/npm/packages/cli/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/npm/packages/core/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/npm/packages/utils/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/nx/.gitignore create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/nx/nx.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/nx/package-lock.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/nx/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/nx/packages/cli/project.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/nx/packages/core/project.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/nx/packages/utils/project.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/pnpm/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/cli/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/core/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/utils/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/pnpm/pnpm-lock.yaml create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/pnpm/pnpm-workspace.yaml create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/turbo/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/cli/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/core/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/utils/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/turbo/turbo.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/turbo/yarn.lock create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/yarn/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/cli/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/core/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/utils/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/tools/yarn/yarn.lock diff --git a/packages/ci/mocks/fixtures/monorepos/tools/npm/package-lock.json b/packages/ci/mocks/fixtures/monorepos/tools/npm/package-lock.json new file mode 100644 index 000000000..f1a67f556 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/npm/package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "npm", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "workspaces": [ + "packages/*" + ] + }, + "node_modules/cli": { + "resolved": "packages/cli", + "link": true + }, + "node_modules/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/utils": { + "resolved": "packages/utils", + "link": true + }, + "packages/cli": {}, + "packages/core": {}, + "packages/utils": {} + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/npm/package.json b/packages/ci/mocks/fixtures/monorepos/tools/npm/package.json new file mode 100644 index 000000000..9f0acdba5 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/npm/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "workspaces": [ + "packages/*" + ] +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/npm/packages/cli/package.json b/packages/ci/mocks/fixtures/monorepos/tools/npm/packages/cli/package.json new file mode 100644 index 000000000..1a558500c --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/npm/packages/cli/package.json @@ -0,0 +1,6 @@ +{ + "name": "cli", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/npm/packages/core/package.json b/packages/ci/mocks/fixtures/monorepos/tools/npm/packages/core/package.json new file mode 100644 index 000000000..e2b53d3a3 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/npm/packages/core/package.json @@ -0,0 +1,6 @@ +{ + "name": "core", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/npm/packages/utils/package.json b/packages/ci/mocks/fixtures/monorepos/tools/npm/packages/utils/package.json new file mode 100644 index 000000000..c75ecc004 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/npm/packages/utils/package.json @@ -0,0 +1,6 @@ +{ + "name": "utils", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/nx/.gitignore b/packages/ci/mocks/fixtures/monorepos/tools/nx/.gitignore new file mode 100644 index 000000000..fb222bf4d --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/nx/.gitignore @@ -0,0 +1 @@ +/.nx \ No newline at end of file diff --git a/packages/ci/mocks/fixtures/monorepos/tools/nx/nx.json b/packages/ci/mocks/fixtures/monorepos/tools/nx/nx.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/nx/nx.json @@ -0,0 +1 @@ +{} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/nx/package-lock.json b/packages/ci/mocks/fixtures/monorepos/tools/nx/package-lock.json new file mode 100644 index 000000000..e2edcfee0 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/nx/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "nx", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/nx/package.json b/packages/ci/mocks/fixtures/monorepos/tools/nx/package.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/nx/package.json @@ -0,0 +1 @@ +{} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/nx/packages/cli/project.json b/packages/ci/mocks/fixtures/monorepos/tools/nx/packages/cli/project.json new file mode 100644 index 000000000..272ccae05 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/nx/packages/cli/project.json @@ -0,0 +1,8 @@ +{ + "name": "cli", + "targets": { + "code-pushup": { + "command": "npx code-pushup --config=packages/cli/code-pushup.config.js --persist.outputDir=packages/cli/.code-pushup" + } + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/nx/packages/core/project.json b/packages/ci/mocks/fixtures/monorepos/tools/nx/packages/core/project.json new file mode 100644 index 000000000..cb22860bf --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/nx/packages/core/project.json @@ -0,0 +1,8 @@ +{ + "name": "core", + "targets": { + "code-pushup": { + "command": "npx code-pushup --config=packages/core/code-pushup.config.js --persist.outputDir=packages/cli/.code-pushup" + } + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/nx/packages/utils/project.json b/packages/ci/mocks/fixtures/monorepos/tools/nx/packages/utils/project.json new file mode 100644 index 000000000..e68a159e4 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/nx/packages/utils/project.json @@ -0,0 +1,8 @@ +{ + "name": "utils", + "targets": { + "code-pushup": { + "command": "npx code-pushup --config=packages/utils/code-pushup.config.js --persist.outputDir=packages/cli/.code-pushup" + } + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/pnpm/package.json b/packages/ci/mocks/fixtures/monorepos/tools/pnpm/package.json new file mode 100644 index 000000000..87abd4a0f --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/pnpm/package.json @@ -0,0 +1,3 @@ +{ + "packageManager": "pnpm@9.5.0" +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/cli/package.json b/packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/cli/package.json new file mode 100644 index 000000000..1a558500c --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/cli/package.json @@ -0,0 +1,6 @@ +{ + "name": "cli", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/core/package.json b/packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/core/package.json new file mode 100644 index 000000000..e2b53d3a3 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/core/package.json @@ -0,0 +1,6 @@ +{ + "name": "core", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/utils/package.json b/packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/utils/package.json new file mode 100644 index 000000000..c75ecc004 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/utils/package.json @@ -0,0 +1,6 @@ +{ + "name": "utils", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/pnpm/pnpm-lock.yaml b/packages/ci/mocks/fixtures/monorepos/tools/pnpm/pnpm-lock.yaml new file mode 100644 index 000000000..3ab979278 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/pnpm/pnpm-lock.yaml @@ -0,0 +1,14 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: {} + + packages/cli: {} + + packages/core: {} + + packages/utils: {} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/pnpm/pnpm-workspace.yaml b/packages/ci/mocks/fixtures/monorepos/tools/pnpm/pnpm-workspace.yaml new file mode 100644 index 000000000..924b55f42 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/pnpm/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/packages/ci/mocks/fixtures/monorepos/tools/turbo/package.json b/packages/ci/mocks/fixtures/monorepos/tools/turbo/package.json new file mode 100644 index 000000000..19179f45a --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/turbo/package.json @@ -0,0 +1,7 @@ +{ + "packageManager": "yarn@1.22.19", + "private": true, + "workspaces": [ + "packages/*" + ] +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/cli/package.json b/packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/cli/package.json new file mode 100644 index 000000000..1a558500c --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/cli/package.json @@ -0,0 +1,6 @@ +{ + "name": "cli", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/core/package.json b/packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/core/package.json new file mode 100644 index 000000000..e2b53d3a3 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/core/package.json @@ -0,0 +1,6 @@ +{ + "name": "core", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/utils/package.json b/packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/utils/package.json new file mode 100644 index 000000000..c75ecc004 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/utils/package.json @@ -0,0 +1,6 @@ +{ + "name": "utils", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/turbo/turbo.json b/packages/ci/mocks/fixtures/monorepos/tools/turbo/turbo.json new file mode 100644 index 000000000..12316330d --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/turbo/turbo.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "code-pushup": { + "outputs": [".code-pushup"] + } + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/turbo/yarn.lock b/packages/ci/mocks/fixtures/monorepos/tools/turbo/yarn.lock new file mode 100644 index 000000000..fb57ccd13 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/turbo/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + diff --git a/packages/ci/mocks/fixtures/monorepos/tools/yarn/package.json b/packages/ci/mocks/fixtures/monorepos/tools/yarn/package.json new file mode 100644 index 000000000..b9bff8df0 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/yarn/package.json @@ -0,0 +1,7 @@ +{ + "packageManager": "yarn@4.5.0", + "private": true, + "workspaces": [ + "packages/*" + ] +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/cli/package.json b/packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/cli/package.json new file mode 100644 index 000000000..1a558500c --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/cli/package.json @@ -0,0 +1,6 @@ +{ + "name": "cli", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/core/package.json b/packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/core/package.json new file mode 100644 index 000000000..e2b53d3a3 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/core/package.json @@ -0,0 +1,6 @@ +{ + "name": "core", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/utils/package.json b/packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/utils/package.json new file mode 100644 index 000000000..c75ecc004 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/utils/package.json @@ -0,0 +1,6 @@ +{ + "name": "utils", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/tools/yarn/yarn.lock b/packages/ci/mocks/fixtures/monorepos/tools/yarn/yarn.lock new file mode 100644 index 000000000..05210ced0 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/tools/yarn/yarn.lock @@ -0,0 +1,30 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"cli@workspace:packages/cli": + version: 0.0.0-use.local + resolution: "cli@workspace:packages/cli" + languageName: unknown + linkType: soft + +"core@workspace:packages/core": + version: 0.0.0-use.local + resolution: "core@workspace:packages/core" + languageName: unknown + linkType: soft + +"root-workspace-0b6124@workspace:.": + version: 0.0.0-use.local + resolution: "root-workspace-0b6124@workspace:." + languageName: unknown + linkType: soft + +"utils@workspace:packages/utils": + version: 0.0.0-use.local + resolution: "utils@workspace:packages/utils" + languageName: unknown + linkType: soft diff --git a/packages/ci/src/lib/run.integration.test.ts b/packages/ci/src/lib/run.integration.test.ts index 3cfb1dd21..20d27ee67 100644 --- a/packages/ci/src/lib/run.integration.test.ts +++ b/packages/ci/src/lib/run.integration.test.ts @@ -1,8 +1,16 @@ -import { copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { + copyFile, + cp, + mkdir, + readFile, + rename, + writeFile, +} from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { type SimpleGit, simpleGit } from 'simple-git'; import type { MockInstance } from 'vitest'; +import type { CoreConfig } from '@code-pushup/models'; import { cleanTestFolder, teardownTestFolder } from '@code-pushup/test-setup'; import { initGitRepo, simulateGitFetch } from '@code-pushup/test-utils'; import * as utils from '@code-pushup/utils'; @@ -14,6 +22,7 @@ import type { ProviderAPIClient, RunResult, } from './models.js'; +import type { MonorepoTool } from './monorepo/index.js'; import { runInCI } from './run.js'; describe('runInCI', () => { @@ -25,7 +34,7 @@ describe('runInCI', () => { 'fixtures', ); const reportsDir = join(fixturesDir, 'outputs'); - const workDir = join('tmp', 'ci', 'run-test'); + const workDir = join(process.cwd(), 'tmp', 'ci', 'run-test'); const fixturePaths = { reports: { @@ -77,38 +86,60 @@ describe('runInCI', () => { Promise >; + async function simulateCodePushUpExecution({ + command, + args, + cwd, + }: utils.ProcessConfig): Promise { + const nxMatch = command.match(/nx run (\w+):code-pushup/); + const outputDir = nxMatch + ? join(workDir, `packages/${nxMatch[1]}/.code-pushup`) + : join(cwd as string, '.code-pushup'); + await mkdir(outputDir, { recursive: true }); + let stdout = ''; + + switch (args![0]) { + case 'compare': + const diffs = fixturePaths.diffs.project; + await copyFile(diffs.json, join(outputDir, 'report-diff.json')); + await copyFile(diffs.md, join(outputDir, 'report-diff.md')); + break; + + case 'print-config': + stdout = await readFile(fixturePaths.config, 'utf8'); + if (nxMatch) { + // simulate effect of custom persist.outputDir per Nx project + const config = JSON.parse(stdout) as CoreConfig; + // eslint-disable-next-line functional/immutable-data + config.persist!.outputDir = outputDir; + stdout = JSON.stringify(config, null, 2); + } + break; + + case 'merge-diffs': // not tested here + break; + + default: + const kind = + (await git.branch()).current === 'main' ? 'before' : 'after'; + const reports = fixturePaths.reports[kind]; + await copyFile(reports.json, join(outputDir, 'report.json')); + await copyFile(reports.md, join(outputDir, 'report.md')); + break; + } + + return { code: 0, stdout, stderr: '' } as utils.ProcessResult; + } + beforeEach(async () => { + const originalExecuteProcess = utils.executeProcess; executeProcessSpy = vi .spyOn(utils, 'executeProcess') - .mockImplementation(async ({ command, args, cwd }) => { - const outputDir = join(cwd as string, '.code-pushup'); - if (command.includes('code-pushup')) { - await mkdir(outputDir, { recursive: true }); - let stdout = ''; - switch (args![0]) { - case 'compare': - const diffs = fixturePaths.diffs.project; - await copyFile(diffs.json, join(outputDir, 'report-diff.json')); - await copyFile(diffs.md, join(outputDir, 'report-diff.md')); - break; - case 'print-config': - stdout = await readFile(fixturePaths.config, 'utf8'); - break; - case 'merge-diffs': // not tested here - break; - default: - const kind = - (await git.branch()).current === 'main' ? 'before' : 'after'; - const reports = fixturePaths.reports[kind]; - await copyFile(reports.json, join(outputDir, 'report.json')); - await copyFile(reports.md, join(outputDir, 'report.md')); - break; - } - return { code: 0, stdout, stderr: '' } as utils.ProcessResult; + .mockImplementation(cfg => { + if (cfg.command.includes('code-pushup')) { + return simulateCodePushUpExecution(cfg); } - throw new Error( - `Unexpected executeProcess call: ${command} ${args?.join(' ') ?? ''}`, - ); + return originalExecuteProcess(cfg); }); cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(workDir); @@ -331,4 +362,87 @@ describe('runInCI', () => { }); }); }); + + describe.each<[MonorepoTool, string]>([ + ['nx', expect.stringMatching(/^npx nx run \w+:code-pushup --$/)], + ['turbo', 'npx turbo run code-pushup --'], + ['pnpm', 'pnpm run code-pushup'], + ['yarn', 'yarn run code-pushup'], + ['npm', 'npm run code-pushup --'], + ])('monorepo mode - %s', (tool, bin) => { + beforeEach(async () => { + const monorepoDir = join(fixturesDir, 'monorepos', 'tools', tool); + await cp(monorepoDir, workDir, { recursive: true }); + await git.add('.'); + await git.commit(`Create packages in ${tool} monorepo`); + }); + + describe('push event', () => { + beforeEach(async () => { + await git.checkout('main'); + }); + + it('should collect reports for all projects', async () => { + await expect( + runInCI( + { head: { ref: 'main', sha: await git.revparse('main') } }, + {} as ProviderAPIClient, + { ...options, monorepo: tool }, + git, + ), + ).resolves.toEqual({ + mode: 'monorepo', + projects: [ + { + name: 'cli', + files: { + report: { + json: join(workDir, 'packages/cli/.code-pushup/report.json'), + md: join(workDir, 'packages/cli/.code-pushup/report.md'), + }, + }, + }, + { + name: 'core', + files: { + report: { + json: join(workDir, 'packages/core/.code-pushup/report.json'), + md: join(workDir, 'packages/core/.code-pushup/report.md'), + }, + }, + }, + { + name: 'utils', + files: { + report: { + json: join( + workDir, + 'packages/utils/.code-pushup/report.json', + ), + md: join(workDir, 'packages/utils/.code-pushup/report.md'), + }, + }, + }, + ], + } satisfies RunResult); + + expect(executeProcessSpy.mock.calls.length).toBeGreaterThanOrEqual(6); + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: bin, + args: ['print-config'], + cwd: expect.stringContaining(workDir), + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: bin, + args: ['--persist.format=json', '--persist.format=md'], + cwd: expect.stringContaining(workDir), + } satisfies utils.ProcessConfig); + + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalled(); + }); + }); + }); }); From fd2ced271aea78a188de77109454d5fc1742105d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Mon, 9 Dec 2024 16:11:10 +0100 Subject: [PATCH 24/38] test(ci): integration test all monorepo tools for pull requests --- .../ci/mocks/fixtures/outputs/diff-merged.md | 45 +++++ packages/ci/src/lib/run.integration.test.ts | 183 +++++++++++++++++- 2 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 packages/ci/mocks/fixtures/outputs/diff-merged.md diff --git a/packages/ci/mocks/fixtures/outputs/diff-merged.md b/packages/ci/mocks/fixtures/outputs/diff-merged.md new file mode 100644 index 000000000..2115957cc --- /dev/null +++ b/packages/ci/mocks/fixtures/outputs/diff-merged.md @@ -0,0 +1,45 @@ +# Code PushUp + +🥳 Code PushUp report has **improved** – compared target commit 0123456789abcdef0123456789abcdef01234567 with source commit abcdef0123456789abcdef0123456789abcdef01. + +## 💼 Project `core` + +🥳 Code PushUp report has **improved**. + +| 🏷️ Category | ⭐ Previous score | ⭐ Current score | 🔄 Score change | +| :------------------- | :---------------: | :--------------: | :--------------------------------------------------------------: | +| TypeScript migration | 🔴 0 | 🟢 **100** | ![↑ +100](https://img.shields.io/badge/%E2%86%91%20%2B100-green) | + +
+👍 1 audit improved + +### 🛡️ Audits + +| 🔌 Plugin | 🛡️ Audit | 📏 Previous value | 📏 Current value | 🔄 Value change | +| :------------------- | :--------------------------------------------------- | :---------------: | :-------------------: | :--------------------------------------------------------------------------------: | +| TypeScript migration | Source files converted from JavaScript to TypeScript | 🟥 0% converted | 🟩 **100% converted** | ![↑ +∞ %](https://img.shields.io/badge/%E2%86%91%20%2B%E2%88%9E%E2%80%89%25-green) | + +
+ +## 💼 Project `cli` + +🥳 Code PushUp report has **improved**. + +| 🏷️ Category | ⭐ Previous score | ⭐ Current score | 🔄 Score change | +| :------------------- | :---------------: | :--------------: | :------------------------------------------------------------: | +| TypeScript migration | 🔴 0 | 🟡 **50** | ![↑ +50](https://img.shields.io/badge/%E2%86%91%20%2B50-green) | + +
+👍 1 audit improved + +### 🛡️ Audits + +| 🔌 Plugin | 🛡️ Audit | 📏 Previous value | 📏 Current value | 🔄 Value change | +| :------------------- | :--------------------------------------------------- | :---------------: | :------------------: | :--------------------------------------------------------------------------------: | +| TypeScript migration | Source files converted from JavaScript to TypeScript | 🟥 0% converted | 🟨 **50% converted** | ![↑ +∞ %](https://img.shields.io/badge/%E2%86%91%20%2B%E2%88%9E%E2%80%89%25-green) | + +
+ +--- + +1 other project is unchanged. diff --git a/packages/ci/src/lib/run.integration.test.ts b/packages/ci/src/lib/run.integration.test.ts index 20d27ee67..17612cc9d 100644 --- a/packages/ci/src/lib/run.integration.test.ts +++ b/packages/ci/src/lib/run.integration.test.ts @@ -52,6 +52,9 @@ describe('runInCI', () => { json: join(reportsDir, 'diff-project.json'), md: join(reportsDir, 'diff-project.md'), }, + merged: { + md: join(reportsDir, 'diff-merged.md'), + }, }, config: join(reportsDir, 'config.json'), }; @@ -116,7 +119,14 @@ describe('runInCI', () => { } break; - case 'merge-diffs': // not tested here + case 'merge-diffs': + await copyFile( + fixturePaths.diffs.merged.md, + join( + nxMatch ? workDir : (cwd as string), + '.code-pushup/merged-report-diff.md', + ), + ); break; default: @@ -427,6 +437,153 @@ describe('runInCI', () => { } satisfies RunResult); expect(executeProcessSpy.mock.calls.length).toBeGreaterThanOrEqual(6); + expect( + executeProcessSpy.mock.calls.filter(([cfg]) => + cfg.command.includes('code-pushup'), + ), + ).toHaveLength(6); // 3 projects: 1 autorun, 1 print-config + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: bin, + args: ['print-config'], + cwd: expect.stringContaining(workDir), + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: bin, + args: ['--persist.format=json', '--persist.format=md'], + cwd: expect.stringContaining(workDir), + } satisfies utils.ProcessConfig); + + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalled(); + }); + }); + + describe('pull request event', () => { + let refs: GitRefs; + let diffMdString: string; + + beforeEach(async () => { + await git.checkoutLocalBranch('feature-1'); + + await writeFile(join(workDir, 'README.md'), '# Hello, world\n'); + await git.add('README.md'); + await git.commit('Create README'); + + refs = { + head: { ref: 'feature-1', sha: await git.revparse('feature-1') }, + base: { ref: 'main', sha: await git.revparse('main') }, + }; + + diffMdString = await readFile(fixturePaths.diffs.merged.md, 'utf8'); + }); + + it('should collect and compare reports for all projects and comment merged diff', async () => { + const api: ProviderAPIClient = { + maxCommentChars: 1_000_000, + createComment: vi.fn().mockResolvedValue(mockComment), + updateComment: vi.fn(), + listComments: vi.fn().mockResolvedValue([]), + downloadReportArtifact: vi.fn().mockImplementation(async project => { + if (project === 'utils') { + // simulates a project which has no cached report + return null; + } + const downloadPath = join(workDir, 'downloaded-report.json'); + await copyFile(fixturePaths.reports.before.json, downloadPath); + return downloadPath; + }), + }; + + await expect( + runInCI(refs, api, { ...options, monorepo: tool }, git), + ).resolves.toEqual({ + mode: 'monorepo', + commentId: mockComment.id, + diffPath: join(workDir, '.code-pushup/merged-report-diff.md'), + projects: [ + { + name: 'cli', + files: { + report: { + json: join(workDir, 'packages/cli/.code-pushup/report.json'), + md: join(workDir, 'packages/cli/.code-pushup/report.md'), + }, + diff: { + json: join( + workDir, + 'packages/cli/.code-pushup/report-diff.json', + ), + md: join(workDir, 'packages/cli/.code-pushup/report-diff.md'), + }, + }, + newIssues: [], + }, + { + name: 'core', + files: { + report: { + json: join(workDir, 'packages/core/.code-pushup/report.json'), + md: join(workDir, 'packages/core/.code-pushup/report.md'), + }, + diff: { + json: join( + workDir, + 'packages/core/.code-pushup/report-diff.json', + ), + md: join( + workDir, + 'packages/core/.code-pushup/report-diff.md', + ), + }, + }, + newIssues: [], + }, + { + name: 'utils', + files: { + report: { + json: join( + workDir, + 'packages/utils/.code-pushup/report.json', + ), + md: join(workDir, 'packages/utils/.code-pushup/report.md'), + }, + diff: { + json: join( + workDir, + 'packages/utils/.code-pushup/report-diff.json', + ), + md: join( + workDir, + 'packages/utils/.code-pushup/report-diff.md', + ), + }, + }, + newIssues: [], + }, + ], + } satisfies RunResult); + + await expect( + readFile(join(workDir, '.code-pushup/merged-report-diff.md'), 'utf8'), + ).resolves.toBe(diffMdString); + + expect(api.listComments).toHaveBeenCalledWith(); + expect(api.createComment).toHaveBeenCalledWith( + expect.stringContaining(diffMdString), + ); + expect(api.updateComment).not.toHaveBeenCalled(); + + // 2 cached projects: 1 autorun, 1 print-config, 1 compare + // 1 uncached project: 2 autoruns, 2 print-configs, 1 compare + // 1 merge-diffs + expect( + executeProcessSpy.mock.calls.filter(([cfg]) => + cfg.command.includes('code-pushup'), + ), + ).toHaveLength(12); expect(utils.executeProcess).toHaveBeenCalledWith({ command: bin, args: ['print-config'], @@ -437,6 +594,30 @@ describe('runInCI', () => { args: ['--persist.format=json', '--persist.format=md'], cwd: expect.stringContaining(workDir), } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: bin, + args: [ + 'compare', + expect.stringMatching(/^--before=.*prev-report.json$/), + expect.stringMatching(/^--after=.*curr-report.json$/), + expect.stringMatching(/^--label=\w+$/), + '--persist.format=json', + '--persist.format=md', + ], + cwd: expect.stringContaining(workDir), + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: bin, + args: [ + 'merge-diffs', + `--files=${join(workDir, 'packages/cli/.code-pushup/report-diff.json')}`, + `--files=${join(workDir, 'packages/core/.code-pushup/report-diff.json')}`, + `--files=${join(workDir, 'packages/utils/.code-pushup/report-diff.json')}`, + expect.stringMatching(/^--persist.outputDir=.*\.code-pushup$/), + '--persist.filename=merged-report', + ], + cwd: expect.stringContaining(workDir), + } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); expect(logger.warn).not.toHaveBeenCalled(); From 9a4c6bd0c6163b4155caeeca681406a168027963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Mon, 9 Dec 2024 16:17:12 +0100 Subject: [PATCH 25/38] test(ci): reduce nesting in fixtures folder --- .../mocks/fixtures/monorepos/{tools => }/npm/package-lock.json | 0 .../ci/mocks/fixtures/monorepos/{tools => }/npm/package.json | 0 .../monorepos/{tools => }/npm/packages/cli/package.json | 0 .../monorepos/{tools => }/npm/packages/core/package.json | 0 .../monorepos/{tools => }/npm/packages/utils/package.json | 0 packages/ci/mocks/fixtures/monorepos/{tools => }/nx/.gitignore | 0 packages/ci/mocks/fixtures/monorepos/{tools => }/nx/nx.json | 0 .../mocks/fixtures/monorepos/{tools => }/nx/package-lock.json | 0 .../ci/mocks/fixtures/monorepos/{tools => }/nx/package.json | 0 .../fixtures/monorepos/{tools => }/nx/packages/cli/project.json | 0 .../monorepos/{tools => }/nx/packages/core/project.json | 0 .../monorepos/{tools => }/nx/packages/utils/project.json | 0 .../ci/mocks/fixtures/monorepos/{tools => }/pnpm/package.json | 0 .../monorepos/{tools => }/pnpm/packages/cli/package.json | 0 .../monorepos/{tools => }/pnpm/packages/core/package.json | 0 .../monorepos/{tools => }/pnpm/packages/utils/package.json | 0 .../ci/mocks/fixtures/monorepos/{tools => }/pnpm/pnpm-lock.yaml | 0 .../fixtures/monorepos/{tools => }/pnpm/pnpm-workspace.yaml | 0 .../ci/mocks/fixtures/monorepos/{tools => }/turbo/package.json | 0 .../monorepos/{tools => }/turbo/packages/cli/package.json | 0 .../monorepos/{tools => }/turbo/packages/core/package.json | 0 .../monorepos/{tools => }/turbo/packages/utils/package.json | 0 .../ci/mocks/fixtures/monorepos/{tools => }/turbo/turbo.json | 0 .../ci/mocks/fixtures/monorepos/{tools => }/turbo/yarn.lock | 0 .../ci/mocks/fixtures/monorepos/{tools => }/yarn/package.json | 0 .../monorepos/{tools => }/yarn/packages/cli/package.json | 0 .../monorepos/{tools => }/yarn/packages/core/package.json | 0 .../monorepos/{tools => }/yarn/packages/utils/package.json | 0 packages/ci/mocks/fixtures/monorepos/{tools => }/yarn/yarn.lock | 0 packages/ci/src/lib/run.integration.test.ts | 2 +- 30 files changed, 1 insertion(+), 1 deletion(-) rename packages/ci/mocks/fixtures/monorepos/{tools => }/npm/package-lock.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/npm/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/npm/packages/cli/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/npm/packages/core/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/npm/packages/utils/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/nx/.gitignore (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/nx/nx.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/nx/package-lock.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/nx/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/nx/packages/cli/project.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/nx/packages/core/project.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/nx/packages/utils/project.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/pnpm/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/pnpm/packages/cli/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/pnpm/packages/core/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/pnpm/packages/utils/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/pnpm/pnpm-lock.yaml (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/pnpm/pnpm-workspace.yaml (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/turbo/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/turbo/packages/cli/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/turbo/packages/core/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/turbo/packages/utils/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/turbo/turbo.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/turbo/yarn.lock (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/yarn/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/yarn/packages/cli/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/yarn/packages/core/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/yarn/packages/utils/package.json (100%) rename packages/ci/mocks/fixtures/monorepos/{tools => }/yarn/yarn.lock (100%) diff --git a/packages/ci/mocks/fixtures/monorepos/tools/npm/package-lock.json b/packages/ci/mocks/fixtures/monorepos/npm/package-lock.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/npm/package-lock.json rename to packages/ci/mocks/fixtures/monorepos/npm/package-lock.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/npm/package.json b/packages/ci/mocks/fixtures/monorepos/npm/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/npm/package.json rename to packages/ci/mocks/fixtures/monorepos/npm/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/npm/packages/cli/package.json b/packages/ci/mocks/fixtures/monorepos/npm/packages/cli/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/npm/packages/cli/package.json rename to packages/ci/mocks/fixtures/monorepos/npm/packages/cli/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/npm/packages/core/package.json b/packages/ci/mocks/fixtures/monorepos/npm/packages/core/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/npm/packages/core/package.json rename to packages/ci/mocks/fixtures/monorepos/npm/packages/core/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/npm/packages/utils/package.json b/packages/ci/mocks/fixtures/monorepos/npm/packages/utils/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/npm/packages/utils/package.json rename to packages/ci/mocks/fixtures/monorepos/npm/packages/utils/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/nx/.gitignore b/packages/ci/mocks/fixtures/monorepos/nx/.gitignore similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/nx/.gitignore rename to packages/ci/mocks/fixtures/monorepos/nx/.gitignore diff --git a/packages/ci/mocks/fixtures/monorepos/tools/nx/nx.json b/packages/ci/mocks/fixtures/monorepos/nx/nx.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/nx/nx.json rename to packages/ci/mocks/fixtures/monorepos/nx/nx.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/nx/package-lock.json b/packages/ci/mocks/fixtures/monorepos/nx/package-lock.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/nx/package-lock.json rename to packages/ci/mocks/fixtures/monorepos/nx/package-lock.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/nx/package.json b/packages/ci/mocks/fixtures/monorepos/nx/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/nx/package.json rename to packages/ci/mocks/fixtures/monorepos/nx/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/nx/packages/cli/project.json b/packages/ci/mocks/fixtures/monorepos/nx/packages/cli/project.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/nx/packages/cli/project.json rename to packages/ci/mocks/fixtures/monorepos/nx/packages/cli/project.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/nx/packages/core/project.json b/packages/ci/mocks/fixtures/monorepos/nx/packages/core/project.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/nx/packages/core/project.json rename to packages/ci/mocks/fixtures/monorepos/nx/packages/core/project.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/nx/packages/utils/project.json b/packages/ci/mocks/fixtures/monorepos/nx/packages/utils/project.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/nx/packages/utils/project.json rename to packages/ci/mocks/fixtures/monorepos/nx/packages/utils/project.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/pnpm/package.json b/packages/ci/mocks/fixtures/monorepos/pnpm/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/pnpm/package.json rename to packages/ci/mocks/fixtures/monorepos/pnpm/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/cli/package.json b/packages/ci/mocks/fixtures/monorepos/pnpm/packages/cli/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/cli/package.json rename to packages/ci/mocks/fixtures/monorepos/pnpm/packages/cli/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/core/package.json b/packages/ci/mocks/fixtures/monorepos/pnpm/packages/core/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/core/package.json rename to packages/ci/mocks/fixtures/monorepos/pnpm/packages/core/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/utils/package.json b/packages/ci/mocks/fixtures/monorepos/pnpm/packages/utils/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/pnpm/packages/utils/package.json rename to packages/ci/mocks/fixtures/monorepos/pnpm/packages/utils/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/pnpm/pnpm-lock.yaml b/packages/ci/mocks/fixtures/monorepos/pnpm/pnpm-lock.yaml similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/pnpm/pnpm-lock.yaml rename to packages/ci/mocks/fixtures/monorepos/pnpm/pnpm-lock.yaml diff --git a/packages/ci/mocks/fixtures/monorepos/tools/pnpm/pnpm-workspace.yaml b/packages/ci/mocks/fixtures/monorepos/pnpm/pnpm-workspace.yaml similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/pnpm/pnpm-workspace.yaml rename to packages/ci/mocks/fixtures/monorepos/pnpm/pnpm-workspace.yaml diff --git a/packages/ci/mocks/fixtures/monorepos/tools/turbo/package.json b/packages/ci/mocks/fixtures/monorepos/turbo/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/turbo/package.json rename to packages/ci/mocks/fixtures/monorepos/turbo/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/cli/package.json b/packages/ci/mocks/fixtures/monorepos/turbo/packages/cli/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/cli/package.json rename to packages/ci/mocks/fixtures/monorepos/turbo/packages/cli/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/core/package.json b/packages/ci/mocks/fixtures/monorepos/turbo/packages/core/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/core/package.json rename to packages/ci/mocks/fixtures/monorepos/turbo/packages/core/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/utils/package.json b/packages/ci/mocks/fixtures/monorepos/turbo/packages/utils/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/turbo/packages/utils/package.json rename to packages/ci/mocks/fixtures/monorepos/turbo/packages/utils/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/turbo/turbo.json b/packages/ci/mocks/fixtures/monorepos/turbo/turbo.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/turbo/turbo.json rename to packages/ci/mocks/fixtures/monorepos/turbo/turbo.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/turbo/yarn.lock b/packages/ci/mocks/fixtures/monorepos/turbo/yarn.lock similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/turbo/yarn.lock rename to packages/ci/mocks/fixtures/monorepos/turbo/yarn.lock diff --git a/packages/ci/mocks/fixtures/monorepos/tools/yarn/package.json b/packages/ci/mocks/fixtures/monorepos/yarn/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/yarn/package.json rename to packages/ci/mocks/fixtures/monorepos/yarn/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/cli/package.json b/packages/ci/mocks/fixtures/monorepos/yarn/packages/cli/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/cli/package.json rename to packages/ci/mocks/fixtures/monorepos/yarn/packages/cli/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/core/package.json b/packages/ci/mocks/fixtures/monorepos/yarn/packages/core/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/core/package.json rename to packages/ci/mocks/fixtures/monorepos/yarn/packages/core/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/utils/package.json b/packages/ci/mocks/fixtures/monorepos/yarn/packages/utils/package.json similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/yarn/packages/utils/package.json rename to packages/ci/mocks/fixtures/monorepos/yarn/packages/utils/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/tools/yarn/yarn.lock b/packages/ci/mocks/fixtures/monorepos/yarn/yarn.lock similarity index 100% rename from packages/ci/mocks/fixtures/monorepos/tools/yarn/yarn.lock rename to packages/ci/mocks/fixtures/monorepos/yarn/yarn.lock diff --git a/packages/ci/src/lib/run.integration.test.ts b/packages/ci/src/lib/run.integration.test.ts index 17612cc9d..4708f75cd 100644 --- a/packages/ci/src/lib/run.integration.test.ts +++ b/packages/ci/src/lib/run.integration.test.ts @@ -381,7 +381,7 @@ describe('runInCI', () => { ['npm', 'npm run code-pushup --'], ])('monorepo mode - %s', (tool, bin) => { beforeEach(async () => { - const monorepoDir = join(fixturesDir, 'monorepos', 'tools', tool); + const monorepoDir = join(fixturesDir, 'monorepos', tool); await cp(monorepoDir, workDir, { recursive: true }); await git.add('.'); await git.commit(`Create packages in ${tool} monorepo`); From 2c6ab2bf8f7b777335c699c4a8dcf05c1af27c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Tue, 10 Dec 2024 10:46:54 +0100 Subject: [PATCH 26/38] refactor(ci): add missing .js extensions in imports --- packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts | 4 ++-- packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts | 4 ++-- packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts | 4 ++-- packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts | 4 ++-- packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts | 4 ++-- packages/ci/src/lib/monorepo/packages.unit.test.ts | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts index 63325f92d..d22e3eae9 100644 --- a/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts @@ -6,8 +6,8 @@ import type { MonorepoHandlerOptions, MonorepoHandlerProjectsContext, ProjectConfig, -} from '../tools'; -import { npmHandler } from './npm'; +} from '../tools.js'; +import { npmHandler } from './npm.js'; describe('npmHandler', () => { const options = { diff --git a/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts index 0b75e0293..4ad65905c 100644 --- a/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts @@ -5,8 +5,8 @@ import type { MonorepoHandlerOptions, MonorepoHandlerProjectsContext, ProjectConfig, -} from '../tools'; -import { nxHandler } from './nx'; +} from '../tools.js'; +import { nxHandler } from './nx.js'; describe('nxHandler', () => { const options: MonorepoHandlerOptions = { diff --git a/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts index b2d25de14..c5f0e0377 100644 --- a/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts @@ -6,8 +6,8 @@ import type { MonorepoHandlerOptions, MonorepoHandlerProjectsContext, ProjectConfig, -} from '../tools'; -import { pnpmHandler } from './pnpm'; +} from '../tools.js'; +import { pnpmHandler } from './pnpm.js'; describe('pnpmHandler', () => { const options = { diff --git a/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts index 6d6e13ccc..ee61fb148 100644 --- a/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts @@ -6,8 +6,8 @@ import type { MonorepoHandlerOptions, MonorepoHandlerProjectsContext, ProjectConfig, -} from '../tools'; -import { turboHandler } from './turbo'; +} from '../tools.js'; +import { turboHandler } from './turbo.js'; describe('turboHandler', () => { const options = { diff --git a/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts index 3f3541945..895ad343d 100644 --- a/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts @@ -7,8 +7,8 @@ import type { MonorepoHandlerOptions, MonorepoHandlerProjectsContext, ProjectConfig, -} from '../tools'; -import { yarnHandler } from './yarn'; +} from '../tools.js'; +import { yarnHandler } from './yarn.js'; describe('yarnHandler', () => { const options = { diff --git a/packages/ci/src/lib/monorepo/packages.unit.test.ts b/packages/ci/src/lib/monorepo/packages.unit.test.ts index 0767a6b09..4f6285f0e 100644 --- a/packages/ci/src/lib/monorepo/packages.unit.test.ts +++ b/packages/ci/src/lib/monorepo/packages.unit.test.ts @@ -10,7 +10,7 @@ import { listPackages, listWorkspaces, readRootPackageJson, -} from './packages'; +} from './packages.js'; const pkgJsonContent = (content: PackageJson) => JSON.stringify(content, null, 2); From 95edb82882d010c1047feb335232e42524798711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Tue, 10 Dec 2024 10:47:21 +0100 Subject: [PATCH 27/38] refactor(ci-e2e): add missing .js extensions in imports --- e2e/ci-e2e/tests/basic.e2e.test.ts | 4 ++-- e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts | 4 ++-- e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/e2e/ci-e2e/tests/basic.e2e.test.ts b/e2e/ci-e2e/tests/basic.e2e.test.ts index c4f0bb302..728b6817f 100644 --- a/e2e/ci-e2e/tests/basic.e2e.test.ts +++ b/e2e/ci-e2e/tests/basic.e2e.test.ts @@ -9,8 +9,8 @@ import { runInCI, } from '@code-pushup/ci'; import { TEST_SNAPSHOTS_DIR } from '@code-pushup/test-utils'; -import { MOCK_API, MOCK_COMMENT } from '../mocks/api'; -import { type TestRepo, setupTestRepo } from '../mocks/setup'; +import { MOCK_API, MOCK_COMMENT } from '../mocks/api.js'; +import { type TestRepo, setupTestRepo } from '../mocks/setup.js'; describe('CI - standalone mode', () => { let repo: TestRepo; diff --git a/e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts b/e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts index 46410189f..519f3fb7a 100644 --- a/e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts +++ b/e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts @@ -11,8 +11,8 @@ import { } from '@code-pushup/ci'; import { TEST_SNAPSHOTS_DIR } from '@code-pushup/test-utils'; import { readJsonFile } from '@code-pushup/utils'; -import { MOCK_API, MOCK_COMMENT } from '../mocks/api'; -import { type TestRepo, setupTestRepo } from '../mocks/setup'; +import { MOCK_API, MOCK_COMMENT } from '../mocks/api.js'; +import { type TestRepo, setupTestRepo } from '../mocks/setup.js'; describe('CI - monorepo mode (npm workspaces)', () => { let repo: TestRepo; diff --git a/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts b/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts index dbb7a33c3..d9320e433 100644 --- a/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts +++ b/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts @@ -11,8 +11,8 @@ import { } from '@code-pushup/ci'; import { TEST_SNAPSHOTS_DIR } from '@code-pushup/test-utils'; import { readJsonFile } from '@code-pushup/utils'; -import { MOCK_API, MOCK_COMMENT } from '../mocks/api'; -import { type TestRepo, setupTestRepo } from '../mocks/setup'; +import { MOCK_API, MOCK_COMMENT } from '../mocks/api.js'; +import { type TestRepo, setupTestRepo } from '../mocks/setup.js'; describe('CI - monorepo mode (Nx)', () => { let repo: TestRepo; From 823ff28f6c44aba8470b92a3da5fa5372da98d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Tue, 10 Dec 2024 13:16:48 +0100 Subject: [PATCH 28/38] test(ci-e2e): fix hanging commands by disabling nx daemon --- e2e/ci-e2e/mocks/fixtures/nx-monorepo/nx.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/nx.json b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/nx.json index 0967ef424..047c7dd4d 100644 --- a/e2e/ci-e2e/mocks/fixtures/nx-monorepo/nx.json +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/nx.json @@ -1 +1,4 @@ -{} +{ + "$schema": "../../../../../node_modules/nx/schemas/nx-schema.json", + "useDaemonProcess": false +} From 839e17b4c25b62c4d829811746afea68cc5c6e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Wed, 11 Dec 2024 13:13:35 +0100 Subject: [PATCH 29/38] refactor(ci): split standalone/monorepo modes into separate functions --- packages/ci/src/lib/run.ts | 102 ++++++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 41 deletions(-) diff --git a/packages/ci/src/lib/run.ts b/packages/ci/src/lib/run.ts index 8ef4f49e8..88a9d1dc2 100644 --- a/packages/ci/src/lib/run.ts +++ b/packages/ci/src/lib/run.ts @@ -43,56 +43,31 @@ import { type ProjectConfig, listMonorepoProjects } from './monorepo/index.js'; * @param git instance of simple-git - useful for testing * @returns result of run (standalone or monorepo) */ -// eslint-disable-next-line max-lines-per-function export async function runInCI( refs: GitRefs, api: ProviderAPIClient, options?: Options, git: SimpleGit = simpleGit(), ): Promise { - const settings: Settings = { ...DEFAULT_SETTINGS, ...options }; - const logger = settings.logger; + const settings: Settings = { + ...DEFAULT_SETTINGS, + ...options, + }; if (settings.monorepo) { - logger.info('Running Code PushUp in monorepo mode'); - const { projects } = await listMonorepoProjects(settings); - const projectResults = await projects.reduce>( - async (acc, project) => [ - ...(await acc), - await runOnProject({ project, settings, refs, api, git }), - ], - Promise.resolve([]), - ); - const diffJsonPaths = projectResults - .map(({ files }) => files.diff?.json) - .filter((file): file is string => file != null); - if (diffJsonPaths.length > 0) { - const tmpDiffPath = await runMergeDiffs( - diffJsonPaths, - createCommandContext(settings, projects[0]), - ); - logger.debug(`Merged ${diffJsonPaths.length} diffs into ${tmpDiffPath}`); - const diffPath = path.join( - settings.directory, - DEFAULT_PERSIST_OUTPUT_DIR, - path.basename(tmpDiffPath), - ); - if (tmpDiffPath !== diffPath) { - await fs.cp(tmpDiffPath, diffPath); - logger.debug(`Copied ${tmpDiffPath} to ${diffPath}`); - } - const commentId = await commentOnPR(tmpDiffPath, api, logger); - return { - mode: 'monorepo', - projects: projectResults, - commentId, - diffPath, - }; - } - return { mode: 'monorepo', projects: projectResults }; + return runInMonorepoMode(refs, api, settings, git); } - logger.info('Running Code PushUp in standalone project mode'); + return runInStandaloneMode(refs, api, settings, git); +} + +async function runInStandaloneMode( + refs: GitRefs, + api: ProviderAPIClient, + settings: Settings, + git: SimpleGit, +): Promise { + settings.logger.info('Running Code PushUp in standalone project mode'); const { files, newIssues } = await runOnProject({ project: null, settings, @@ -102,7 +77,7 @@ export async function runInCI( }); const commentMdPath = files.diff?.md; if (commentMdPath) { - const commentId = await commentOnPR(commentMdPath, api, logger); + const commentId = await commentOnPR(commentMdPath, api, settings.logger); return { mode: 'standalone', files, @@ -113,6 +88,51 @@ export async function runInCI( return { mode: 'standalone', files, newIssues }; } +async function runInMonorepoMode( + refs: GitRefs, + api: ProviderAPIClient, + settings: Settings, + git: SimpleGit, +): Promise { + const { logger, directory } = settings; + logger.info('Running Code PushUp in monorepo mode'); + const { projects } = await listMonorepoProjects(settings); + const projectResults = await projects.reduce>( + async (acc, project) => [ + ...(await acc), + await runOnProject({ project, settings, refs, api, git }), + ], + Promise.resolve([]), + ); + const diffJsonPaths = projectResults + .map(({ files }) => files.diff?.json) + .filter((file): file is string => file != null); + if (diffJsonPaths.length > 0) { + const tmpDiffPath = await runMergeDiffs( + diffJsonPaths, + createCommandContext(settings, projects[0]), + ); + logger.debug(`Merged ${diffJsonPaths.length} diffs into ${tmpDiffPath}`); + const diffPath = path.join( + directory, + DEFAULT_PERSIST_OUTPUT_DIR, + path.basename(tmpDiffPath), + ); + if (tmpDiffPath !== diffPath) { + await fs.cp(tmpDiffPath, diffPath); + logger.debug(`Copied ${tmpDiffPath} to ${diffPath}`); + } + const commentId = await commentOnPR(tmpDiffPath, api, logger); + return { + mode: 'monorepo', + projects: projectResults, + commentId, + diffPath, + }; + } + return { mode: 'monorepo', projects: projectResults }; +} + type RunOnProjectArgs = { project: ProjectConfig | null; refs: GitRefs; From 3638ba67f2dc7f0b08726698192f13c0cc7b6bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Wed, 11 Dec 2024 16:37:55 +0100 Subject: [PATCH 30/38] feat(utils): implement type guard for nullable object props --- packages/plugin-lighthouse/src/lib/types.ts | 4 ++-- packages/utils/src/index.ts | 17 ++++++++------- packages/utils/src/lib/guards.ts | 8 +++++++ packages/utils/src/lib/guards.unit.test.ts | 24 ++++++++++++++++++++- packages/utils/src/lib/types.ts | 4 ++-- 5 files changed, 44 insertions(+), 13 deletions(-) diff --git a/packages/plugin-lighthouse/src/lib/types.ts b/packages/plugin-lighthouse/src/lib/types.ts index c44312aae..30820c1cb 100644 --- a/packages/plugin-lighthouse/src/lib/types.ts +++ b/packages/plugin-lighthouse/src/lib/types.ts @@ -1,7 +1,7 @@ import type { CliFlags } from 'lighthouse'; -import type { ExcludeNullFromPropertyTypes } from '@code-pushup/utils'; +import type { ExcludeNullableProps } from '@code-pushup/utils'; -export type LighthouseOptions = ExcludeNullFromPropertyTypes< +export type LighthouseOptions = ExcludeNullableProps< Partial< Omit< CliFlags, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e53715e75..34e456f39 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -39,13 +39,6 @@ export { truncateText, truncateTitle, } from './lib/formatting.js'; -export { - formatGitPath, - getGitRoot, - guardAgainstLocalChanges, - safeCheckout, - toGitPath, -} from './lib/git/git.js'; export { getCurrentBranchOrTag, getHashFromTag, @@ -54,10 +47,18 @@ export { getSemverTags, type LogResult, } from './lib/git/git.commits-and-tags.js'; +export { + formatGitPath, + getGitRoot, + guardAgainstLocalChanges, + safeCheckout, + toGitPath, +} from './lib/git/git.js'; export { groupByStatus } from './lib/group-by-status.js'; export { isPromiseFulfilledResult, isPromiseRejectedResult, + hasNoNullableProps, } from './lib/guards.js'; export { logMultipleResults } from './lib/log-results.js'; export { link, ui, type CliUi, type Column } from './lib/logging.js'; @@ -114,7 +115,7 @@ export { type CliArgsObject, } from './lib/transform.js'; export type { - ExcludeNullFromPropertyTypes, + ExcludeNullableProps, ExtractArray, ExtractArrays, ItemOrArray, diff --git a/packages/utils/src/lib/guards.ts b/packages/utils/src/lib/guards.ts index ad79d21e9..aca4ceef0 100644 --- a/packages/utils/src/lib/guards.ts +++ b/packages/utils/src/lib/guards.ts @@ -1,3 +1,5 @@ +import type { ExcludeNullableProps } from './types.js'; + export function isPromiseFulfilledResult( result: PromiseSettledResult, ): result is PromiseFulfilledResult { @@ -9,3 +11,9 @@ export function isPromiseRejectedResult( ): result is PromiseRejectedResult { return result.status === 'rejected'; } + +export function hasNoNullableProps( + obj: T, +): obj is ExcludeNullableProps { + return Object.values(obj).every(value => value != null); +} diff --git a/packages/utils/src/lib/guards.unit.test.ts b/packages/utils/src/lib/guards.unit.test.ts index 56ce8e5ed..f4138c8b7 100644 --- a/packages/utils/src/lib/guards.unit.test.ts +++ b/packages/utils/src/lib/guards.unit.test.ts @@ -1,5 +1,9 @@ import { describe } from 'vitest'; -import { isPromiseFulfilledResult, isPromiseRejectedResult } from './guards.js'; +import { + hasNoNullableProps, + isPromiseFulfilledResult, + isPromiseRejectedResult, +} from './guards.js'; describe('promise-result', () => { it('should get fulfilled result', () => { @@ -20,3 +24,21 @@ describe('promise-result', () => { expect(isPromiseRejectedResult(result)).toBe(true); }); }); + +describe('hasNoNullableProps', () => { + it('should return true if object prop values are neither null nor undefined', () => { + expect(hasNoNullableProps({ a: 42, b: 'foo', c: {}, d: [] })).toBe(true); + }); + + it('should return false if some prop is null', () => { + expect(hasNoNullableProps({ x: 42, y: null })).toBe(false); + }); + + it('should return false if some prop is set to undefined', () => { + expect(hasNoNullableProps({ x: undefined })).toBe(false); + }); + + it('should return true for empty object', () => { + expect(hasNoNullableProps({})).toBe(true); + }); +}); diff --git a/packages/utils/src/lib/types.ts b/packages/utils/src/lib/types.ts index 620e33ee7..a32d7b031 100644 --- a/packages/utils/src/lib/types.ts +++ b/packages/utils/src/lib/types.ts @@ -1,5 +1,5 @@ -export type ExcludeNullFromPropertyTypes = { - [P in keyof T]: Exclude; +export type ExcludeNullableProps = { + [P in keyof T]: NonNullable; }; export type ItemOrArray = T | T[]; From b4443ba879e20e16fc78b1210ceb90e30fe1e026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Wed, 11 Dec 2024 17:24:36 +0100 Subject: [PATCH 31/38] feat(ci): implement bulk collecting reports for parallel monorepo runs --- packages/ci/src/lib/constants.ts | 2 +- packages/ci/src/lib/run.integration.test.ts | 58 +-- packages/ci/src/lib/run.ts | 404 ++++++++++++++++---- 3 files changed, 369 insertions(+), 95 deletions(-) diff --git a/packages/ci/src/lib/constants.ts b/packages/ci/src/lib/constants.ts index 081929382..50d1320b7 100644 --- a/packages/ci/src/lib/constants.ts +++ b/packages/ci/src/lib/constants.ts @@ -2,7 +2,7 @@ import type { Settings } from './models.js'; export const DEFAULT_SETTINGS: Settings = { monorepo: false, - parallel: false, // TODO: default to true once battle-tested? + parallel: false, projects: null, task: 'code-pushup', bin: 'npx --no-install code-pushup', diff --git a/packages/ci/src/lib/run.integration.test.ts b/packages/ci/src/lib/run.integration.test.ts index 4708f75cd..064d419c1 100644 --- a/packages/ci/src/lib/run.integration.test.ts +++ b/packages/ci/src/lib/run.integration.test.ts @@ -133,8 +133,21 @@ describe('runInCI', () => { const kind = (await git.branch()).current === 'main' ? 'before' : 'after'; const reports = fixturePaths.reports[kind]; - await copyFile(reports.json, join(outputDir, 'report.json')); - await copyFile(reports.md, join(outputDir, 'report.md')); + if (/workspaces|concurrency|parallel/.test(command)) { + // eslint-disable-next-line functional/no-loop-statements + for (const project of ['cli', 'core', 'utils']) { + const projectOutputDir = join( + workDir, + `packages/${project}/.code-pushup`, + ); + await mkdir(projectOutputDir, { recursive: true }); + await copyFile(reports.json, join(projectOutputDir, 'report.json')); + await copyFile(reports.json, join(projectOutputDir, 'report.md')); + } + } else { + await copyFile(reports.json, join(outputDir, 'report.json')); + await copyFile(reports.md, join(outputDir, 'report.md')); + } break; } @@ -436,22 +449,21 @@ describe('runInCI', () => { ], } satisfies RunResult); - expect(executeProcessSpy.mock.calls.length).toBeGreaterThanOrEqual(6); - expect( - executeProcessSpy.mock.calls.filter(([cfg]) => - cfg.command.includes('code-pushup'), - ), - ).toHaveLength(6); // 3 projects: 1 autorun, 1 print-config + // expect( + // executeProcessSpy.mock.calls.filter(([cfg]) => + // cfg.command.includes('code-pushup'), + // ), + // ).toHaveLength(6); // 3 projects: 1 autorun, 1 print-config expect(utils.executeProcess).toHaveBeenCalledWith({ command: bin, args: ['print-config'], cwd: expect.stringContaining(workDir), } satisfies utils.ProcessConfig); - expect(utils.executeProcess).toHaveBeenCalledWith({ - command: bin, - args: ['--persist.format=json', '--persist.format=md'], - cwd: expect.stringContaining(workDir), - } satisfies utils.ProcessConfig); + // expect(utils.executeProcess).toHaveBeenCalledWith({ + // command: bin, + // args: ['--persist.format=json', '--persist.format=md'], + // cwd: expect.stringContaining(workDir), + // } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); expect(logger.warn).not.toHaveBeenCalled(); @@ -579,21 +591,21 @@ describe('runInCI', () => { // 2 cached projects: 1 autorun, 1 print-config, 1 compare // 1 uncached project: 2 autoruns, 2 print-configs, 1 compare // 1 merge-diffs - expect( - executeProcessSpy.mock.calls.filter(([cfg]) => - cfg.command.includes('code-pushup'), - ), - ).toHaveLength(12); + // expect( + // executeProcessSpy.mock.calls.filter(([cfg]) => + // cfg.command.includes('code-pushup'), + // ), + // ).toHaveLength(12); expect(utils.executeProcess).toHaveBeenCalledWith({ command: bin, args: ['print-config'], cwd: expect.stringContaining(workDir), } satisfies utils.ProcessConfig); - expect(utils.executeProcess).toHaveBeenCalledWith({ - command: bin, - args: ['--persist.format=json', '--persist.format=md'], - cwd: expect.stringContaining(workDir), - } satisfies utils.ProcessConfig); + // expect(utils.executeProcess).toHaveBeenCalledWith({ + // command: bin, + // args: ['--persist.format=json', '--persist.format=md'], + // cwd: expect.stringContaining(workDir), + // } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: bin, args: [ diff --git a/packages/ci/src/lib/run.ts b/packages/ci/src/lib/run.ts index 88a9d1dc2..6a7508a9d 100644 --- a/packages/ci/src/lib/run.ts +++ b/packages/ci/src/lib/run.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import fs from 'node:fs/promises'; import path from 'node:path'; import { type SimpleGit, simpleGit } from 'simple-git'; @@ -7,7 +8,11 @@ import { type Report, type ReportsDiff, } from '@code-pushup/models'; -import { stringifyError } from '@code-pushup/utils'; +import { + type ExcludeNullableProps, + hasNoNullableProps, + stringifyError, +} from '@code-pushup/utils'; import { type CommandContext, createCommandContext, @@ -34,6 +39,7 @@ import type { Settings, } from './models.js'; import { type ProjectConfig, listMonorepoProjects } from './monorepo/index.js'; +import type { MonorepoProjects } from './monorepo/list-projects.js'; /** * Runs Code PushUp in CI environment. @@ -54,30 +60,35 @@ export async function runInCI( ...options, }; + const env: RunEnv = { refs, api, settings, git }; + if (settings.monorepo) { - return runInMonorepoMode(refs, api, settings, git); + return runInMonorepoMode(env); } - return runInStandaloneMode(refs, api, settings, git); + return runInStandaloneMode(env); } -async function runInStandaloneMode( - refs: GitRefs, - api: ProviderAPIClient, - settings: Settings, - git: SimpleGit, -): Promise { - settings.logger.info('Running Code PushUp in standalone project mode'); - const { files, newIssues } = await runOnProject({ - project: null, - settings, +type RunEnv = { + refs: GitRefs; + api: ProviderAPIClient; + settings: Settings; + git: SimpleGit; +}; + +async function runInStandaloneMode(env: RunEnv): Promise { + const { api, - refs, - git, - }); + settings: { logger }, + } = env; + + logger.info('Running Code PushUp in standalone project mode'); + + const { files, newIssues } = await runOnProject(null, env); + const commentMdPath = files.diff?.md; if (commentMdPath) { - const commentId = await commentOnPR(commentMdPath, api, settings.logger); + const commentId = await commentOnPR(commentMdPath, api, logger); return { mode: 'standalone', files, @@ -88,22 +99,17 @@ async function runInStandaloneMode( return { mode: 'standalone', files, newIssues }; } -async function runInMonorepoMode( - refs: GitRefs, - api: ProviderAPIClient, - settings: Settings, - git: SimpleGit, -): Promise { +async function runInMonorepoMode(env: RunEnv): Promise { + const { api, settings } = env; const { logger, directory } = settings; + logger.info('Running Code PushUp in monorepo mode'); - const { projects } = await listMonorepoProjects(settings); - const projectResults = await projects.reduce>( - async (acc, project) => [ - ...(await acc), - await runOnProject({ project, settings, refs, api, git }), - ], - Promise.resolve([]), - ); + + const { projects, runManyCommand } = await listMonorepoProjects(settings); + const projectResults = runManyCommand + ? await runProjectsInBulk(projects, runManyCommand, env) + : await runProjectsIndividually(projects, env); + const diffJsonPaths = projectResults .map(({ files }) => files.diff?.json) .filter((file): file is string => file != null); @@ -130,25 +136,204 @@ async function runInMonorepoMode( diffPath, }; } + return { mode: 'monorepo', projects: projectResults }; } -type RunOnProjectArgs = { - project: ProjectConfig | null; - refs: GitRefs; - api: ProviderAPIClient; - settings: Settings; - git: SimpleGit; -}; +function runProjectsIndividually( + projects: ProjectConfig[], + env: RunEnv, +): Promise { + env.settings.logger.info( + `Running on ${projects.length} projects individually`, + ); + return projects.reduce>( + async (acc, project) => [...(await acc), await runOnProject(project, env)], + Promise.resolve([]), + ); +} // eslint-disable-next-line max-lines-per-function -async function runOnProject(args: RunOnProjectArgs): Promise { +async function runProjectsInBulk( + projects: ProjectConfig[], + runManyCommand: NonNullable, + env: RunEnv, +): Promise { + const { + refs: { base }, + settings, + } = env; + const logger = settings.logger; + + logger.info( + `Running on ${projects.length} projects in bulk (parallel: ${settings.parallel})`, + ); + + await collectMany(runManyCommand, env); + + const currProjectReports = await Promise.all( + projects.map(async project => { + const ctx = createCommandContext(settings, project); + const config = await printPersistConfig(ctx, settings); + const reports = persistedFilesFromConfig(config, ctx); + return { project, reports, config, ctx }; + }), + ); + logger.debug( + `Loaded ${currProjectReports.length} persist configs by running print-config command for each project`, + ); + + if (base == null) { + return currProjectReports.map( + ({ project, reports }): ProjectRunResult => ({ + name: project.name, + files: { report: reports }, + }), + ); + } + + const projectReportsWithCache = await Promise.all( + currProjectReports.map(async ({ project, ctx, reports, config }) => { + const args = { project, base, ctx, env }; + return { + ...args, + config, + currReport: await fs.readFile(reports.json, 'utf8'), + prevReport: await loadCachedBaseReport(args), + }; + }), + ); + const uncachedProjectReports = projectReportsWithCache.filter( + ({ prevReport }) => !prevReport, + ); + logger.info( + `${projects.length - uncachedProjectReports.length} out of ${projects.length} projects loaded previous report from artifact cache`, + ); + + const collectedPrevReports = await collectPreviousReports( + base, + uncachedProjectReports, + runManyCommand, + env, + ); + + const projectsToCompare = projectReportsWithCache + .map(args => ({ + ...args, + prevReport: args.prevReport || collectedPrevReports[args.project.name], + })) + .filter(hasNoNullableProps); + + const projectComparisons = await projectsToCompare.reduce< + Promise> + >( + async (acc, args) => ({ + ...(await acc), + [args.project.name]: await compareReports(args), + }), + Promise.resolve({}), + ); + + return currProjectReports.map(({ project, reports }): ProjectRunResult => { + const comparison = projectComparisons[project.name]; + return { + name: project.name, + files: { + report: reports, + ...(comparison && { diff: comparison.files }), + }, + ...(comparison?.newIssues && { newIssues: comparison.newIssues }), + }; + }); +} + +async function collectPreviousReports( + base: GitBranch, + uncachedProjectReports: ExcludeNullableProps[], + runManyCommand: NonNullable, + env: RunEnv, +): Promise> { + const { + settings: { logger }, + } = env; + + if (uncachedProjectReports.length === 0) { + return {}; + } + + return runInBaseBranch(base, env, async () => { + const uncachedProjectConfigs = await Promise.all( + uncachedProjectReports.map(async args => ({ + name: args.project.name, + ctx: args.ctx, + config: await checkPrintConfig(args), + })), + ); + + const validProjectConfigs = + uncachedProjectConfigs.filter(hasNoNullableProps); + const onlyProjects = validProjectConfigs.map(({ name }) => name); + const invalidProjects = uncachedProjectConfigs + .map(({ name }) => name) + .filter(name => !onlyProjects.includes(name)); + if (invalidProjects.length > 0) { + logger.debug( + `Printing config failed for ${invalidProjects.length} projects - ${invalidProjects.join(', ')}`, + ); + logger.info( + `Skipping ${invalidProjects.length} projects which aren't configured in base branch ${base.ref}`, + ); + } + + if (onlyProjects.length > 0) { + logger.info( + `Collecting previous reports for ${onlyProjects.length} projects`, + ); + await collectMany(runManyCommand, env, onlyProjects); + } + + const projectFiles = validProjectConfigs.map( + async ({ name, ctx, config }) => + [ + name, + await fs.readFile(persistedFilesFromConfig(config, ctx).json, 'utf8'), + ] as const, + ); + + return Object.fromEntries(await Promise.all(projectFiles)); + }); +} + +async function collectMany( + runManyCommand: NonNullable, + env: RunEnv, + onlyProjects?: string[], +): Promise { + const { settings } = env; + const command = await runManyCommand(onlyProjects); + const ctx: CommandContext = { + ...createCommandContext(settings, null), + bin: command, + }; + + await runCollect(ctx); + + const countText = onlyProjects + ? `${onlyProjects.length} previous` + : 'all current'; + settings.logger.debug( + `Collected ${countText} reports using command \`${command}\``, + ); +} + +async function runOnProject( + project: ProjectConfig | null, + env: RunEnv, +): Promise { const { - project, refs: { head, base }, settings, - git, - } = args; + } = env; const logger = settings.logger; const ctx = createCommandContext(settings, project); @@ -182,11 +367,53 @@ async function runOnProject(args: RunOnProjectArgs): Promise { `PR/MR detected, preparing to compare base branch ${base.ref} to head ${head.ref}`, ); - const prevReport = await collectPreviousReport({ ...args, base, ctx }); + const prevReport = await collectPreviousReport({ project, env, base, ctx }); if (!prevReport) { return noDiffOutput; } + const compareArgs = { project, env, base, config, currReport, prevReport }; + const { files: diffFiles, newIssues } = await compareReports(compareArgs); + + return { + ...noDiffOutput, + files: { + ...noDiffOutput.files, + diff: diffFiles, + }, + ...(newIssues && { newIssues }), + }; +} + +type CompareReportsArgs = { + project: ProjectConfig | null; + env: RunEnv; + base: GitBranch; + currReport: string; + prevReport: string; + config: Pick; +}; + +type CompareReportsResult = { + files: OutputFiles; + newIssues?: SourceFileIssue[]; +}; + +async function compareReports( + args: CompareReportsArgs, +): Promise { + const { + project, + env: { settings, git }, + base, + currReport, + prevReport, + config, + } = args; + const logger = settings.logger; + + const ctx = createCommandContext(settings, project); + const reportsDir = path.join(settings.directory, '.code-pushup'); const currPath = path.join(reportsDir, 'curr-report.json'); const prevPath = path.join(reportsDir, 'prev-report.json'); @@ -208,16 +435,8 @@ async function runOnProject(args: RunOnProjectArgs): Promise { `Generated diff files at ${comparisonFiles.json} and ${comparisonFiles.md}`, ); - const diffOutput = { - ...noDiffOutput, - files: { - ...noDiffOutput.files, - diff: comparisonFiles, - }, - } satisfies ProjectRunResult; - if (!settings.detectNewIssues) { - return diffOutput; + return { files: comparisonFiles }; } const newIssues = await findNewIssues({ @@ -229,19 +448,50 @@ async function runOnProject(args: RunOnProjectArgs): Promise { git, }); - return { ...diffOutput, newIssues }; + return { files: comparisonFiles, newIssues }; } -type CollectPreviousReportArgs = RunOnProjectArgs & { +type BaseReportArgs = { + project: ProjectConfig | null; + env: RunEnv; base: GitBranch; ctx: CommandContext; }; async function collectPreviousReport( - args: CollectPreviousReportArgs, + args: BaseReportArgs, ): Promise { - const { project, base, api, settings, ctx, git } = args; - const logger = settings.logger; + const { ctx, env, base } = args; + + const cachedBaseReport = await loadCachedBaseReport(args); + if (cachedBaseReport) { + return cachedBaseReport; + } + + return runInBaseBranch(base, env, async () => { + const config = await checkPrintConfig(args); + if (!config) { + return null; + } + + await runCollect(ctx); + const { json: prevReportPath } = persistedFilesFromConfig(config, ctx); + const prevReport = await fs.readFile(prevReportPath, 'utf8'); + env.settings.logger.debug(`Collected previous report at ${prevReportPath}`); + return prevReport; + }); +} + +async function loadCachedBaseReport( + args: BaseReportArgs, +): Promise { + const { + project, + env: { + api, + settings: { logger }, + }, + } = args; const cachedBaseReport = await api .downloadReportArtifact?.(project?.name) @@ -264,43 +514,55 @@ async function collectPreviousReport( if (cachedBaseReport) { return fs.readFile(cachedBaseReport, 'utf8'); } + return null; +} + +async function runInBaseBranch( + base: GitBranch, + env: RunEnv, + fn: () => Promise, +): Promise { + const { + git, + settings: { logger }, + } = env; await git.fetch('origin', base.ref, ['--depth=1']); await git.checkout(['-f', base.ref]); logger.info(`Switched to base branch ${base.ref}`); - const config = await checkPrintConfig(args); - if (!config) { - return null; - } - - await runCollect(ctx); - const { json: prevReportPath } = persistedFilesFromConfig(config, ctx); - const prevReport = await fs.readFile(prevReportPath, 'utf8'); - logger.debug(`Collected previous report at ${prevReportPath}`); + const result = await fn(); await git.checkout(['-f', '-']); logger.info('Switched back to PR/MR branch'); - return prevReport; + return result; } async function checkPrintConfig( - args: CollectPreviousReportArgs, + args: BaseReportArgs, ): Promise | null> { - const { ctx, base, settings } = args; + const { + project, + ctx, + base, + env: { settings }, + } = args; const { logger } = settings; + const operation = project + ? `Executing print-config for project ${project.name}` + : 'Executing print-config'; try { const config = await printPersistConfig(ctx, settings); logger.debug( - `Executing print-config verified code-pushup installed in base branch ${base.ref}`, + `${operation} verified code-pushup installed in base branch ${base.ref}`, ); return config; } catch (error) { logger.debug(`Error from print-config - ${stringifyError(error)}`); logger.info( - `Executing print-config failed, assuming code-pushup not installed in base branch ${base.ref} and skipping comparison`, + `${operation} failed, assuming code-pushup not installed in base branch ${base.ref} and skipping comparison`, ); return null; } From cc34b209c5e175cefcdc239bffa2b7918ee791ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 13 Dec 2024 09:31:17 +0100 Subject: [PATCH 32/38] test(ci): assert autorun calls in monorepo mode --- packages/ci/src/lib/run.integration.test.ts | 130 ++++++++++++++------ 1 file changed, 95 insertions(+), 35 deletions(-) diff --git a/packages/ci/src/lib/run.integration.test.ts b/packages/ci/src/lib/run.integration.test.ts index 064d419c1..a6eb67534 100644 --- a/packages/ci/src/lib/run.integration.test.ts +++ b/packages/ci/src/lib/run.integration.test.ts @@ -89,6 +89,8 @@ describe('runInCI', () => { Promise >; + let yarnVersion: string; + async function simulateCodePushUpExecution({ command, args, @@ -162,6 +164,13 @@ describe('runInCI', () => { if (cfg.command.includes('code-pushup')) { return simulateCodePushUpExecution(cfg); } + if (cfg.command === 'yarn' && cfg.args![0] === '-v') { + return Promise.resolve({ + code: 0, + stdout: yarnVersion, + stderr: '', + } as utils.ProcessResult); + } return originalExecuteProcess(cfg); }); @@ -386,18 +395,65 @@ describe('runInCI', () => { }); }); - describe.each<[MonorepoTool, string]>([ - ['nx', expect.stringMatching(/^npx nx run \w+:code-pushup --$/)], - ['turbo', 'npx turbo run code-pushup --'], - ['pnpm', 'pnpm run code-pushup'], - ['yarn', 'yarn run code-pushup'], - ['npm', 'npm run code-pushup --'], - ])('monorepo mode - %s', (tool, bin) => { + describe.each<{ + name: string; + tool: MonorepoTool; + run: string; + runMany: string; + setup?: () => void; + }>([ + { + name: 'Nx', + tool: 'nx', + run: expect.stringMatching( + /^npx nx run (cli|core|utils):code-pushup --$/, + ), + runMany: + 'npx nx run-many --targets=code-pushup --parallel=false --projects=cli,core,utils --', + }, + { + name: 'Turborepo', + tool: 'turbo', + run: 'npx turbo run code-pushup --', + runMany: 'npx turbo run code-pushup --concurrency=1 --', + }, + { + name: 'pnpm workspace', + tool: 'pnpm', + run: 'pnpm run code-pushup', + runMany: 'pnpm --recursive --workspace-concurrency=1 code-pushup', + }, + { + name: 'Yarn workspaces (modern)', + tool: 'yarn', + run: 'yarn run code-pushup', + runMany: 'yarn workspaces foreach --all code-pushup', + setup: () => { + yarnVersion = '2.0.0'; + }, + }, + { + name: 'Yarn workspaces (classic)', + tool: 'yarn', + run: 'yarn run code-pushup', + runMany: 'yarn workspaces run code-pushup', + setup: () => { + yarnVersion = '1.0.0'; + }, + }, + { + name: 'npm workspaces', + tool: 'npm', + run: 'npm run code-pushup --', + runMany: 'npm run code-pushup --workspaces --if-present --', + }, + ])('monorepo mode - $name', ({ tool, run, runMany, setup }) => { beforeEach(async () => { const monorepoDir = join(fixturesDir, 'monorepos', tool); await cp(monorepoDir, workDir, { recursive: true }); await git.add('.'); await git.commit(`Create packages in ${tool} monorepo`); + setup?.(); }); describe('push event', () => { @@ -449,21 +505,21 @@ describe('runInCI', () => { ], } satisfies RunResult); - // expect( - // executeProcessSpy.mock.calls.filter(([cfg]) => - // cfg.command.includes('code-pushup'), - // ), - // ).toHaveLength(6); // 3 projects: 1 autorun, 1 print-config + expect( + executeProcessSpy.mock.calls.filter(([cfg]) => + cfg.command.includes('code-pushup'), + ), + ).toHaveLength(4); // 1 autorun for all projects, 3 print-configs for each project expect(utils.executeProcess).toHaveBeenCalledWith({ - command: bin, + command: run, args: ['print-config'], cwd: expect.stringContaining(workDir), } satisfies utils.ProcessConfig); - // expect(utils.executeProcess).toHaveBeenCalledWith({ - // command: bin, - // args: ['--persist.format=json', '--persist.format=md'], - // cwd: expect.stringContaining(workDir), - // } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: runMany, + args: ['--persist.format=json', '--persist.format=md'], + cwd: expect.stringContaining(workDir), + } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); expect(logger.warn).not.toHaveBeenCalled(); @@ -502,7 +558,8 @@ describe('runInCI', () => { // simulates a project which has no cached report return null; } - const downloadPath = join(workDir, 'downloaded-report.json'); + const downloadPath = join(workDir, 'tmp', project, 'report.json'); + await mkdir(dirname(downloadPath), { recursive: true }); await copyFile(fixturePaths.reports.before.json, downloadPath); return downloadPath; }), @@ -588,26 +645,29 @@ describe('runInCI', () => { ); expect(api.updateComment).not.toHaveBeenCalled(); - // 2 cached projects: 1 autorun, 1 print-config, 1 compare - // 1 uncached project: 2 autoruns, 2 print-configs, 1 compare - // 1 merge-diffs - // expect( - // executeProcessSpy.mock.calls.filter(([cfg]) => - // cfg.command.includes('code-pushup'), - // ), - // ).toHaveLength(12); + // 1 autorun for all projects + // 3 print-configs for each project + // 1 print-config for uncached project + // 1 autorun for uncached projects + // 3 compares for each project + // 1 merge-diffs for all projects + expect( + executeProcessSpy.mock.calls.filter(([cfg]) => + cfg.command.includes('code-pushup'), + ), + ).toHaveLength(10); expect(utils.executeProcess).toHaveBeenCalledWith({ - command: bin, + command: run, args: ['print-config'], cwd: expect.stringContaining(workDir), } satisfies utils.ProcessConfig); - // expect(utils.executeProcess).toHaveBeenCalledWith({ - // command: bin, - // args: ['--persist.format=json', '--persist.format=md'], - // cwd: expect.stringContaining(workDir), - // } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ - command: bin, + command: runMany, + args: ['--persist.format=json', '--persist.format=md'], + cwd: expect.stringContaining(workDir), + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: run, args: [ 'compare', expect.stringMatching(/^--before=.*prev-report.json$/), @@ -619,7 +679,7 @@ describe('runInCI', () => { cwd: expect.stringContaining(workDir), } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ - command: bin, + command: run, args: [ 'merge-diffs', `--files=${join(workDir, 'packages/cli/.code-pushup/report-diff.json')}`, From 258d27c7b26319828872a191d9f16b25b164894b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 13 Dec 2024 09:52:15 +0100 Subject: [PATCH 33/38] test(ci-e2e): include parallel in Nx monorepo test --- e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts b/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts index d9320e433..0654d4d4f 100644 --- a/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts +++ b/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts @@ -25,6 +25,7 @@ describe('CI - monorepo mode (Nx)', () => { options = { monorepo: true, directory: repo.baseDir, + parallel: true, silent: true, // comment out for debugging }; }); From 23519374c23eba2d1dc126924757c2e880b4477b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 13 Dec 2024 10:23:11 +0100 Subject: [PATCH 34/38] test(ci): add integration tests for custom monorepo setups --- .../monorepos/custom/backend/api/package.json | 6 + .../custom/backend/auth/package.json | 6 + .../monorepos/custom/frontend/package.json | 6 + packages/ci/src/lib/run.integration.test.ts | 245 ++++++++++++++++++ packages/ci/src/lib/run.ts | 2 +- 5 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 packages/ci/mocks/fixtures/monorepos/custom/backend/api/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/custom/backend/auth/package.json create mode 100644 packages/ci/mocks/fixtures/monorepos/custom/frontend/package.json diff --git a/packages/ci/mocks/fixtures/monorepos/custom/backend/api/package.json b/packages/ci/mocks/fixtures/monorepos/custom/backend/api/package.json new file mode 100644 index 000000000..1641db03a --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/custom/backend/api/package.json @@ -0,0 +1,6 @@ +{ + "name": "api", + "devDependencies": { + "@code-pushup/cli": "latest" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/custom/backend/auth/package.json b/packages/ci/mocks/fixtures/monorepos/custom/backend/auth/package.json new file mode 100644 index 000000000..cbb4e6e65 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/custom/backend/auth/package.json @@ -0,0 +1,6 @@ +{ + "name": "auth", + "devDependencies": { + "@code-pushup/cli": "latest" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/custom/frontend/package.json b/packages/ci/mocks/fixtures/monorepos/custom/frontend/package.json new file mode 100644 index 000000000..d9ca7fcfb --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/custom/frontend/package.json @@ -0,0 +1,6 @@ +{ + "name": "frontend", + "devDependencies": { + "@code-pushup/cli": "latest" + } +} diff --git a/packages/ci/src/lib/run.integration.test.ts b/packages/ci/src/lib/run.integration.test.ts index a6eb67534..f7579d490 100644 --- a/packages/ci/src/lib/run.integration.test.ts +++ b/packages/ci/src/lib/run.integration.test.ts @@ -698,4 +698,249 @@ describe('runInCI', () => { }); }); }); + + describe.each<[string, Options]>([ + [ + 'projects explicitly configured using folder patterns', + { + monorepo: true, + projects: ['frontend', 'backend/*'], + }, + ], + [ + 'projects implicitly determined by package.json files', + { + monorepo: true, + }, + ], + ])('monorepo mode - custom: %s', (_, monorepoOptions) => { + beforeEach(async () => { + const monorepoDir = join(fixturesDir, 'monorepos', 'custom'); + await cp(monorepoDir, workDir, { recursive: true }); + await git.add('.'); + await git.commit('Create projects in monorepo'); + }); + + describe('push event', () => { + beforeEach(async () => { + await git.checkout('main'); + }); + + it('should collect reports for all projects', async () => { + await expect( + runInCI( + { head: { ref: 'main', sha: await git.revparse('main') } }, + {} as ProviderAPIClient, + { ...options, ...monorepoOptions }, + git, + ), + ).resolves.toEqual({ + mode: 'monorepo', + projects: [ + { + name: expect.stringContaining('api'), + files: { + report: { + json: join(workDir, 'backend/api/.code-pushup/report.json'), + md: join(workDir, 'backend/api/.code-pushup/report.md'), + }, + }, + }, + { + name: expect.stringContaining('auth'), + files: { + report: { + json: join(workDir, 'backend/auth/.code-pushup/report.json'), + md: join(workDir, 'backend/auth/.code-pushup/report.md'), + }, + }, + }, + { + name: 'frontend', + files: { + report: { + json: join(workDir, 'frontend/.code-pushup/report.json'), + md: join(workDir, 'frontend/.code-pushup/report.md'), + }, + }, + }, + ], + } satisfies RunResult); + + expect( + executeProcessSpy.mock.calls.filter(([cfg]) => + cfg.command.includes('code-pushup'), + ), + ).toHaveLength(6); // 3 autoruns and 3 print-configs for each project + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: options.bin, + args: ['print-config'], + cwd: expect.stringContaining(workDir), + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: options.bin, + args: ['--persist.format=json', '--persist.format=md'], + cwd: expect.stringContaining(workDir), + } satisfies utils.ProcessConfig); + + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalled(); + }); + }); + + describe('pull request event', () => { + let refs: GitRefs; + let diffMdString: string; + + beforeEach(async () => { + await git.checkoutLocalBranch('feature-1'); + + await writeFile(join(workDir, 'README.md'), '# Hello, world\n'); + await git.add('README.md'); + await git.commit('Create README'); + + refs = { + head: { ref: 'feature-1', sha: await git.revparse('feature-1') }, + base: { ref: 'main', sha: await git.revparse('main') }, + }; + + diffMdString = await readFile(fixturePaths.diffs.merged.md, 'utf8'); + }); + + it('should collect and compare reports for all projects and comment merged diff', async () => { + const api: ProviderAPIClient = { + maxCommentChars: 1_000_000, + createComment: vi.fn().mockResolvedValue(mockComment), + updateComment: vi.fn(), + listComments: vi.fn().mockResolvedValue([]), + downloadReportArtifact: vi.fn().mockImplementation(async project => { + const downloadPath = join(workDir, 'tmp', project, 'report.json'); + await mkdir(dirname(downloadPath), { recursive: true }); + await copyFile(fixturePaths.reports.before.json, downloadPath); + return downloadPath; + }), + }; + + await expect( + runInCI(refs, api, { ...options, ...monorepoOptions }, git), + ).resolves.toEqual({ + mode: 'monorepo', + commentId: mockComment.id, + diffPath: join(workDir, '.code-pushup/merged-report-diff.md'), + projects: [ + { + name: expect.stringContaining('api'), + files: { + report: { + json: join(workDir, 'backend/api/.code-pushup/report.json'), + md: join(workDir, 'backend/api/.code-pushup/report.md'), + }, + diff: { + json: join( + workDir, + 'backend/api/.code-pushup/report-diff.json', + ), + md: join(workDir, 'backend/api/.code-pushup/report-diff.md'), + }, + }, + newIssues: [], + }, + { + name: expect.stringContaining('auth'), + files: { + report: { + json: join(workDir, 'backend/auth/.code-pushup/report.json'), + md: join(workDir, 'backend/auth/.code-pushup/report.md'), + }, + diff: { + json: join( + workDir, + 'backend/auth/.code-pushup/report-diff.json', + ), + md: join(workDir, 'backend/auth/.code-pushup/report-diff.md'), + }, + }, + newIssues: [], + }, + { + name: 'frontend', + files: { + report: { + json: join(workDir, 'frontend/.code-pushup/report.json'), + md: join(workDir, 'frontend/.code-pushup/report.md'), + }, + diff: { + json: join(workDir, 'frontend/.code-pushup/report-diff.json'), + md: join(workDir, 'frontend/.code-pushup/report-diff.md'), + }, + }, + newIssues: [], + }, + ], + } satisfies RunResult); + + await expect( + readFile(join(workDir, '.code-pushup/merged-report-diff.md'), 'utf8'), + ).resolves.toBe(diffMdString); + + expect(api.listComments).toHaveBeenCalledWith(); + expect(api.createComment).toHaveBeenCalledWith( + expect.stringContaining(diffMdString), + ); + expect(api.updateComment).not.toHaveBeenCalled(); + + // 3 autoruns for each project + // 3 print-configs for each project + // 3 compares for each project + // 0 autoruns and print-configs for uncached projects + // 1 merge-diffs for all projects + expect( + executeProcessSpy.mock.calls.filter(([cfg]) => + cfg.command.includes('code-pushup'), + ), + ).toHaveLength(10); + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: options.bin, + args: ['print-config'], + cwd: expect.stringContaining(workDir), + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: options.bin, + args: ['--persist.format=json', '--persist.format=md'], + cwd: expect.stringContaining(workDir), + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: options.bin, + args: [ + 'compare', + expect.stringMatching(/^--before=.*prev-report.json$/), + expect.stringMatching(/^--after=.*curr-report.json$/), + expect.stringMatching(/^--label=\w+$/), + '--persist.format=json', + '--persist.format=md', + ], + cwd: expect.stringContaining(workDir), + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: options.bin, + args: [ + 'merge-diffs', + `--files=${join(workDir, 'backend/api/.code-pushup/report-diff.json')}`, + `--files=${join(workDir, 'backend/auth/.code-pushup/report-diff.json')}`, + `--files=${join(workDir, 'frontend/.code-pushup/report-diff.json')}`, + expect.stringMatching(/^--persist.outputDir=.*\.code-pushup$/), + '--persist.filename=merged-report', + ], + cwd: expect.stringContaining(workDir), + } satisfies utils.ProcessConfig); + + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalled(); + }); + }); + }); }); diff --git a/packages/ci/src/lib/run.ts b/packages/ci/src/lib/run.ts index 6a7508a9d..d8a2bcceb 100644 --- a/packages/ci/src/lib/run.ts +++ b/packages/ci/src/lib/run.ts @@ -125,7 +125,7 @@ async function runInMonorepoMode(env: RunEnv): Promise { path.basename(tmpDiffPath), ); if (tmpDiffPath !== diffPath) { - await fs.cp(tmpDiffPath, diffPath); + await fs.copyFile(tmpDiffPath, diffPath); logger.debug(`Copied ${tmpDiffPath} to ${diffPath}`); } const commentId = await commentOnPR(tmpDiffPath, api, logger); From 491379ebf32aa6758198417eb212c15422a4bdb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 13 Dec 2024 11:50:51 +0100 Subject: [PATCH 35/38] refactor(ci): split up overlarge run.ts and runProjectsInBulk --- packages/ci/src/lib/monorepo/index.ts | 4 +- packages/ci/src/lib/monorepo/list-projects.ts | 6 +- packages/ci/src/lib/run-monorepo.ts | 286 +++++++++ packages/ci/src/lib/run-standalone.ts | 28 + packages/ci/src/lib/run-utils.ts | 314 ++++++++++ packages/ci/src/lib/run.ts | 574 +----------------- 6 files changed, 638 insertions(+), 574 deletions(-) create mode 100644 packages/ci/src/lib/run-monorepo.ts create mode 100644 packages/ci/src/lib/run-standalone.ts create mode 100644 packages/ci/src/lib/run-utils.ts diff --git a/packages/ci/src/lib/monorepo/index.ts b/packages/ci/src/lib/monorepo/index.ts index 58db97767..2a36e8579 100644 --- a/packages/ci/src/lib/monorepo/index.ts +++ b/packages/ci/src/lib/monorepo/index.ts @@ -1,7 +1,7 @@ -export { listMonorepoProjects } from './list-projects.js'; +export { listMonorepoProjects, type RunManyCommand } from './list-projects.js'; export { - MONOREPO_TOOLS, isMonorepoTool, + MONOREPO_TOOLS, type MonorepoTool, type ProjectConfig, } from './tools.js'; diff --git a/packages/ci/src/lib/monorepo/list-projects.ts b/packages/ci/src/lib/monorepo/list-projects.ts index 2647188c9..437c738ec 100644 --- a/packages/ci/src/lib/monorepo/list-projects.ts +++ b/packages/ci/src/lib/monorepo/list-projects.ts @@ -13,9 +13,13 @@ import type { export type MonorepoProjects = { tool: MonorepoTool | null; projects: ProjectConfig[]; - runManyCommand?: (onlyProjects?: string[]) => string | Promise; + runManyCommand?: RunManyCommand; }; +export type RunManyCommand = ( + onlyProjects?: string[], +) => string | Promise; + export async function listMonorepoProjects( settings: Settings, ): Promise { diff --git a/packages/ci/src/lib/run-monorepo.ts b/packages/ci/src/lib/run-monorepo.ts new file mode 100644 index 000000000..eb3a7b37c --- /dev/null +++ b/packages/ci/src/lib/run-monorepo.ts @@ -0,0 +1,286 @@ +import { copyFile, readFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import { + type CoreConfig, + DEFAULT_PERSIST_OUTPUT_DIR, +} from '@code-pushup/models'; +import { + type ExcludeNullableProps, + hasNoNullableProps, +} from '@code-pushup/utils'; +import { + type CommandContext, + createCommandContext, + persistedFilesFromConfig, + runCollect, + runMergeDiffs, +} from './cli/index.js'; +import { commentOnPR } from './comment.js'; +import type { + GitBranch, + MonorepoRunResult, + OutputFiles, + ProjectRunResult, +} from './models.js'; +import { + type ProjectConfig, + type RunManyCommand, + listMonorepoProjects, +} from './monorepo/index.js'; +import { + type BaseReportArgs, + type CompareReportsResult, + type RunEnv, + checkPrintConfig, + compareReports, + loadCachedBaseReport, + printPersistConfig, + runInBaseBranch, + runOnProject, +} from './run-utils.js'; + +export async function runInMonorepoMode( + env: RunEnv, +): Promise { + const { api, settings } = env; + const { logger, directory } = settings; + + logger.info('Running Code PushUp in monorepo mode'); + + const { projects, runManyCommand } = await listMonorepoProjects(settings); + const projectResults = runManyCommand + ? await runProjectsInBulk(projects, runManyCommand, env) + : await runProjectsIndividually(projects, env); + + const diffJsonPaths = projectResults + .map(({ files }) => files.diff?.json) + .filter((file): file is string => file != null); + if (diffJsonPaths.length > 0) { + const tmpDiffPath = await runMergeDiffs( + diffJsonPaths, + createCommandContext(settings, projects[0]), + ); + logger.debug(`Merged ${diffJsonPaths.length} diffs into ${tmpDiffPath}`); + const diffPath = join( + directory, + DEFAULT_PERSIST_OUTPUT_DIR, + basename(tmpDiffPath), + ); + if (tmpDiffPath !== diffPath) { + await copyFile(tmpDiffPath, diffPath); + logger.debug(`Copied ${tmpDiffPath} to ${diffPath}`); + } + const commentId = await commentOnPR(tmpDiffPath, api, logger); + return { + mode: 'monorepo', + projects: projectResults, + commentId, + diffPath, + }; + } + + return { mode: 'monorepo', projects: projectResults }; +} + +type ProjectReport = { + project: ProjectConfig; + reports: OutputFiles; + config: Pick; + ctx: CommandContext; +}; + +function runProjectsIndividually( + projects: ProjectConfig[], + env: RunEnv, +): Promise { + env.settings.logger.info( + `Running on ${projects.length} projects individually`, + ); + return projects.reduce>( + async (acc, project) => [...(await acc), await runOnProject(project, env)], + Promise.resolve([]), + ); +} + +async function runProjectsInBulk( + projects: ProjectConfig[], + runManyCommand: RunManyCommand, + env: RunEnv, +): Promise { + const { + refs: { base }, + settings, + } = env; + const logger = settings.logger; + + logger.info( + `Running on ${projects.length} projects in bulk (parallel: ${settings.parallel})`, + ); + + await collectMany(runManyCommand, env); + + const currProjectReports = await Promise.all( + projects.map(async (project): Promise => { + const ctx = createCommandContext(settings, project); + const config = await printPersistConfig(ctx, settings); + const reports = persistedFilesFromConfig(config, ctx); + return { project, reports, config, ctx }; + }), + ); + logger.debug( + `Loaded ${currProjectReports.length} persist configs by running print-config command for each project`, + ); + + if (base == null) { + return finalizeProjectReports(currProjectReports); + } + + return compareProjectsInBulk(currProjectReports, base, runManyCommand, env); +} + +async function compareProjectsInBulk( + currProjectReports: ProjectReport[], + base: GitBranch, + runManyCommand: RunManyCommand, + env: RunEnv, +): Promise { + const projectReportsWithCache = await Promise.all( + currProjectReports.map(async ({ project, ctx, reports, config }) => { + const args = { project, base, ctx, env }; + return { + ...args, + config, + currReport: await readFile(reports.json, 'utf8'), + prevReport: await loadCachedBaseReport(args), + }; + }), + ); + const uncachedProjectReports = projectReportsWithCache.filter( + ({ prevReport }) => !prevReport, + ); + env.settings.logger.info( + `${currProjectReports.length - uncachedProjectReports.length} out of ${currProjectReports.length} projects loaded previous report from artifact cache`, + ); + + const collectedPrevReports = await collectPreviousReports( + base, + uncachedProjectReports, + runManyCommand, + env, + ); + + const projectsToCompare = projectReportsWithCache + .map(args => ({ + ...args, + prevReport: args.prevReport || collectedPrevReports[args.project.name], + })) + .filter(hasNoNullableProps); + + const projectComparisons = await projectsToCompare.reduce< + Promise> + >( + async (acc, args) => ({ + ...(await acc), + [args.project.name]: await compareReports(args), + }), + Promise.resolve({}), + ); + + return finalizeProjectReports(currProjectReports, projectComparisons); +} + +function finalizeProjectReports( + projectReports: ProjectReport[], + projectComparisons?: Record, +): ProjectRunResult[] { + return projectReports.map(({ project, reports }): ProjectRunResult => { + const comparison = projectComparisons?.[project.name]; + return { + name: project.name, + files: { + report: reports, + ...(comparison && { diff: comparison.files }), + }, + ...(comparison?.newIssues && { newIssues: comparison.newIssues }), + }; + }); +} + +async function collectPreviousReports( + base: GitBranch, + uncachedProjectReports: ExcludeNullableProps[], + runManyCommand: RunManyCommand, + env: RunEnv, +): Promise> { + const { + settings: { logger }, + } = env; + + if (uncachedProjectReports.length === 0) { + return {}; + } + + return runInBaseBranch(base, env, async () => { + const uncachedProjectConfigs = await Promise.all( + uncachedProjectReports.map(async args => ({ + name: args.project.name, + ctx: args.ctx, + config: await checkPrintConfig(args), + })), + ); + + const validProjectConfigs = + uncachedProjectConfigs.filter(hasNoNullableProps); + const onlyProjects = validProjectConfigs.map(({ name }) => name); + const invalidProjects = uncachedProjectConfigs + .map(({ name }) => name) + .filter(name => !onlyProjects.includes(name)); + if (invalidProjects.length > 0) { + logger.debug( + `Printing config failed for ${invalidProjects.length} projects - ${invalidProjects.join(', ')}`, + ); + logger.info( + `Skipping ${invalidProjects.length} projects which aren't configured in base branch ${base.ref}`, + ); + } + + if (onlyProjects.length > 0) { + logger.info( + `Collecting previous reports for ${onlyProjects.length} projects`, + ); + await collectMany(runManyCommand, env, onlyProjects); + } + + const projectFiles = validProjectConfigs.map( + async ({ name, ctx, config }) => + [ + name, + await readFile(persistedFilesFromConfig(config, ctx).json, 'utf8'), + ] as const, + ); + + return Object.fromEntries(await Promise.all(projectFiles)); + }); +} + +async function collectMany( + runManyCommand: RunManyCommand, + env: RunEnv, + onlyProjects?: string[], +): Promise { + const { settings } = env; + const command = await runManyCommand(onlyProjects); + const ctx: CommandContext = { + ...createCommandContext(settings, null), + bin: command, + }; + + await runCollect(ctx); + + const countText = onlyProjects + ? `${onlyProjects.length} previous` + : 'all current'; + settings.logger.debug( + `Collected ${countText} reports using command \`${command}\``, + ); +} diff --git a/packages/ci/src/lib/run-standalone.ts b/packages/ci/src/lib/run-standalone.ts new file mode 100644 index 000000000..7d6a96a99 --- /dev/null +++ b/packages/ci/src/lib/run-standalone.ts @@ -0,0 +1,28 @@ +import { commentOnPR } from './comment.js'; +import type { StandaloneRunResult } from './models.js'; +import { type RunEnv, runOnProject } from './run-utils.js'; + +export async function runInStandaloneMode( + env: RunEnv, +): Promise { + const { + api, + settings: { logger }, + } = env; + + logger.info('Running Code PushUp in standalone project mode'); + + const { files, newIssues } = await runOnProject(null, env); + + const commentMdPath = files.diff?.md; + if (commentMdPath) { + const commentId = await commentOnPR(commentMdPath, api, logger); + return { + mode: 'standalone', + files, + commentId, + newIssues, + }; + } + return { mode: 'standalone', files, newIssues }; +} diff --git a/packages/ci/src/lib/run-utils.ts b/packages/ci/src/lib/run-utils.ts new file mode 100644 index 000000000..196df1d2c --- /dev/null +++ b/packages/ci/src/lib/run-utils.ts @@ -0,0 +1,314 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { SimpleGit } from 'simple-git'; +import type { CoreConfig, Report, ReportsDiff } from '@code-pushup/models'; +import { stringifyError } from '@code-pushup/utils'; +import { + type CommandContext, + createCommandContext, + persistedFilesFromConfig, + runCollect, + runCompare, + runPrintConfig, +} from './cli/index.js'; +import { parsePersistConfig } from './cli/persist.js'; +import { listChangedFiles } from './git.js'; +import { type SourceFileIssue, filterRelevantIssues } from './issues.js'; +import type { + GitBranch, + GitRefs, + Logger, + OutputFiles, + ProjectRunResult, + ProviderAPIClient, + Settings, +} from './models.js'; +import type { ProjectConfig } from './monorepo/index.js'; + +export type RunEnv = { + refs: GitRefs; + api: ProviderAPIClient; + settings: Settings; + git: SimpleGit; +}; + +export type CompareReportsArgs = { + project: ProjectConfig | null; + env: RunEnv; + base: GitBranch; + currReport: string; + prevReport: string; + config: Pick; +}; + +export type CompareReportsResult = { + files: OutputFiles; + newIssues?: SourceFileIssue[]; +}; + +export type BaseReportArgs = { + project: ProjectConfig | null; + env: RunEnv; + base: GitBranch; + ctx: CommandContext; +}; + +export async function runOnProject( + project: ProjectConfig | null, + env: RunEnv, +): Promise { + const { + refs: { head, base }, + settings, + } = env; + const logger = settings.logger; + + const ctx = createCommandContext(settings, project); + + if (project) { + logger.info(`Running Code PushUp on monorepo project ${project.name}`); + } + + const config = await printPersistConfig(ctx, settings); + logger.debug( + `Loaded persist config from print-config command - ${JSON.stringify(config.persist)}`, + ); + + await runCollect(ctx); + const reportFiles = persistedFilesFromConfig(config, ctx); + const currReport = await readFile(reportFiles.json, 'utf8'); + logger.debug(`Collected current report at ${reportFiles.json}`); + + const noDiffOutput = { + name: project?.name ?? '-', + files: { + report: reportFiles, + }, + } satisfies ProjectRunResult; + + if (base == null) { + return noDiffOutput; + } + + logger.info( + `PR/MR detected, preparing to compare base branch ${base.ref} to head ${head.ref}`, + ); + + const prevReport = await collectPreviousReport({ project, env, base, ctx }); + if (!prevReport) { + return noDiffOutput; + } + + const compareArgs = { project, env, base, config, currReport, prevReport }; + const { files: diffFiles, newIssues } = await compareReports(compareArgs); + + return { + ...noDiffOutput, + files: { + ...noDiffOutput.files, + diff: diffFiles, + }, + ...(newIssues && { newIssues }), + }; +} + +export async function compareReports( + args: CompareReportsArgs, +): Promise { + const { + project, + env: { settings, git }, + base, + currReport, + prevReport, + config, + } = args; + const logger = settings.logger; + + const ctx = createCommandContext(settings, project); + + const reportsDir = join(settings.directory, '.code-pushup'); + const currPath = join(reportsDir, 'curr-report.json'); + const prevPath = join(reportsDir, 'prev-report.json'); + await mkdir(reportsDir, { recursive: true }); + await writeFile(currPath, currReport); + await writeFile(prevPath, prevReport); + logger.debug(`Saved reports to ${currPath} and ${prevPath}`); + + await runCompare( + { before: prevPath, after: currPath, label: project?.name }, + ctx, + ); + const comparisonFiles = persistedFilesFromConfig(config, { + directory: ctx.directory, + isDiff: true, + }); + logger.info('Compared reports and generated diff files'); + logger.debug( + `Generated diff files at ${comparisonFiles.json} and ${comparisonFiles.md}`, + ); + + if (!settings.detectNewIssues) { + return { files: comparisonFiles }; + } + + const newIssues = await findNewIssues({ + base, + currReport, + prevReport, + comparisonFiles, + logger, + git, + }); + + return { files: comparisonFiles, newIssues }; +} + +export async function collectPreviousReport( + args: BaseReportArgs, +): Promise { + const { ctx, env, base } = args; + + const cachedBaseReport = await loadCachedBaseReport(args); + if (cachedBaseReport) { + return cachedBaseReport; + } + + return runInBaseBranch(base, env, async () => { + const config = await checkPrintConfig(args); + if (!config) { + return null; + } + + await runCollect(ctx); + const { json: prevReportPath } = persistedFilesFromConfig(config, ctx); + const prevReport = await readFile(prevReportPath, 'utf8'); + env.settings.logger.debug(`Collected previous report at ${prevReportPath}`); + return prevReport; + }); +} + +export async function loadCachedBaseReport( + args: BaseReportArgs, +): Promise { + const { + project, + env: { + api, + settings: { logger }, + }, + } = args; + + const cachedBaseReport = await api + .downloadReportArtifact?.(project?.name) + .catch((error: unknown) => { + logger.warn( + `Error when downloading previous report artifact, skipping - ${stringifyError(error)}`, + ); + }); + if (api.downloadReportArtifact != null) { + logger.info( + `Previous report artifact ${cachedBaseReport ? 'found' : 'not found'}`, + ); + if (cachedBaseReport) { + logger.debug( + `Previous report artifact downloaded to ${cachedBaseReport}`, + ); + } + } + + if (cachedBaseReport) { + return readFile(cachedBaseReport, 'utf8'); + } + return null; +} + +export async function runInBaseBranch( + base: GitBranch, + env: RunEnv, + fn: () => Promise, +): Promise { + const { + git, + settings: { logger }, + } = env; + + await git.fetch('origin', base.ref, ['--depth=1']); + await git.checkout(['-f', base.ref]); + logger.info(`Switched to base branch ${base.ref}`); + + const result = await fn(); + + await git.checkout(['-f', '-']); + logger.info('Switched back to PR/MR branch'); + + return result; +} + +export async function checkPrintConfig( + args: BaseReportArgs, +): Promise | null> { + const { + project, + ctx, + base, + env: { settings }, + } = args; + const { logger } = settings; + + const operation = project + ? `Executing print-config for project ${project.name}` + : 'Executing print-config'; + try { + const config = await printPersistConfig(ctx, settings); + logger.debug( + `${operation} verified code-pushup installed in base branch ${base.ref}`, + ); + return config; + } catch (error) { + logger.debug(`Error from print-config - ${stringifyError(error)}`); + logger.info( + `${operation} failed, assuming code-pushup not installed in base branch ${base.ref} and skipping comparison`, + ); + return null; + } +} + +export async function printPersistConfig( + ctx: CommandContext, + settings: Settings, +): Promise> { + const json = await runPrintConfig({ ...ctx, silent: !settings.debug }); + return parsePersistConfig(json); +} + +export async function findNewIssues(args: { + base: GitBranch; + currReport: string; + prevReport: string; + comparisonFiles: OutputFiles; + logger: Logger; + git: SimpleGit; +}): Promise { + const { base, currReport, prevReport, comparisonFiles, logger, git } = args; + + await git.fetch('origin', base.ref, ['--depth=1']); + const reportsDiff = await readFile(comparisonFiles.json, 'utf8'); + const changedFiles = await listChangedFiles( + { base: 'FETCH_HEAD', head: 'HEAD' }, + git, + ); + const issues = filterRelevantIssues({ + currReport: JSON.parse(currReport) as Report, + prevReport: JSON.parse(prevReport) as Report, + reportsDiff: JSON.parse(reportsDiff) as ReportsDiff, + changedFiles, + }); + logger.debug( + `Found ${issues.length} relevant issues for ${ + Object.keys(changedFiles).length + } changed files`, + ); + + return issues; +} diff --git a/packages/ci/src/lib/run.ts b/packages/ci/src/lib/run.ts index d8a2bcceb..49473a1db 100644 --- a/packages/ci/src/lib/run.ts +++ b/packages/ci/src/lib/run.ts @@ -1,45 +1,15 @@ -/* eslint-disable max-lines */ -import fs from 'node:fs/promises'; -import path from 'node:path'; import { type SimpleGit, simpleGit } from 'simple-git'; -import { - type CoreConfig, - DEFAULT_PERSIST_OUTPUT_DIR, - type Report, - type ReportsDiff, -} from '@code-pushup/models'; -import { - type ExcludeNullableProps, - hasNoNullableProps, - stringifyError, -} from '@code-pushup/utils'; -import { - type CommandContext, - createCommandContext, - persistedFilesFromConfig, - runCollect, - runCompare, - runMergeDiffs, - runPrintConfig, -} from './cli/index.js'; -import { parsePersistConfig } from './cli/persist.js'; -import { commentOnPR } from './comment.js'; import { DEFAULT_SETTINGS } from './constants.js'; -import { listChangedFiles } from './git.js'; -import { type SourceFileIssue, filterRelevantIssues } from './issues.js'; import type { - GitBranch, GitRefs, - Logger, Options, - OutputFiles, - ProjectRunResult, ProviderAPIClient, RunResult, Settings, } from './models.js'; -import { type ProjectConfig, listMonorepoProjects } from './monorepo/index.js'; -import type { MonorepoProjects } from './monorepo/list-projects.js'; +import { runInMonorepoMode } from './run-monorepo.js'; +import { runInStandaloneMode } from './run-standalone.js'; +import type { RunEnv } from './run-utils.js'; /** * Runs Code PushUp in CI environment. @@ -68,541 +38,3 @@ export async function runInCI( return runInStandaloneMode(env); } - -type RunEnv = { - refs: GitRefs; - api: ProviderAPIClient; - settings: Settings; - git: SimpleGit; -}; - -async function runInStandaloneMode(env: RunEnv): Promise { - const { - api, - settings: { logger }, - } = env; - - logger.info('Running Code PushUp in standalone project mode'); - - const { files, newIssues } = await runOnProject(null, env); - - const commentMdPath = files.diff?.md; - if (commentMdPath) { - const commentId = await commentOnPR(commentMdPath, api, logger); - return { - mode: 'standalone', - files, - commentId, - newIssues, - }; - } - return { mode: 'standalone', files, newIssues }; -} - -async function runInMonorepoMode(env: RunEnv): Promise { - const { api, settings } = env; - const { logger, directory } = settings; - - logger.info('Running Code PushUp in monorepo mode'); - - const { projects, runManyCommand } = await listMonorepoProjects(settings); - const projectResults = runManyCommand - ? await runProjectsInBulk(projects, runManyCommand, env) - : await runProjectsIndividually(projects, env); - - const diffJsonPaths = projectResults - .map(({ files }) => files.diff?.json) - .filter((file): file is string => file != null); - if (diffJsonPaths.length > 0) { - const tmpDiffPath = await runMergeDiffs( - diffJsonPaths, - createCommandContext(settings, projects[0]), - ); - logger.debug(`Merged ${diffJsonPaths.length} diffs into ${tmpDiffPath}`); - const diffPath = path.join( - directory, - DEFAULT_PERSIST_OUTPUT_DIR, - path.basename(tmpDiffPath), - ); - if (tmpDiffPath !== diffPath) { - await fs.copyFile(tmpDiffPath, diffPath); - logger.debug(`Copied ${tmpDiffPath} to ${diffPath}`); - } - const commentId = await commentOnPR(tmpDiffPath, api, logger); - return { - mode: 'monorepo', - projects: projectResults, - commentId, - diffPath, - }; - } - - return { mode: 'monorepo', projects: projectResults }; -} - -function runProjectsIndividually( - projects: ProjectConfig[], - env: RunEnv, -): Promise { - env.settings.logger.info( - `Running on ${projects.length} projects individually`, - ); - return projects.reduce>( - async (acc, project) => [...(await acc), await runOnProject(project, env)], - Promise.resolve([]), - ); -} - -// eslint-disable-next-line max-lines-per-function -async function runProjectsInBulk( - projects: ProjectConfig[], - runManyCommand: NonNullable, - env: RunEnv, -): Promise { - const { - refs: { base }, - settings, - } = env; - const logger = settings.logger; - - logger.info( - `Running on ${projects.length} projects in bulk (parallel: ${settings.parallel})`, - ); - - await collectMany(runManyCommand, env); - - const currProjectReports = await Promise.all( - projects.map(async project => { - const ctx = createCommandContext(settings, project); - const config = await printPersistConfig(ctx, settings); - const reports = persistedFilesFromConfig(config, ctx); - return { project, reports, config, ctx }; - }), - ); - logger.debug( - `Loaded ${currProjectReports.length} persist configs by running print-config command for each project`, - ); - - if (base == null) { - return currProjectReports.map( - ({ project, reports }): ProjectRunResult => ({ - name: project.name, - files: { report: reports }, - }), - ); - } - - const projectReportsWithCache = await Promise.all( - currProjectReports.map(async ({ project, ctx, reports, config }) => { - const args = { project, base, ctx, env }; - return { - ...args, - config, - currReport: await fs.readFile(reports.json, 'utf8'), - prevReport: await loadCachedBaseReport(args), - }; - }), - ); - const uncachedProjectReports = projectReportsWithCache.filter( - ({ prevReport }) => !prevReport, - ); - logger.info( - `${projects.length - uncachedProjectReports.length} out of ${projects.length} projects loaded previous report from artifact cache`, - ); - - const collectedPrevReports = await collectPreviousReports( - base, - uncachedProjectReports, - runManyCommand, - env, - ); - - const projectsToCompare = projectReportsWithCache - .map(args => ({ - ...args, - prevReport: args.prevReport || collectedPrevReports[args.project.name], - })) - .filter(hasNoNullableProps); - - const projectComparisons = await projectsToCompare.reduce< - Promise> - >( - async (acc, args) => ({ - ...(await acc), - [args.project.name]: await compareReports(args), - }), - Promise.resolve({}), - ); - - return currProjectReports.map(({ project, reports }): ProjectRunResult => { - const comparison = projectComparisons[project.name]; - return { - name: project.name, - files: { - report: reports, - ...(comparison && { diff: comparison.files }), - }, - ...(comparison?.newIssues && { newIssues: comparison.newIssues }), - }; - }); -} - -async function collectPreviousReports( - base: GitBranch, - uncachedProjectReports: ExcludeNullableProps[], - runManyCommand: NonNullable, - env: RunEnv, -): Promise> { - const { - settings: { logger }, - } = env; - - if (uncachedProjectReports.length === 0) { - return {}; - } - - return runInBaseBranch(base, env, async () => { - const uncachedProjectConfigs = await Promise.all( - uncachedProjectReports.map(async args => ({ - name: args.project.name, - ctx: args.ctx, - config: await checkPrintConfig(args), - })), - ); - - const validProjectConfigs = - uncachedProjectConfigs.filter(hasNoNullableProps); - const onlyProjects = validProjectConfigs.map(({ name }) => name); - const invalidProjects = uncachedProjectConfigs - .map(({ name }) => name) - .filter(name => !onlyProjects.includes(name)); - if (invalidProjects.length > 0) { - logger.debug( - `Printing config failed for ${invalidProjects.length} projects - ${invalidProjects.join(', ')}`, - ); - logger.info( - `Skipping ${invalidProjects.length} projects which aren't configured in base branch ${base.ref}`, - ); - } - - if (onlyProjects.length > 0) { - logger.info( - `Collecting previous reports for ${onlyProjects.length} projects`, - ); - await collectMany(runManyCommand, env, onlyProjects); - } - - const projectFiles = validProjectConfigs.map( - async ({ name, ctx, config }) => - [ - name, - await fs.readFile(persistedFilesFromConfig(config, ctx).json, 'utf8'), - ] as const, - ); - - return Object.fromEntries(await Promise.all(projectFiles)); - }); -} - -async function collectMany( - runManyCommand: NonNullable, - env: RunEnv, - onlyProjects?: string[], -): Promise { - const { settings } = env; - const command = await runManyCommand(onlyProjects); - const ctx: CommandContext = { - ...createCommandContext(settings, null), - bin: command, - }; - - await runCollect(ctx); - - const countText = onlyProjects - ? `${onlyProjects.length} previous` - : 'all current'; - settings.logger.debug( - `Collected ${countText} reports using command \`${command}\``, - ); -} - -async function runOnProject( - project: ProjectConfig | null, - env: RunEnv, -): Promise { - const { - refs: { head, base }, - settings, - } = env; - const logger = settings.logger; - - const ctx = createCommandContext(settings, project); - - if (project) { - logger.info(`Running Code PushUp on monorepo project ${project.name}`); - } - - const config = await printPersistConfig(ctx, settings); - logger.debug( - `Loaded persist config from print-config command - ${JSON.stringify(config.persist)}`, - ); - - await runCollect(ctx); - const reportFiles = persistedFilesFromConfig(config, ctx); - const currReport = await fs.readFile(reportFiles.json, 'utf8'); - logger.debug(`Collected current report at ${reportFiles.json}`); - - const noDiffOutput = { - name: project?.name ?? '-', - files: { - report: reportFiles, - }, - } satisfies ProjectRunResult; - - if (base == null) { - return noDiffOutput; - } - - logger.info( - `PR/MR detected, preparing to compare base branch ${base.ref} to head ${head.ref}`, - ); - - const prevReport = await collectPreviousReport({ project, env, base, ctx }); - if (!prevReport) { - return noDiffOutput; - } - - const compareArgs = { project, env, base, config, currReport, prevReport }; - const { files: diffFiles, newIssues } = await compareReports(compareArgs); - - return { - ...noDiffOutput, - files: { - ...noDiffOutput.files, - diff: diffFiles, - }, - ...(newIssues && { newIssues }), - }; -} - -type CompareReportsArgs = { - project: ProjectConfig | null; - env: RunEnv; - base: GitBranch; - currReport: string; - prevReport: string; - config: Pick; -}; - -type CompareReportsResult = { - files: OutputFiles; - newIssues?: SourceFileIssue[]; -}; - -async function compareReports( - args: CompareReportsArgs, -): Promise { - const { - project, - env: { settings, git }, - base, - currReport, - prevReport, - config, - } = args; - const logger = settings.logger; - - const ctx = createCommandContext(settings, project); - - const reportsDir = path.join(settings.directory, '.code-pushup'); - const currPath = path.join(reportsDir, 'curr-report.json'); - const prevPath = path.join(reportsDir, 'prev-report.json'); - await fs.mkdir(reportsDir, { recursive: true }); - await fs.writeFile(currPath, currReport); - await fs.writeFile(prevPath, prevReport); - logger.debug(`Saved reports to ${currPath} and ${prevPath}`); - - await runCompare( - { before: prevPath, after: currPath, label: project?.name }, - ctx, - ); - const comparisonFiles = persistedFilesFromConfig(config, { - directory: ctx.directory, - isDiff: true, - }); - logger.info('Compared reports and generated diff files'); - logger.debug( - `Generated diff files at ${comparisonFiles.json} and ${comparisonFiles.md}`, - ); - - if (!settings.detectNewIssues) { - return { files: comparisonFiles }; - } - - const newIssues = await findNewIssues({ - base, - currReport, - prevReport, - comparisonFiles, - logger, - git, - }); - - return { files: comparisonFiles, newIssues }; -} - -type BaseReportArgs = { - project: ProjectConfig | null; - env: RunEnv; - base: GitBranch; - ctx: CommandContext; -}; - -async function collectPreviousReport( - args: BaseReportArgs, -): Promise { - const { ctx, env, base } = args; - - const cachedBaseReport = await loadCachedBaseReport(args); - if (cachedBaseReport) { - return cachedBaseReport; - } - - return runInBaseBranch(base, env, async () => { - const config = await checkPrintConfig(args); - if (!config) { - return null; - } - - await runCollect(ctx); - const { json: prevReportPath } = persistedFilesFromConfig(config, ctx); - const prevReport = await fs.readFile(prevReportPath, 'utf8'); - env.settings.logger.debug(`Collected previous report at ${prevReportPath}`); - return prevReport; - }); -} - -async function loadCachedBaseReport( - args: BaseReportArgs, -): Promise { - const { - project, - env: { - api, - settings: { logger }, - }, - } = args; - - const cachedBaseReport = await api - .downloadReportArtifact?.(project?.name) - .catch((error: unknown) => { - logger.warn( - `Error when downloading previous report artifact, skipping - ${stringifyError(error)}`, - ); - }); - if (api.downloadReportArtifact != null) { - logger.info( - `Previous report artifact ${cachedBaseReport ? 'found' : 'not found'}`, - ); - if (cachedBaseReport) { - logger.debug( - `Previous report artifact downloaded to ${cachedBaseReport}`, - ); - } - } - - if (cachedBaseReport) { - return fs.readFile(cachedBaseReport, 'utf8'); - } - return null; -} - -async function runInBaseBranch( - base: GitBranch, - env: RunEnv, - fn: () => Promise, -): Promise { - const { - git, - settings: { logger }, - } = env; - - await git.fetch('origin', base.ref, ['--depth=1']); - await git.checkout(['-f', base.ref]); - logger.info(`Switched to base branch ${base.ref}`); - - const result = await fn(); - - await git.checkout(['-f', '-']); - logger.info('Switched back to PR/MR branch'); - - return result; -} - -async function checkPrintConfig( - args: BaseReportArgs, -): Promise | null> { - const { - project, - ctx, - base, - env: { settings }, - } = args; - const { logger } = settings; - - const operation = project - ? `Executing print-config for project ${project.name}` - : 'Executing print-config'; - try { - const config = await printPersistConfig(ctx, settings); - logger.debug( - `${operation} verified code-pushup installed in base branch ${base.ref}`, - ); - return config; - } catch (error) { - logger.debug(`Error from print-config - ${stringifyError(error)}`); - logger.info( - `${operation} failed, assuming code-pushup not installed in base branch ${base.ref} and skipping comparison`, - ); - return null; - } -} - -async function printPersistConfig( - ctx: CommandContext, - settings: Settings, -): Promise> { - const json = await runPrintConfig({ ...ctx, silent: !settings.debug }); - return parsePersistConfig(json); -} - -async function findNewIssues(args: { - base: GitBranch; - currReport: string; - prevReport: string; - comparisonFiles: OutputFiles; - logger: Logger; - git: SimpleGit; -}): Promise { - const { base, currReport, prevReport, comparisonFiles, logger, git } = args; - - await git.fetch('origin', base.ref, ['--depth=1']); - const reportsDiff = await fs.readFile(comparisonFiles.json, 'utf8'); - const changedFiles = await listChangedFiles( - { base: 'FETCH_HEAD', head: 'HEAD' }, - git, - ); - const issues = filterRelevantIssues({ - currReport: JSON.parse(currReport) as Report, - prevReport: JSON.parse(prevReport) as Report, - reportsDiff: JSON.parse(reportsDiff) as ReportsDiff, - changedFiles, - }); - logger.debug( - `Found ${issues.length} relevant issues for ${ - Object.keys(changedFiles).length - } changed files`, - ); - - return issues; -} From 2b23bb57ee3305f72380fdfa1b070a97105620b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 13 Dec 2024 13:10:44 +0100 Subject: [PATCH 36/38] refactor(ci): avoid hardcoded default concurrency for turborepo and pnpm --- packages/ci/src/lib/monorepo/handlers/pnpm.ts | 12 ++++++------ .../ci/src/lib/monorepo/handlers/pnpm.unit.test.ts | 4 ++-- packages/ci/src/lib/monorepo/handlers/turbo.ts | 10 ++++------ .../ci/src/lib/monorepo/handlers/turbo.unit.test.ts | 4 ++-- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/ci/src/lib/monorepo/handlers/pnpm.ts b/packages/ci/src/lib/monorepo/handlers/pnpm.ts index b7dbe69fe..0947c9467 100644 --- a/packages/ci/src/lib/monorepo/handlers/pnpm.ts +++ b/packages/ci/src/lib/monorepo/handlers/pnpm.ts @@ -11,9 +11,6 @@ import type { MonorepoToolHandler } from '../tools.js'; const WORKSPACE_FILE = 'pnpm-workspace.yaml'; -// https://pnpm.io/cli/recursive#--workspace-concurrency -const DEFAULT_WORKSPACE_CONCURRENCY = 4; - export const pnpmHandler: MonorepoToolHandler = { tool: 'pnpm', @@ -46,16 +43,19 @@ export const pnpmHandler: MonorepoToolHandler = { }, createRunManyCommand(options, projects) { - const workspaceConcurrency: number = + // https://pnpm.io/cli/recursive#--workspace-concurrency + const workspaceConcurrency: number | null = options.parallel === true - ? DEFAULT_WORKSPACE_CONCURRENCY + ? null : options.parallel === false ? 1 : options.parallel; return [ 'pnpm', '--recursive', - `--workspace-concurrency=${workspaceConcurrency}`, + ...(workspaceConcurrency == null + ? [] + : [`--workspace-concurrency=${workspaceConcurrency}`]), ...(projects.only?.map(project => `--filter=${project}`) ?? []), options.task, ].join(' '); diff --git a/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts index c5f0e0377..124ce6acf 100644 --- a/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts @@ -198,13 +198,13 @@ describe('pnpmHandler', () => { ); }); - it('should set parallel flag with default number of jobs', () => { + it('should leave default concurrency if parallel flag is true', () => { expect( pnpmHandler.createRunManyCommand( { ...options, parallel: true }, projects, ), - ).toBe('pnpm --recursive --workspace-concurrency=4 code-pushup'); + ).toBe('pnpm --recursive code-pushup'); }); it('should set parallel flag with custom number of jobs', () => { diff --git a/packages/ci/src/lib/monorepo/handlers/turbo.ts b/packages/ci/src/lib/monorepo/handlers/turbo.ts index 9cbcc39a8..c3c56c8a3 100644 --- a/packages/ci/src/lib/monorepo/handlers/turbo.ts +++ b/packages/ci/src/lib/monorepo/handlers/turbo.ts @@ -7,9 +7,6 @@ import { yarnHandler } from './yarn.js'; const WORKSPACE_HANDLERS = [pnpmHandler, yarnHandler, npmHandler]; -// https://turbo.build/repo/docs/reference/run#--concurrency-number--percentage -const DEFAULT_CONCURRENCY = 10; - type TurboConfig = { tasks: Record; }; @@ -47,9 +44,10 @@ export const turboHandler: MonorepoToolHandler = { }, createRunManyCommand(options, projects) { - const concurrency: number = + // https://turbo.build/repo/docs/reference/run#--concurrency-number--percentage + const concurrency: number | null = options.parallel === true - ? DEFAULT_CONCURRENCY + ? null : options.parallel === false ? 1 : options.parallel; @@ -59,7 +57,7 @@ export const turboHandler: MonorepoToolHandler = { 'run', options.task, ...(projects.only?.map(project => `--filter=${project}`) ?? []), - `--concurrency=${concurrency}`, + ...(concurrency == null ? [] : [`--concurrency=${concurrency}`]), '--', ].join(' '); }, diff --git a/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts index ee61fb148..cc0546a12 100644 --- a/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts @@ -191,13 +191,13 @@ describe('turboHandler', () => { ); }); - it('should set parallel flag with default number of jobs', () => { + it('should leave default concurrency if parallel flag is true', () => { expect( turboHandler.createRunManyCommand( { ...options, parallel: true }, projects, ), - ).toBe('npx turbo run code-pushup --concurrency=10 --'); + ).toBe('npx turbo run code-pushup --'); }); it('should set parallel flag with custom number of jobs', () => { From 191bc55a27d9172cd9057499e0c37e054619b366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 13 Dec 2024 14:21:10 +0100 Subject: [PATCH 37/38] test(ci): fix failing path matched on Windows --- packages/ci/src/lib/monorepo/packages.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ci/src/lib/monorepo/packages.unit.test.ts b/packages/ci/src/lib/monorepo/packages.unit.test.ts index 4f6285f0e..0e42538d3 100644 --- a/packages/ci/src/lib/monorepo/packages.unit.test.ts +++ b/packages/ci/src/lib/monorepo/packages.unit.test.ts @@ -35,7 +35,7 @@ describe('listPackages', () => { }, { name: 'example-monorepo', - directory: MEMFS_VOLUME, + directory: join(MEMFS_VOLUME), packageJson: { name: 'example-monorepo' }, }, { From 527eb55a1be3334ac7fa509e5296cd62e9c4eada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 13 Dec 2024 14:43:35 +0100 Subject: [PATCH 38/38] ci(ci): fix code-pushup coverage error for missing tsconfig in nx tests --- packages/ci/mocks/fixtures/monorepos/nx/tsconfig.base.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/ci/mocks/fixtures/monorepos/nx/tsconfig.base.json diff --git a/packages/ci/mocks/fixtures/monorepos/nx/tsconfig.base.json b/packages/ci/mocks/fixtures/monorepos/nx/tsconfig.base.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/nx/tsconfig.base.json @@ -0,0 +1 @@ +{}