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/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/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/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..047c7dd4d --- /dev/null +++ b/e2e/ci-e2e/mocks/fixtures/nx-monorepo/nx.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../../../../node_modules/nx/schemas/nx-schema.json", + "useDaemonProcess": false +} diff --git a/e2e/ci-e2e/mocks/setup.ts b/e2e/ci-e2e/mocks/setup.ts new file mode 100644 index 000000000..1e40bae25 --- /dev/null +++ b/e2e/ci-e2e/mocks/setup.ts @@ -0,0 +1,43 @@ +import { cp } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +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>; + +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, + ); + + 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, + cleanup: () => teardownTestFolder(baseDir), + }; +} 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__/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/__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 new file mode 100644 index 000000000..728b6817f --- /dev/null +++ b/e2e/ci-e2e/tests/basic.e2e.test.ts @@ -0,0 +1,128 @@ +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 RunResult, + runInCI, +} from '@code-pushup/ci'; +import { TEST_SNAPSHOTS_DIR } from '@code-pushup/test-utils'; +import { MOCK_API, MOCK_COMMENT } from '../mocks/api.js'; +import { type TestRepo, setupTestRepo } from '../mocks/setup.js'; + +describe('CI - standalone mode', () => { + let repo: TestRepo; + let git: SimpleGit; + let options: Options; + + beforeEach(async () => { + repo = await setupTestRepo('basic'); + git = repo.git; + options = { + directory: repo.baseDir, + silent: true, // comment out for debugging + }; + }); + + afterEach(async () => { + await repo.cleanup(); + }); + + 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') } }, + MOCK_API, + options, + git, + ), + ).resolves.toEqual({ + mode: 'standalone', + files: { + report: { + json: join(repo.baseDir, '.code-pushup/report.json'), + md: join(repo.baseDir, '.code-pushup/report.md'), + }, + }, + } satisfies RunResult); + + 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( + expect.objectContaining({ + plugins: [ + expect.objectContaining({ + slug: 'ts-migration', + audits: [ + expect.objectContaining({ + score: 0.5, + displayValue: '50% converted', + }), + ], + }), + ], + }), + ); + }); + }); + + describe('pull request event', () => { + let refs: GitRefs; + + beforeEach(async () => { + await git.checkoutLocalBranch('feature-1'); + + await rename( + join(repo.baseDir, 'index.js'), + join(repo.baseDir, 'index.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') }, + }; + }); + + it('should compare reports', async () => { + await expect(runInCI(refs, MOCK_API, options, git)).resolves.toEqual({ + mode: 'standalone', + commentId: MOCK_COMMENT.id, + newIssues: [], + files: { + report: { + json: join(repo.baseDir, '.code-pushup/report.json'), + md: join(repo.baseDir, '.code-pushup/report.md'), + }, + diff: { + 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.baseDir, '.code-pushup/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, 'basic-report-diff.md')); + }); + }); +}); diff --git a/e2e/ci-e2e/tests/ci.e2e.test.ts b/e2e/ci-e2e/tests/ci.e2e.test.ts deleted file mode 100644 index 96d85f9ac..000000000 --- a/e2e/ci-e2e/tests/ci.e2e.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -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 { afterEach } from 'vitest'; -import { - type Comment, - type GitRefs, - type Options, - type ProviderAPIClient, - 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'; - -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 git: SimpleGit; - - beforeEach(async () => { - await cp(fixturesDir, ciSetupRepoDir, { recursive: true }); - - 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'); - }); - - afterEach(async () => { - await teardownTestFolder(ciSetupRepoDir); - }); - - afterAll(async () => { - await teardownTestFolder(ciSetupRepoDir); - }); - - 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', - artifacts: { - report: { - rootDir: outputDir, - files: [ - join(outputDir, 'report.json'), - join(outputDir, 'report.md'), - ], - }, - }, - } satisfies RunResult); - - const jsonPromise = readFile(join(outputDir, 'report.json'), 'utf8'); - await expect(jsonPromise).resolves.toBeTruthy(); - const report = JSON.parse(await jsonPromise) as Report; - expect(report).toEqual( - expect.objectContaining({ - plugins: [ - expect.objectContaining({ - slug: 'ts-migration', - audits: [ - expect.objectContaining({ - score: 0.5, - displayValue: '50% converted', - }), - ], - }), - ], - }), - ); - }); - }); - - 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 () => { - await git.checkoutLocalBranch('feature-1'); - - await rename( - join(ciSetupRepoDir, 'index.js'), - join(ciSetupRepoDir, 'index.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') }, - }; - }); - - it('should compare reports', async () => { - await expect(runInCI(refs, api, options, git)).resolves.toEqual({ - mode: 'standalone', - commentId: comment.id, - newIssues: [], - artifacts: { - report: { - rootDir: outputDir, - files: [ - join(outputDir, 'report.json'), - join(outputDir, 'report.md'), - ], - }, - diff: { - rootDir: outputDir, - files: [ - join(outputDir, 'report-diff.json'), - join(outputDir, 'report-diff.md'), - ], - }, - }, - } satisfies RunResult); - - const mdPromise = readFile(join(outputDir, '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, 'report-diff.md')); - }); - }); -}); 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..519f3fb7a --- /dev/null +++ b/e2e/ci-e2e/tests/npm-workspaces.e2e.test.ts @@ -0,0 +1,161 @@ +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.js'; +import { type TestRepo, setupTestRepo } from '../mocks/setup.js'; + +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, + silent: true, // comment out for debugging + }; + }); + + 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'), + ); + }); + }); +}); 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..0654d4d4f --- /dev/null +++ b/e2e/ci-e2e/tests/nx-monorepo.e2e.test.ts @@ -0,0 +1,181 @@ +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 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.js'; +import { type TestRepo, setupTestRepo } from '../mocks/setup.js'; + +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, + parallel: true, + silent: true, // comment out for debugging + }; + }); + + 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', + }), + ], + }), + ], + }), + ); + }); + }); + + 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'), + ); + }); + }); +}); 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/README.md b/packages/ci/README.md index 3a5831254..7b38e18dc 100644 --- a/packages/ci/README.md +++ b/packages/ci/README.md @@ -97,22 +97,20 @@ 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) [^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: @@ -138,7 +136,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 @@ -196,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. @@ -211,7 +230,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 +238,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/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/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/mocks/fixtures/monorepos/npm/package-lock.json b/packages/ci/mocks/fixtures/monorepos/npm/package-lock.json new file mode 100644 index 000000000..f1a67f556 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/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/npm/package.json b/packages/ci/mocks/fixtures/monorepos/npm/package.json new file mode 100644 index 000000000..9f0acdba5 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/npm/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "workspaces": [ + "packages/*" + ] +} diff --git a/packages/ci/mocks/fixtures/monorepos/npm/packages/cli/package.json b/packages/ci/mocks/fixtures/monorepos/npm/packages/cli/package.json new file mode 100644 index 000000000..1a558500c --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/npm/packages/cli/package.json @@ -0,0 +1,6 @@ +{ + "name": "cli", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/npm/packages/core/package.json b/packages/ci/mocks/fixtures/monorepos/npm/packages/core/package.json new file mode 100644 index 000000000..e2b53d3a3 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/npm/packages/core/package.json @@ -0,0 +1,6 @@ +{ + "name": "core", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/npm/packages/utils/package.json b/packages/ci/mocks/fixtures/monorepos/npm/packages/utils/package.json new file mode 100644 index 000000000..c75ecc004 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/npm/packages/utils/package.json @@ -0,0 +1,6 @@ +{ + "name": "utils", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/nx/.gitignore b/packages/ci/mocks/fixtures/monorepos/nx/.gitignore new file mode 100644 index 000000000..fb222bf4d --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/nx/.gitignore @@ -0,0 +1 @@ +/.nx \ No newline at end of file diff --git a/packages/ci/mocks/fixtures/monorepos/nx/nx.json b/packages/ci/mocks/fixtures/monorepos/nx/nx.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/nx/nx.json @@ -0,0 +1 @@ +{} diff --git a/packages/ci/mocks/fixtures/monorepos/nx/package-lock.json b/packages/ci/mocks/fixtures/monorepos/nx/package-lock.json new file mode 100644 index 000000000..e2edcfee0 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/nx/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "nx", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/packages/ci/mocks/fixtures/monorepos/nx/package.json b/packages/ci/mocks/fixtures/monorepos/nx/package.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/nx/package.json @@ -0,0 +1 @@ +{} diff --git a/packages/ci/mocks/fixtures/monorepos/nx/packages/cli/project.json b/packages/ci/mocks/fixtures/monorepos/nx/packages/cli/project.json new file mode 100644 index 000000000..272ccae05 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/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/nx/packages/core/project.json b/packages/ci/mocks/fixtures/monorepos/nx/packages/core/project.json new file mode 100644 index 000000000..cb22860bf --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/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/nx/packages/utils/project.json b/packages/ci/mocks/fixtures/monorepos/nx/packages/utils/project.json new file mode 100644 index 000000000..e68a159e4 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/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/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 @@ +{} diff --git a/packages/ci/mocks/fixtures/monorepos/pnpm/package.json b/packages/ci/mocks/fixtures/monorepos/pnpm/package.json new file mode 100644 index 000000000..87abd4a0f --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/pnpm/package.json @@ -0,0 +1,3 @@ +{ + "packageManager": "pnpm@9.5.0" +} diff --git a/packages/ci/mocks/fixtures/monorepos/pnpm/packages/cli/package.json b/packages/ci/mocks/fixtures/monorepos/pnpm/packages/cli/package.json new file mode 100644 index 000000000..1a558500c --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/pnpm/packages/cli/package.json @@ -0,0 +1,6 @@ +{ + "name": "cli", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/pnpm/packages/core/package.json b/packages/ci/mocks/fixtures/monorepos/pnpm/packages/core/package.json new file mode 100644 index 000000000..e2b53d3a3 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/pnpm/packages/core/package.json @@ -0,0 +1,6 @@ +{ + "name": "core", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/pnpm/packages/utils/package.json b/packages/ci/mocks/fixtures/monorepos/pnpm/packages/utils/package.json new file mode 100644 index 000000000..c75ecc004 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/pnpm/packages/utils/package.json @@ -0,0 +1,6 @@ +{ + "name": "utils", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/pnpm/pnpm-lock.yaml b/packages/ci/mocks/fixtures/monorepos/pnpm/pnpm-lock.yaml new file mode 100644 index 000000000..3ab979278 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/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/pnpm/pnpm-workspace.yaml b/packages/ci/mocks/fixtures/monorepos/pnpm/pnpm-workspace.yaml new file mode 100644 index 000000000..924b55f42 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/pnpm/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/packages/ci/mocks/fixtures/monorepos/turbo/package.json b/packages/ci/mocks/fixtures/monorepos/turbo/package.json new file mode 100644 index 000000000..19179f45a --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/turbo/package.json @@ -0,0 +1,7 @@ +{ + "packageManager": "yarn@1.22.19", + "private": true, + "workspaces": [ + "packages/*" + ] +} diff --git a/packages/ci/mocks/fixtures/monorepos/turbo/packages/cli/package.json b/packages/ci/mocks/fixtures/monorepos/turbo/packages/cli/package.json new file mode 100644 index 000000000..1a558500c --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/turbo/packages/cli/package.json @@ -0,0 +1,6 @@ +{ + "name": "cli", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/turbo/packages/core/package.json b/packages/ci/mocks/fixtures/monorepos/turbo/packages/core/package.json new file mode 100644 index 000000000..e2b53d3a3 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/turbo/packages/core/package.json @@ -0,0 +1,6 @@ +{ + "name": "core", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/turbo/packages/utils/package.json b/packages/ci/mocks/fixtures/monorepos/turbo/packages/utils/package.json new file mode 100644 index 000000000..c75ecc004 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/turbo/packages/utils/package.json @@ -0,0 +1,6 @@ +{ + "name": "utils", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/turbo/turbo.json b/packages/ci/mocks/fixtures/monorepos/turbo/turbo.json new file mode 100644 index 000000000..12316330d --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/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/turbo/yarn.lock b/packages/ci/mocks/fixtures/monorepos/turbo/yarn.lock new file mode 100644 index 000000000..fb57ccd13 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/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/yarn/package.json b/packages/ci/mocks/fixtures/monorepos/yarn/package.json new file mode 100644 index 000000000..b9bff8df0 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/yarn/package.json @@ -0,0 +1,7 @@ +{ + "packageManager": "yarn@4.5.0", + "private": true, + "workspaces": [ + "packages/*" + ] +} diff --git a/packages/ci/mocks/fixtures/monorepos/yarn/packages/cli/package.json b/packages/ci/mocks/fixtures/monorepos/yarn/packages/cli/package.json new file mode 100644 index 000000000..1a558500c --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/yarn/packages/cli/package.json @@ -0,0 +1,6 @@ +{ + "name": "cli", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/yarn/packages/core/package.json b/packages/ci/mocks/fixtures/monorepos/yarn/packages/core/package.json new file mode 100644 index 000000000..e2b53d3a3 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/yarn/packages/core/package.json @@ -0,0 +1,6 @@ +{ + "name": "core", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/yarn/packages/utils/package.json b/packages/ci/mocks/fixtures/monorepos/yarn/packages/utils/package.json new file mode 100644 index 000000000..c75ecc004 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/yarn/packages/utils/package.json @@ -0,0 +1,6 @@ +{ + "name": "utils", + "scripts": { + "code-pushup": "code-pushup" + } +} diff --git a/packages/ci/mocks/fixtures/monorepos/yarn/yarn.lock b/packages/ci/mocks/fixtures/monorepos/yarn/yarn.lock new file mode 100644 index 000000000..05210ced0 --- /dev/null +++ b/packages/ci/mocks/fixtures/monorepos/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/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/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/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/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..90c2025b8 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(directory, 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..dbf280770 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,21 @@ 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(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)}`, + ); + } } 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..8961caa77 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,7 +12,7 @@ describe('createCommandContext', () => { directory: '/test', logger: console, monorepo: false, - output: '.code-pushup', + nxProjectsFilter: '--with-target={task}', projects: null, silent: false, task: 'code-pushup', @@ -21,12 +20,10 @@ describe('createCommandContext', () => { null, ), ).toStrictEqual({ - project: undefined, bin: 'npx --no-install code-pushup', directory: '/test', config: null, silent: false, - output: '.code-pushup', }); }); @@ -41,7 +38,7 @@ describe('createCommandContext', () => { directory: '/test', logger: console, monorepo: false, - output: '.code-pushup', + nxProjectsFilter: '--with-target={task}', projects: null, silent: false, task: 'code-pushup', @@ -53,49 +50,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/constants.ts b/packages/ci/src/lib/constants.ts index 85de8af3e..50d1320b7 100644 --- a/packages/ci/src/lib/constants.ts +++ b/packages/ci/src/lib/constants.ts @@ -1,8 +1,8 @@ -import { DEFAULT_PERSIST_OUTPUT_DIR } from '@code-pushup/models'; import type { Settings } from './models.js'; export const DEFAULT_SETTINGS: Settings = { monorepo: false, + parallel: false, projects: null, task: 'code-pushup', bin: 'npx --no-install code-pushup', @@ -12,6 +12,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 1ed578cd8..7bb5cf54b 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'; @@ -7,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[]; @@ -17,7 +19,6 @@ export type Options = { debug?: boolean; detectNewIssues?: boolean; logger?: Logger; - output?: string; }; /** @@ -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/monorepo/handlers/npm.ts b/packages/ci/src/lib/monorepo/handlers/npm.ts index 4657b4531..d6d0161e7 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 @@ -25,11 +27,17 @@ export const npmHandler: MonorepoToolHandler = { hasCodePushUpDependency(packageJson) || hasCodePushUpDependency(rootPackageJson), ) - .map(({ name, packageJson }) => ({ + .map(({ name, directory, packageJson }) => ({ name, + directory, bin: hasScript(packageJson, options.task) - ? `npm -w ${name} run ${options.task} --` - : `npm -w ${name} exec ${options.task} --`, + ? `npm run ${options.task} --` + : `npm 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/npm.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts new file mode 100644 index 000000000..d22e3eae9 --- /dev/null +++ b/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts @@ -0,0 +1,205 @@ +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 { + MonorepoHandlerOptions, + MonorepoHandlerProjectsContext, + ProjectConfig, +} from '../tools.js'; +import { npmHandler } from './npm.js'; + +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', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'npm run code-pushup --', + }, + { + name: 'shared', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'npm 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', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'npm exec code-pushup --', + }, + { + name: 'shared', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'npm 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', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'npm exec code-pushup --', + }, + { + name: 'frontend', + directory: join(MEMFS_VOLUME, 'apps', 'frontend'), + bin: 'npm exec code-pushup --', + }, + { + name: 'shared', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'npm exec code-pushup --', + }, + ] satisfies ProjectConfig[]); + }); + }); + + describe('createRunManyCommand', () => { + const projects: MonorepoHandlerProjectsContext = { + all: [ + { + name: 'api', + directory: join(MEMFS_VOLUME, 'api'), + bin: 'npm run code-pushup --', + }, + { + name: 'ui', + directory: join(MEMFS_VOLUME, 'ui'), + bin: 'npm run code-pushup --', + }, + ], + }; + + it('should create command to run npm script for all workspaces', () => { + 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 7e7ad236e..bb09c8d8e 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', @@ -38,11 +41,25 @@ 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} --`, })); }, + + createRunManyCommand(options, projects) { + const projectNames: string[] = + projects.only ?? projects.all.map(({ name }) => name); + return [ + 'npx', + 'nx', + 'run-many', + `--targets=${options.task}`, + `--parallel=${options.parallel}`, + `--projects=${projectNames.join(',')}`, + '--', + ].join(' '); + }, }; function parseProjects(stdout: string): string[] { 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..4ad65905c --- /dev/null +++ b/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts @@ -0,0 +1,163 @@ +import { vol } from 'memfs'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import * as utils from '@code-pushup/utils'; +import type { + MonorepoHandlerOptions, + MonorepoHandlerProjectsContext, + ProjectConfig, +} from '../tools.js'; +import { nxHandler } from './nx.js'; + +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', () => { + 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 }, + 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 }, 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, { + ...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 01bc368d5..0947c9467 100644 --- a/packages/ci/src/lib/monorepo/handlers/pnpm.ts +++ b/packages/ci/src/lib/monorepo/handlers/pnpm.ts @@ -13,12 +13,14 @@ const WORKSPACE_FILE = 'pnpm-workspace.yaml'; 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[] }; @@ -31,11 +33,31 @@ export const pnpmHandler: MonorepoToolHandler = { hasCodePushUpDependency(packageJson) || hasCodePushUpDependency(rootPackageJson), ) - .map(({ name, packageJson }) => ({ + .map(({ name, directory, packageJson }) => ({ name, + directory, bin: hasScript(packageJson, options.task) - ? `pnpm -F ${name} run ${options.task}` - : `pnpm -F ${name} exec ${options.task}`, + ? `pnpm run ${options.task}` + : `pnpm exec ${options.task}`, })); }, + + createRunManyCommand(options, projects) { + // https://pnpm.io/cli/recursive#--workspace-concurrency + const workspaceConcurrency: number | null = + options.parallel === true + ? null + : options.parallel === false + ? 1 + : options.parallel; + return [ + 'pnpm', + '--recursive', + ...(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 new file mode 100644 index 000000000..124ce6acf --- /dev/null +++ b/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts @@ -0,0 +1,227 @@ +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 { + MonorepoHandlerOptions, + MonorepoHandlerProjectsContext, + ProjectConfig, +} from '../tools.js'; +import { pnpmHandler } from './pnpm.js'; + +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', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'pnpm run code-pushup', + }, + { + name: 'shared', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'pnpm 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', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'pnpm exec code-pushup', + }, + { + name: 'shared', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'pnpm 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', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'pnpm exec code-pushup', + }, + { + name: 'frontend', + directory: join(MEMFS_VOLUME, 'apps', 'frontend'), + bin: 'pnpm exec code-pushup', + }, + { + name: 'shared', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'pnpm exec code-pushup', + }, + ] satisfies ProjectConfig[]); + }); + }); + + describe('createRunManyCommand', () => { + const projects: MonorepoHandlerProjectsContext = { + all: [ + { + 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', + }, + ], + }; + + it('should run script for all workspace packages sequentially by default', () => { + expect(pnpmHandler.createRunManyCommand(options, projects)).toBe( + 'pnpm --recursive --workspace-concurrency=1 code-pushup', + ); + }); + + it('should leave default concurrency if parallel flag is true', () => { + expect( + pnpmHandler.createRunManyCommand( + { ...options, parallel: true }, + projects, + ), + ).toBe('pnpm --recursive code-pushup'); + }); + + it('should set parallel flag with custom number of jobs', () => { + expect( + 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, { + ...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 0f8e3ff85..c3c56c8a3 100644 --- a/packages/ci/src/lib/monorepo/handlers/turbo.ts +++ b/packages/ci/src/lib/monorepo/handlers/turbo.ts @@ -13,6 +13,7 @@ type TurboConfig = { export const turboHandler: MonorepoToolHandler = { tool: 'turbo', + async isConfigured(options) { const configPath = join(options.cwd, 'turbo.json'); return ( @@ -20,6 +21,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) { @@ -27,16 +29,36 @@ 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} -F ${name} --`, + directory, + bin: `npx turbo run ${options.task} --`, })); } } 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('/')}`, ); }, + + createRunManyCommand(options, projects) { + // https://turbo.build/repo/docs/reference/run#--concurrency-number--percentage + const concurrency: number | null = + options.parallel === true + ? null + : options.parallel === false + ? 1 + : options.parallel; + return [ + 'npx', + 'turbo', + 'run', + options.task, + ...(projects.only?.map(project => `--filter=${project}`) ?? []), + ...(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 new file mode 100644 index 000000000..cc0546a12 --- /dev/null +++ b/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts @@ -0,0 +1,223 @@ +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 { + MonorepoHandlerOptions, + MonorepoHandlerProjectsContext, + ProjectConfig, +} from '../tools.js'; +import { turboHandler } from './turbo.js'; + +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', + directory: join(MEMFS_VOLUME, 'packages', 'cli'), + bin: 'npx turbo run code-pushup --', + }, + { + name: '@example/core', + directory: join(MEMFS_VOLUME, 'packages', 'core'), + bin: 'npx turbo run code-pushup --', + }, + ] 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', () => { + const projects: MonorepoHandlerProjectsContext = { + all: [ + { + 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 --', + }, + ], + }; + + it('should run script for all projects sequentially by default', () => { + expect(turboHandler.createRunManyCommand(options, projects)).toBe( + 'npx turbo run code-pushup --concurrency=1 --', + ); + }); + + it('should leave default concurrency if parallel flag is true', () => { + expect( + turboHandler.createRunManyCommand( + { ...options, parallel: true }, + projects, + ), + ).toBe('npx turbo run code-pushup --'); + }); + + it('should set parallel flag with custom number of jobs', () => { + expect( + 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, { + ...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 db5c3f632..8351afebb 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 @@ -25,11 +27,34 @@ 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}`, })); }, + + async createRunManyCommand(options, projects) { + 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}`] + : []), + ...(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 new file mode 100644 index 000000000..895ad343d --- /dev/null +++ b/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts @@ -0,0 +1,265 @@ +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'; +import type { + MonorepoHandlerOptions, + MonorepoHandlerProjectsContext, + ProjectConfig, +} from '../tools.js'; +import { yarnHandler } from './yarn.js'; + +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', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'yarn run code-pushup', + }, + { + name: 'shared', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'yarn 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', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'yarn exec code-pushup', + }, + { + name: 'shared', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'yarn 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', + directory: join(MEMFS_VOLUME, 'apps', 'backend'), + bin: 'yarn exec code-pushup', + }, + { + name: 'frontend', + directory: join(MEMFS_VOLUME, 'apps', 'frontend'), + bin: 'yarn exec code-pushup', + }, + { + name: 'shared', + directory: join(MEMFS_VOLUME, 'libs', 'shared'), + bin: 'yarn exec code-pushup', + }, + ] satisfies ProjectConfig[]); + }); + }); + + describe('createRunManyCommand', () => { + const projects: MonorepoHandlerProjectsContext = { + all: [ + { + 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', + }, + ], + }; + + 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, projects), + ).resolves.toBe('yarn workspaces run code-pushup'); + }); + }); + + 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, 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 }, + 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 }, + projects, + ), + ).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, { + ...projects, + only: ['api', 'cms'], + }), + ).resolves.toBe( + 'yarn workspaces foreach --include=api --include=cms code-pushup', + ); + }); + }); + }); +}); 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 961608af5..437c738ec 100644 --- a/packages/ci/src/lib/monorepo/list-projects.ts +++ b/packages/ci/src/lib/monorepo/list-projects.ts @@ -4,55 +4,87 @@ 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?: RunManyCommand; +}; + +export type 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, { + all: projects, + ...(onlyProjects?.length && { only: 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 +93,7 @@ function createMonorepoHandlerOptions( return { task: settings.task, cwd: settings.directory, + parallel: settings.parallel, 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..e68aec614 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,44 @@ 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', + 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); }); it('should detect packages in PNPM workspace with code-pushup script', async () => { @@ -140,11 +166,27 @@ 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', + 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: '@repo/utils', + directory: join(MEMFS_VOLUME, 'libs', 'utils'), + bin: 'pnpm run code-pushup', + }, + ], + runManyCommand: expect.any(Function), + } satisfies MonorepoProjects); }); it('should detect Yarn workspaces with code-pushup installed individually', async () => { @@ -170,10 +212,22 @@ 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', + 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); }); it('should detect NPM workspaces when code-pushup installed at root level', async () => { @@ -195,10 +249,22 @@ 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', + directory: join(MEMFS_VOLUME, 'packages', 'backend'), + bin: 'npm exec code-pushup --', + }, + { + name: 'frontend', + directory: join(MEMFS_VOLUME, 'packages', 'frontend'), + bin: 'npm 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 +292,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 +334,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 +391,31 @@ 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', + directory: join(MEMFS_VOLUME, 'apps', 'backoffice'), + bin: 'pnpm exec code-pushup', + }, + { + name: 'frontoffice', + directory: join(MEMFS_VOLUME, 'apps', 'frontoffice'), + bin: 'pnpm exec code-pushup', + }, + { + name: '@repo/models', + directory: join(MEMFS_VOLUME, 'packages', 'models'), + bin: 'pnpm exec code-pushup', + }, + { + name: '@repo/ui', + directory: join(MEMFS_VOLUME, 'packages', 'ui'), + bin: 'pnpm exec code-pushup', + }, + ], + runManyCommand: expect.any(Function), + } satisfies MonorepoProjects); }); }); 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..0e42538d3 --- /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.js'; + +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: join(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); + }); +}); diff --git a/packages/ci/src/lib/monorepo/tools.ts b/packages/ci/src/lib/monorepo/tools.ts index 435315eb0..ef5554bbc 100644 --- a/packages/ci/src/lib/monorepo/tools.ts +++ b/packages/ci/src/lib/monorepo/tools.ts @@ -7,15 +7,25 @@ export type MonorepoToolHandler = { tool: MonorepoTool; isConfigured: (options: MonorepoHandlerOptions) => Promise; listProjects: (options: MonorepoHandlerOptions) => Promise; + createRunManyCommand: ( + options: MonorepoHandlerOptions, + projects: MonorepoHandlerProjectsContext, + ) => string | Promise; }; export type MonorepoHandlerOptions = { task: string; cwd: string; + parallel: boolean | number; observer?: ProcessObserver; nxProjectsFilter: string | string[]; }; +export type MonorepoHandlerProjectsContext = { + only?: string[]; + all: ProjectConfig[]; +}; + export type ProjectConfig = { name: string; bin: string; 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.integration.test.ts b/packages/ci/src/lib/run.integration.test.ts index 80c2ae9d4..f7579d490 100644 --- a/packages/ci/src/lib/run.integration.test.ts +++ b/packages/ci/src/lib/run.integration.test.ts @@ -1,21 +1,18 @@ import { copyFile, + cp, mkdir, readFile, rename, - rm, 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 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'; import type { Comment, @@ -25,6 +22,7 @@ import type { ProviderAPIClient, RunResult, } from './models.js'; +import type { MonorepoTool } from './monorepo/index.js'; import { runInCI } from './run.js'; describe('runInCI', () => { @@ -35,8 +33,31 @@ describe('runInCI', () => { 'mocks', 'fixtures', ); - const workDir = join('tmp', 'ci', 'run-test'); - const outputDir = join(workDir, '.code-pushup'); + const reportsDir = join(fixturesDir, 'outputs'); + const workDir = join(process.cwd(), 'tmp', 'ci', 'run-test'); + + 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'), + }, + merged: { + md: join(reportsDir, 'diff-merged.md'), + }, + }, + config: join(reportsDir, 'config.json'), + }; const logger: Logger = { error: vi.fn(), @@ -46,11 +67,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< @@ -62,62 +89,100 @@ describe('runInCI', () => { Promise >; + let yarnVersion: string; + + 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': + await copyFile( + fixturePaths.diffs.merged.md, + join( + nxMatch ? workDir : (cwd as string), + '.code-pushup/merged-report-diff.md', + ), + ); + break; + + default: + const kind = + (await git.branch()).current === 'main' ? 'before' : 'after'; + const reports = fixturePaths.reports[kind]; + 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; + } + + return { code: 0, stdout, stderr: '' } as utils.ProcessResult; + } + beforeEach(async () => { + const originalExecuteProcess = utils.executeProcess; executeProcessSpy = vi .spyOn(utils, 'executeProcess') - .mockImplementation(async ({ command, args }) => { - if (command === options.bin) { - 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)), - ), - ); - break; - case 'print-config': - stdout = await readFile(join(fixturesDir, 'config.json'), '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)), - ), - ); - 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(' ') ?? ''}`, - ); + if (cfg.command === 'yarn' && cfg.args![0] === '-v') { + return Promise.resolve({ + code: 0, + stdout: yarnVersion, + stderr: '', + } as utils.ProcessResult); + } + return originalExecuteProcess(cfg); }); 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,240 +190,757 @@ 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'), + }, + }, + } 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(); + }); }); - it('should collect report', async () => { - await expect( - runInCI( - { head: { ref: 'main', sha: await git.revparse('main') } }, - {} as ProviderAPIClient, - options, - git, - ), - ).resolves.toEqual({ - mode: 'standalone', - artifacts: { - report: { - rootDir: outputDir, - files: [ - join(outputDir, 'report.json'), - join(outputDir, 'report.md'), - ], + describe('pull request event', () => { + let refs: GitRefs; + let diffMdString: string; + + beforeEach(async () => { + await git.checkoutLocalBranch('feature-1'); + + await rename(join(workDir, 'index.js'), join(workDir, 'index.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') }, + }; + + 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'), + }, + 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(); + }); + + 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(utils.executeProcess).toHaveBeenCalledTimes(1); - expect(utils.executeProcess).toHaveBeenCalledWith({ - command: options.bin, - args: [ - `--persist.outputDir=${outputDir}`, - '--persist.filename=report', - '--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(); + }); }); }); - describe('pull request event', () => { - let refs: GitRefs; - let diffMdString: string; + 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?.(); + }); - const mockComment: Comment = { - id: 42, - body: '... ', - url: 'https://fake.hosted.git/comments/42', - }; + describe('push event', () => { + beforeEach(async () => { + await git.checkout('main'); + }); - beforeEach(async () => { - await git.checkoutLocalBranch('feature-1'); + 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.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: run, + args: ['print-config'], + 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(); + expect(logger.info).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalled(); + }); + }); - await rename(join(workDir, 'index.js'), join(workDir, 'index.ts')); + describe('pull request event', () => { + let refs: GitRefs; + let diffMdString: string; - await git.add('index.ts'); - await git.commit('Convert JS file to TS'); + beforeEach(async () => { + await git.checkoutLocalBranch('feature-1'); - refs = { - head: { ref: 'feature-1', sha: await git.revparse('feature-1') }, - base: { ref: 'main', sha: await git.revparse('main') }, - }; + await writeFile(join(workDir, 'README.md'), '# Hello, world\n'); + await git.add('README.md'); + await git.commit('Create README'); - diffMdString = await readFile( - join(fixturesDir, 'report-diff.md'), - 'utf8', - ); + 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, '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, 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(); + + // 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: run, + args: ['print-config'], + 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(utils.executeProcess).toHaveBeenCalledWith({ + command: run, + 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: run, + 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(); + expect(logger.info).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalled(); + }); }); + }); - 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: [], - artifacts: { - report: { - rootDir: outputDir, - files: [ - join(outputDir, 'report.json'), - join(outputDir, 'report.md'), - ], - }, - diff: { - rootDir: outputDir, - files: [ - join(outputDir, 'report-diff.json'), - 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(4); - expect(utils.executeProcess).toHaveBeenNthCalledWith(1, { - command: options.bin, - args: [ - `--persist.outputDir=${outputDir}`, - '--persist.filename=report', - '--persist.format=json', - '--persist.format=md', - ], - cwd: workDir, - } satisfies utils.ProcessConfig); - expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { - command: options.bin, - args: ['print-config'], - 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', - ], - cwd: workDir, - } satisfies utils.ProcessConfig); - expect(utils.executeProcess).toHaveBeenNthCalledWith(4, { - 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', - ], - cwd: workDir, - } satisfies utils.ProcessConfig); - - expect(logger.error).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalled(); - expect(logger.debug).toHaveBeenCalled(); + 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'); }); - 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: [], - artifacts: { - report: { - rootDir: outputDir, - files: [ - join(outputDir, 'report.json'), - join(outputDir, 'report.md'), - ], - }, - diff: { - rootDir: outputDir, - files: [ - join(outputDir, 'report-diff.json'), - 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(2); - expect(utils.executeProcess).toHaveBeenNthCalledWith(1, { - command: options.bin, - args: [ - `--persist.outputDir=${outputDir}`, - '--persist.filename=report', - '--persist.format=json', - '--persist.format=md', - ], - cwd: workDir, - } satisfies utils.ProcessConfig); - expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { - 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', - ], - cwd: workDir, - } satisfies utils.ProcessConfig); - - expect(logger.error).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalled(); - expect(logger.debug).toHaveBeenCalled(); + 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 b3ac85e41..49473a1db 100644 --- a/packages/ci/src/lib/run.ts +++ b/packages/ci/src/lib/run.ts @@ -1,32 +1,15 @@ -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 { stringifyError } from '@code-pushup/utils'; -import { - type CommandContext, - type PersistedCliFiles, - createCommandContext, - runCollect, - runCompare, - runMergeDiffs, - runPrintConfig, -} from './cli/index.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, - ProjectRunResult, ProviderAPIClient, RunResult, Settings, } from './models.js'; -import { type ProjectConfig, listMonorepoProjects } from './monorepo/index.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. @@ -36,247 +19,22 @@ 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(({ artifacts: { diff } }) => - diff?.files.find(file => file.endsWith('.json')), - ) - .filter((file): file is string => file != null); - if (diffJsonPaths.length > 0) { - const { mdFilePath, artifactData: diffArtifact } = await runMergeDiffs( - diffJsonPaths, - createCommandContext(settings, projects[0]), - ); - logger.debug(`Merged ${diffJsonPaths.length} diffs into ${mdFilePath}`); - const commentId = await commentOnPR(mdFilePath, api, logger); - return { - mode: 'monorepo', - projects: projectResults, - commentId, - diffArtifact, - }; - } - return { mode: 'monorepo', projects: projectResults }; - } - - logger.info('Running Code PushUp in standalone project mode'); - const { artifacts, newIssues } = await runOnProject({ - project: null, - settings, - api, - refs, - git, - }); - const commentMdPath = artifacts.diff?.files.find(file => - file.endsWith('.md'), - ); - if (commentMdPath) { - const commentId = await commentOnPR(commentMdPath, api, logger); - return { - mode: 'standalone', - artifacts, - commentId, - newIssues, - }; - } - return { mode: 'standalone', artifacts, newIssues }; -} - -type RunOnProjectArgs = { - project: ProjectConfig | null; - refs: GitRefs; - api: ProviderAPIClient; - settings: Settings; - git: SimpleGit; -}; - -// eslint-disable-next-line max-lines-per-function -async function runOnProject(args: RunOnProjectArgs): Promise { - const { - project, - refs: { head, base }, - settings, - git, - } = args; - const logger = settings.logger; - - const ctx = createCommandContext(settings, project); - - if (project) { - 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 noDiffOutput = { - name: project?.name ?? '-', - artifacts: { - report: reportArtifact, - }, - } 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({ ...args, base, ctx }); - if (!prevReport) { - return noDiffOutput; - } - - 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.writeFile(currPath, currReport); - await fs.writeFile(prevPath, prevReport); - logger.debug(`Saved reports to ${currPath} and ${prevPath}`); - - const comparisonFiles = await runCompare( - { before: prevPath, after: currPath, label: project?.name }, - ctx, - ); - logger.info('Compared reports and generated diff files'); - logger.debug( - `Generated diff files at ${comparisonFiles.jsonFilePath} and ${comparisonFiles.mdFilePath}`, - ); + const env: RunEnv = { refs, api, settings, git }; - const diffOutput = { - ...noDiffOutput, - artifacts: { - ...noDiffOutput.artifacts, - diff: comparisonFiles.artifactData, - }, - } satisfies ProjectRunResult; - - if (!settings.detectNewIssues) { - return diffOutput; - } - - const newIssues = await findNewIssues({ - base, - currReport, - prevReport, - comparisonFiles, - logger, - git, - }); - - return { ...diffOutput, newIssues }; -} - -type CollectPreviousReportArgs = RunOnProjectArgs & { - base: GitBranch; - ctx: CommandContext; -}; - -async function collectPreviousReport( - args: CollectPreviousReportArgs, -): Promise { - const { project, base, api, settings, ctx, git } = args; - const logger = settings.logger; - - 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'); - } 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; - } - - const { jsonFilePath: prevReportPath } = await runCollect(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'); - - return prevReport; + if (settings.monorepo) { + return runInMonorepoMode(env); } -} - -async function findNewIssues(args: { - base: GitBranch; - currReport: string; - prevReport: string; - comparisonFiles: PersistedCliFiles; - 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 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; + return runInStandaloneMode(env); } 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; 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[]; 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)), + ), + ); +}