From 6876789e40a46af7fda6c65c13bc355adda769f4 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Wed, 18 Mar 2026 19:54:23 -0400 Subject: [PATCH 1/6] feat(plugin-coverage): add setup wizard binding --- package-lock.json | 30 +-- package.json | 1 + packages/create-cli/README.md | 12 + packages/create-cli/package.json | 1 + packages/create-cli/src/index.ts | 8 +- packages/create-cli/src/lib/setup/wizard.ts | 22 +- packages/models/src/lib/plugin-setup.ts | 6 +- packages/plugin-coverage/package.json | 1 + packages/plugin-coverage/src/index.ts | 1 + packages/plugin-coverage/src/lib/binding.ts | 246 ++++++++++++++++++ .../src/lib/binding.unit.test.ts | 228 ++++++++++++++++ .../plugin-coverage/src/lib/config-file.ts | 76 ++++++ .../src/lib/config-file.unit.test.ts | 107 ++++++++ 13 files changed, 717 insertions(+), 22 deletions(-) create mode 100644 packages/plugin-coverage/src/lib/binding.ts create mode 100644 packages/plugin-coverage/src/lib/binding.unit.test.ts create mode 100644 packages/plugin-coverage/src/lib/config-file.ts create mode 100644 packages/plugin-coverage/src/lib/config-file.unit.test.ts diff --git a/package-lock.json b/package-lock.json index 15eb957577..d4651d4ecc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "glob": "^11.0.1", "lighthouse": "^12.0.0", "lighthouse-logger": "2.0.1", + "magicast": "^0.3.5", "nx": "22.3.3", "ora": "^9.0.0", "parse-lcov": "^1.0.4", @@ -646,17 +647,15 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -701,13 +700,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", - "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", - "dev": true, + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -2285,14 +2283,13 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", - "dev": true, + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -23728,7 +23725,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, + "license": "MIT", "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", @@ -28274,7 +28271,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index 8e677492a3..3fbe99e93f 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "glob": "^11.0.1", "lighthouse": "^12.0.0", "lighthouse-logger": "2.0.1", + "magicast": "^0.3.5", "nx": "22.3.3", "ora": "^9.0.0", "parse-lcov": "^1.0.4", diff --git a/packages/create-cli/README.md b/packages/create-cli/README.md index 8b3d2fed53..b8af2b6958 100644 --- a/packages/create-cli/README.md +++ b/packages/create-cli/README.md @@ -37,6 +37,18 @@ Each plugin exposes its own configuration keys that can be passed as CLI argumen | **`--eslint.patterns`** | `string` | `src` or `.` | File patterns to lint | | **`--eslint.categories`** | `boolean` | `true` | Add recommended categories | +#### Coverage + +| Option | Type | Default | Description | +| ------------------------------- | -------------------------------------- | -------------------- | ------------------------------ | +| **`--coverage.framework`** | `'jest'` \| `'vitest'` \| `'other'` | auto-detected | Test framework | +| **`--coverage.configFile`** | `string` | auto-detected | Path to test config file | +| **`--coverage.reportPath`** | `string` | `coverage/lcov.info` | Path to LCOV report file | +| **`--coverage.testCommand`** | `string` | auto-detected | Command to run tests | +| **`--coverage.types`** | `'function'` \| `'branch'` \| `'line'` | all | Coverage types to measure | +| **`--coverage.continueOnFail`** | `boolean` | `true` | Continue if test command fails | +| **`--coverage.categories`** | `boolean` | `true` | Add code coverage category | + ### Examples Run interactively (default): diff --git a/packages/create-cli/package.json b/packages/create-cli/package.json index 3bd9f6ffb2..ddec67b604 100644 --- a/packages/create-cli/package.json +++ b/packages/create-cli/package.json @@ -26,6 +26,7 @@ }, "type": "module", "dependencies": { + "@code-pushup/coverage-plugin": "0.120.1", "@code-pushup/eslint-plugin": "0.120.1", "@code-pushup/models": "0.120.1", "@code-pushup/utils": "0.120.1", diff --git a/packages/create-cli/src/index.ts b/packages/create-cli/src/index.ts index 160d2b2dc1..97faa3c84e 100755 --- a/packages/create-cli/src/index.ts +++ b/packages/create-cli/src/index.ts @@ -1,6 +1,7 @@ #! /usr/bin/env node import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; +import { coverageSetupBinding } from '@code-pushup/coverage-plugin'; import { eslintSetupBinding } from '@code-pushup/eslint-plugin'; import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js'; import { @@ -11,8 +12,11 @@ import { } from './lib/setup/types.js'; import { runSetupWizard } from './lib/setup/wizard.js'; -// TODO: create, import and pass remaining plugin bindings (coverage, lighthouse, typescript, js-packages, jsdocs, axe) -const bindings: PluginSetupBinding[] = [eslintSetupBinding]; +// TODO: create, import and pass remaining plugin bindings (lighthouse, typescript, js-packages, jsdocs, axe) +const bindings: PluginSetupBinding[] = [ + eslintSetupBinding, + coverageSetupBinding, +]; const argv = await yargs(hideBin(process.argv)) .option('dry-run', { diff --git a/packages/create-cli/src/lib/setup/wizard.ts b/packages/create-cli/src/lib/setup/wizard.ts index a13e8819a0..3ede380b02 100644 --- a/packages/create-cli/src/lib/setup/wizard.ts +++ b/packages/create-cli/src/lib/setup/wizard.ts @@ -32,6 +32,7 @@ import type { PluginCodegenResult, PluginSetupBinding, ScopedPluginResult, + Tree, WriteContext, } from './types.js'; import { createTree } from './virtual-fs.js'; @@ -57,6 +58,8 @@ export async function runSetupWizard( const format = await promptConfigFormat(targetDir, cliArgs); const ciProvider = await promptCiProvider(cliArgs); + const tree = createTree(await getGitRoot()); + const resolved: ScopedPluginResult[] = await asyncSequential( selectedBindings, async binding => ({ @@ -65,11 +68,11 @@ export async function runSetupWizard( }), ); + await applyAdjustments(tree, resolved); + const packageJson = await readPackageJson(targetDir); const isEsm = packageJson.type === 'module'; const configFilename = resolveFilename('code-pushup.config', format, isEsm); - - const tree = createTree(await getGitRoot()); const writeContext: WriteContext = { tree, format, configFilename, isEsm }; await (context.mode === 'monorepo' && context.tool != null @@ -112,6 +115,21 @@ async function resolveBinding( return binding.generateConfig(answers); } +async function applyAdjustments( + tree: Pick, + resolved: ScopedPluginResult[], +): Promise { + await asyncSequential( + resolved.flatMap(({ result }) => result.adjustments ?? []), + async ({ path: filePath, transform }) => { + const content = await tree.read(filePath); + if (content != null) { + await tree.write(filePath, transform(content)); + } + }, + ); +} + async function writeStandaloneConfig( { tree, format, configFilename }: WriteContext, results: PluginCodegenResult[], diff --git a/packages/models/src/lib/plugin-setup.ts b/packages/models/src/lib/plugin-setup.ts index 1b684016ed..6bac83d288 100644 --- a/packages/models/src/lib/plugin-setup.ts +++ b/packages/models/src/lib/plugin-setup.ts @@ -47,11 +47,15 @@ export type ImportDeclarationStructure = { /** A single value in the answers record produced by plugin prompts. */ export type PluginAnswer = string | string[] | boolean; -/** Import declarations and plugin initialization code produced by `generateConfig`. */ +/** Code and file changes a plugin binding contributes to the generated config. */ export type PluginCodegenResult = { imports: ImportDeclarationStructure[]; pluginInit: string; categories?: CategoryConfig[]; + adjustments?: { + path: string; + transform: (content: string) => string; + }[]; }; /** diff --git a/packages/plugin-coverage/package.json b/packages/plugin-coverage/package.json index d1f9928a9e..156dd1af4f 100644 --- a/packages/plugin-coverage/package.json +++ b/packages/plugin-coverage/package.json @@ -36,6 +36,7 @@ "dependencies": { "@code-pushup/models": "0.120.1", "@code-pushup/utils": "0.120.1", + "magicast": "^0.3.5", "parse-lcov": "^1.0.4", "zod": "^4.2.1" }, diff --git a/packages/plugin-coverage/src/index.ts b/packages/plugin-coverage/src/index.ts index 50a0bb48b9..7dadfff93c 100644 --- a/packages/plugin-coverage/src/index.ts +++ b/packages/plugin-coverage/src/index.ts @@ -1,5 +1,6 @@ import { coveragePlugin } from './lib/coverage-plugin.js'; export default coveragePlugin; +export { coverageSetupBinding } from './lib/binding.js'; export type { CoveragePluginConfig } from './lib/config.js'; export { getNxCoveragePaths } from './lib/nx/coverage-paths.js'; diff --git a/packages/plugin-coverage/src/lib/binding.ts b/packages/plugin-coverage/src/lib/binding.ts new file mode 100644 index 0000000000..50ab9f9f74 --- /dev/null +++ b/packages/plugin-coverage/src/lib/binding.ts @@ -0,0 +1,246 @@ +import { readdir } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import type { + CategoryConfig, + PluginAnswer, + PluginCodegenResult, + PluginSetupBinding, +} from '@code-pushup/models'; +import { hasDependency, readJsonFile, singleQuote } from '@code-pushup/utils'; +import { addLcovReporter, hasLcovReporter } from './config-file.js'; +import { + ALL_COVERAGE_TYPES, + COVERAGE_PLUGIN_SLUG, + COVERAGE_PLUGIN_TITLE, +} from './constants.js'; + +const { name: PACKAGE_NAME } = createRequire(import.meta.url)( + '../../package.json', +) as typeof import('../../package.json'); + +const CONFIG_EXT = '[mc]?[tj]s'; +const VITEST_CONFIG = new RegExp(`^vi(test|te)\\.config\\.${CONFIG_EXT}$`); +const VITEST_WORKSPACE = new RegExp(`^vitest\\.workspace\\.${CONFIG_EXT}$`); +const JEST_CONFIG = new RegExp(`^jest\\.config\\.${CONFIG_EXT}$`); +const DEFAULT_REPORT_PATH = 'coverage/lcov.info'; + +const FRAMEWORKS = [ + { name: 'Jest', value: 'jest' }, + { name: 'Vitest', value: 'vitest' }, + { name: 'other', value: 'other' }, +] as const; +type Framework = (typeof FRAMEWORKS)[number]['value']; + +const CATEGORIES: CategoryConfig[] = [ + { + slug: 'code-coverage', + title: 'Code coverage', + description: 'Measures how much of your code is **covered by tests**.', + refs: [ + { + type: 'group', + plugin: COVERAGE_PLUGIN_SLUG, + slug: 'coverage', + weight: 1, + }, + ], + }, +]; + +type CoverageOptions = { + framework: string; + configFile: string; + reportPath: string; + testCommand: string; + types: string[]; + continueOnFail: boolean; + categories: boolean; +}; + +export const coverageSetupBinding = { + slug: COVERAGE_PLUGIN_SLUG, + title: COVERAGE_PLUGIN_TITLE, + packageName: PACKAGE_NAME, + isRecommended, + prompts: async (targetDir: string) => { + const framework = await detectFramework(targetDir); + const configFile = await detectConfigFile(targetDir, framework); + return [ + { + key: 'coverage.framework', + message: 'Test framework', + type: 'select', + choices: [...FRAMEWORKS], + default: framework, + }, + { + key: 'coverage.configFile', + message: 'Path to test config file', + type: 'input', + default: configFile ?? '', + }, + { + key: 'coverage.reportPath', + message: 'Path to LCOV report file', + type: 'input', + default: framework === 'other' ? '' : DEFAULT_REPORT_PATH, + }, + { + key: 'coverage.testCommand', + message: 'Command to run tests with coverage', + type: 'input', + default: defaultTestCommand(framework), + }, + { + key: 'coverage.types', + message: 'Coverage types to measure', + type: 'checkbox', + choices: ALL_COVERAGE_TYPES.map(type => ({ name: type, value: type })), + default: [...ALL_COVERAGE_TYPES], + }, + { + key: 'coverage.continueOnFail', + message: 'Continue if test command fails?', + type: 'confirm', + default: true, + }, + { + key: 'coverage.categories', + message: 'Add code coverage category?', + type: 'confirm', + default: true, + }, + ]; + }, + generateConfig: (answers: Record) => { + const args = parseAnswers(answers); + return { + imports: [ + { moduleSpecifier: PACKAGE_NAME, defaultImport: 'coveragePlugin' }, + ], + pluginInit: formatPluginInit(args), + ...(args.categories ? { categories: CATEGORIES } : {}), + ...resolveAdjustments(args), + }; + }, +} satisfies PluginSetupBinding; + +/** Applies defaults for missing or empty values. */ +function parseAnswers(answers: Record): CoverageOptions { + const string = (key: string) => { + const value = answers[key]; + return typeof value === 'string' ? value : ''; + }; + const types = answers['coverage.types']; + return { + framework: string('coverage.framework'), + configFile: string('coverage.configFile'), + reportPath: string('coverage.reportPath') || DEFAULT_REPORT_PATH, + testCommand: string('coverage.testCommand'), + types: Array.isArray(types) + ? types + : (typeof types === 'string' ? types : '') + .split(',') + .map(item => item.trim()) + .filter(Boolean), + continueOnFail: answers['coverage.continueOnFail'] !== false, + categories: answers['coverage.categories'] !== false, + }; +} + +/** Omits options that match plugin defaults. */ +function formatPluginInit(options: CoverageOptions): string { + const { reportPath, testCommand, types, continueOnFail } = options; + + const args = [ + `reports: [${singleQuote(reportPath)}]`, + testCommand + ? `coverageToolCommand: { command: ${singleQuote(testCommand)} }` + : '', + types.length > 0 && types.length < ALL_COVERAGE_TYPES.length + ? `coverageTypes: [${types.map(singleQuote).join(', ')}]` + : '', + continueOnFail ? '' : 'continueOnCommandFail: false', + ].filter(Boolean); + + return `await coveragePlugin({ + ${args.join(',\n ')}, + })`; +} + +function resolveAdjustments( + options: CoverageOptions, +): Pick { + const { framework, configFile } = options; + if (framework === 'other' || !configFile) { + return {}; + } + return { + adjustments: [ + { + path: configFile, + transform: (content: string) => + hasLcovReporter(content, framework) + ? content + : addLcovReporter(content, framework), + }, + ], + }; +} + +async function isRecommended(targetDir: string): Promise { + return (await detectFramework(targetDir)) !== 'other'; +} + +async function detectFramework(targetDir: string): Promise { + const files = await readdir(targetDir, { encoding: 'utf8' }); + const hasVitestConfig = files.some( + file => VITEST_CONFIG.test(file) || VITEST_WORKSPACE.test(file), + ); + const hasJestConfig = files.some(file => JEST_CONFIG.test(file)); + if (hasVitestConfig) { + return 'vitest'; + } + if (hasJestConfig) { + return 'jest'; + } + try { + const packageJson = await readJsonFile<{ + dependencies?: Record; + devDependencies?: Record; + }>(path.join(targetDir, 'package.json')); + if (hasDependency(packageJson, 'vitest')) { + return 'vitest'; + } + if (hasDependency(packageJson, 'jest')) { + return 'jest'; + } + } catch { + return 'other'; + } + return 'other'; +} + +async function detectConfigFile( + targetDir: string, + framework: Framework, +): Promise { + if (framework === 'other') { + return undefined; + } + const files = await readdir(targetDir, { encoding: 'utf8' }); + const pattern = framework === 'vitest' ? VITEST_CONFIG : JEST_CONFIG; + return files.find(file => pattern.test(file)); +} + +function defaultTestCommand(framework: Framework): string { + switch (framework) { + case 'jest': + return 'npx jest --coverage'; + case 'vitest': + return 'npx vitest run --coverage.enabled'; + default: + return ''; + } +} diff --git a/packages/plugin-coverage/src/lib/binding.unit.test.ts b/packages/plugin-coverage/src/lib/binding.unit.test.ts new file mode 100644 index 0000000000..7564fc51f8 --- /dev/null +++ b/packages/plugin-coverage/src/lib/binding.unit.test.ts @@ -0,0 +1,228 @@ +import { vol } from 'memfs'; +import type { PluginAnswer } from '@code-pushup/models'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { readJsonFile } from '@code-pushup/utils'; +import { coverageSetupBinding } from './binding.js'; + +vi.mock('@code-pushup/utils', async () => { + const actual = await vi.importActual('@code-pushup/utils'); + return { + ...actual, + readJsonFile: vi.fn().mockRejectedValue(new Error('ENOENT')), + }; +}); + +function generateConfig(overrides: Record = {}) { + return coverageSetupBinding.generateConfig({ + 'coverage.framework': 'vitest', + 'coverage.configFile': '', + 'coverage.reportPath': 'coverage/lcov.info', + 'coverage.testCommand': 'npx vitest run --coverage.enabled', + 'coverage.types': ['function', 'branch', 'line'], + 'coverage.continueOnFail': true, + 'coverage.categories': true, + ...overrides, + }); +} + +describe('coverageSetupBinding', () => { + beforeEach(() => { + vol.fromJSON({ '.gitkeep': '' }, MEMFS_VOLUME); + }); + + describe('isRecommended', () => { + it.each([ + { file: 'vitest.config.ts' }, + { file: 'vite.config.mjs' }, + { file: 'vitest.workspace.cts' }, + { file: 'jest.config.js' }, + ])('should detect $file', async ({ file }) => { + vol.fromJSON({ [file]: '' }, MEMFS_VOLUME); + + await expect( + coverageSetupBinding.isRecommended(MEMFS_VOLUME), + ).resolves.toBeTrue(); + }); + + it.each([{ field: 'dependencies' }, { field: 'devDependencies' }])( + 'should detect vitest in $field', + async ({ field }) => { + vi.mocked(readJsonFile).mockResolvedValue({ + [field]: { vitest: '^2.0.0' }, + }); + + await expect( + coverageSetupBinding.isRecommended(MEMFS_VOLUME), + ).resolves.toBeTrue(); + }, + ); + + it.each([{ field: 'dependencies' }, { field: 'devDependencies' }])( + 'should detect jest in $field', + async ({ field }) => { + vi.mocked(readJsonFile).mockResolvedValue({ + [field]: { jest: '^29.0.0' }, + }); + + await expect( + coverageSetupBinding.isRecommended(MEMFS_VOLUME), + ).resolves.toBeTrue(); + }, + ); + + it('should not recommend when no test framework found', async () => { + vi.mocked(readJsonFile).mockResolvedValue({}); + + await expect( + coverageSetupBinding.isRecommended(MEMFS_VOLUME), + ).resolves.toBeFalse(); + }); + }); + + describe('prompts', () => { + it('should detect vitest defaults', async () => { + vol.fromJSON({ 'vitest.config.ts': '' }, MEMFS_VOLUME); + + await expect( + coverageSetupBinding.prompts(MEMFS_VOLUME), + ).resolves.toIncludeAllPartialMembers([ + { key: 'coverage.framework', default: 'vitest' }, + { key: 'coverage.configFile', default: 'vitest.config.ts' }, + { key: 'coverage.reportPath', default: 'coverage/lcov.info' }, + ]); + }); + + it('should detect jest defaults', async () => { + vol.fromJSON({ 'jest.config.js': '' }, MEMFS_VOLUME); + + await expect( + coverageSetupBinding.prompts(MEMFS_VOLUME), + ).resolves.toIncludeAllPartialMembers([ + { key: 'coverage.framework', default: 'jest' }, + { key: 'coverage.configFile', default: 'jest.config.js' }, + ]); + }); + + it('should default to other when no framework detected', async () => { + vi.mocked(readJsonFile).mockResolvedValue({}); + + await expect( + coverageSetupBinding.prompts(MEMFS_VOLUME), + ).resolves.toIncludeAllPartialMembers([ + { key: 'coverage.framework', default: 'other' }, + { key: 'coverage.reportPath', default: '' }, + { key: 'coverage.testCommand', default: '' }, + ]); + }); + }); + + describe('generateConfig', () => { + it('should generate vitest config', () => { + expect(generateConfig().pluginInit).toMatchInlineSnapshot(` + "await coveragePlugin({ + reports: ['coverage/lcov.info'], + coverageToolCommand: { command: 'npx vitest run --coverage.enabled' }, + })" + `); + }); + + it('should generate jest config', () => { + const { pluginInit } = generateConfig({ + 'coverage.framework': 'jest', + 'coverage.testCommand': 'npx jest --coverage', + }); + expect(pluginInit).toMatchInlineSnapshot(` + "await coveragePlugin({ + reports: ['coverage/lcov.info'], + coverageToolCommand: { command: 'npx jest --coverage' }, + })" + `); + }); + + it('should omit coverageToolCommand when test command is empty', () => { + expect( + generateConfig({ 'coverage.testCommand': '' }).pluginInit, + ).not.toContain('coverageToolCommand'); + }); + + it('should use default report path when empty', () => { + expect( + generateConfig({ 'coverage.reportPath': '' }).pluginInit, + ).toContain("'coverage/lcov.info'"); + }); + + it('should use custom report path when provided', () => { + expect( + generateConfig({ 'coverage.reportPath': 'dist/coverage/lcov.info' }) + .pluginInit, + ).toContain("'dist/coverage/lcov.info'"); + }); + + it('should omit coverageTypes when all selected', () => { + expect(generateConfig().pluginInit).not.toContain('coverageTypes'); + }); + + it('should include coverageTypes when subset selected', () => { + expect( + generateConfig({ 'coverage.types': ['branch', 'line'] }).pluginInit, + ).toContain("coverageTypes: ['branch', 'line']"); + }); + + it('should disable continueOnCommandFail when declined', () => { + expect( + generateConfig({ 'coverage.continueOnFail': false }).pluginInit, + ).toContain('continueOnCommandFail: false'); + }); + + it('should omit continueOnCommandFail when default', () => { + expect(generateConfig().pluginInit).not.toContain( + 'continueOnCommandFail', + ); + }); + + it('should omit categories when declined', () => { + expect( + generateConfig({ 'coverage.categories': false }).categories, + ).toBeUndefined(); + }); + + it('should import from @code-pushup/coverage-plugin', () => { + expect(generateConfig().imports).toEqual([ + { + moduleSpecifier: '@code-pushup/coverage-plugin', + defaultImport: 'coveragePlugin', + }, + ]); + }); + }); + + describe('adjustments', () => { + it('should target vitest config file', () => { + const { adjustments } = generateConfig({ + 'coverage.framework': 'vitest', + 'coverage.configFile': 'vitest.config.ts', + }); + expect(adjustments).toHaveLength(1); + expect(adjustments![0]!.path).toBe('vitest.config.ts'); + }); + + it('should target jest config file', () => { + const { adjustments } = generateConfig({ + 'coverage.framework': 'jest', + 'coverage.configFile': 'jest.config.mjs', + }); + expect(adjustments).toHaveLength(1); + expect(adjustments![0]!.path).toBe('jest.config.mjs'); + }); + + it('should skip for other framework', () => { + expect( + generateConfig({ 'coverage.framework': 'other' }).adjustments, + ).toBeUndefined(); + }); + + it('should skip when no config file detected', () => { + expect(generateConfig().adjustments).toBeUndefined(); + }); + }); +}); diff --git a/packages/plugin-coverage/src/lib/config-file.ts b/packages/plugin-coverage/src/lib/config-file.ts new file mode 100644 index 0000000000..3a13542710 --- /dev/null +++ b/packages/plugin-coverage/src/lib/config-file.ts @@ -0,0 +1,76 @@ +import { generateCode, parseModule } from 'magicast'; +import { deepMergeObject } from 'magicast/helpers'; + +type ProxyObject = Record & { + toJSON?: () => unknown; +}; + +const REPORTER_CONFIGS: Record = { + vitest: { path: ['test', 'coverage'], key: 'reporter' }, + jest: { path: [], key: 'coverageReporters' }, +}; + +export function hasLcovReporter(content: string, framework: string): boolean { + const reporterConfig = REPORTER_CONFIGS[framework]; + if (!reporterConfig) { + return false; + } + return /['"]lcov['"]/.test(content) && content.includes(reporterConfig.key); +} + +export function addLcovReporter(content: string, framework: string): string { + const reporterConfig = REPORTER_CONFIGS[framework]; + if (!reporterConfig) { + return content; + } + try { + const mod = parseModule(content); + const exported = mod.exports['default']; + const configObject = + exported.$type === 'function-call' ? exported.$args[0] : exported; + const currentReporters = readReporters(configObject, reporterConfig); + const updatedReporters = [...currentReporters, 'lcov']; + + deepMergeObject( + configObject, + buildNestedObject( + [...reporterConfig.path, reporterConfig.key], + updatedReporters, + ), + ); + + return generateCode(mod).code; + } catch { + return content; + } +} + +function isProxyObject(value: unknown): value is ProxyObject { + return typeof value === 'object' && value != null; +} + +function readReporters( + configObject: ProxyObject, + { path, key }: { path: string[]; key: string }, +): string[] { + const container = path.reduce((parent, segment) => { + const nested = parent[segment]; + return isProxyObject(nested) ? nested : {}; + }, configObject); + const reporterProxy = container[key]; + const resolved = + isProxyObject(reporterProxy) && typeof reporterProxy.toJSON === 'function' + ? reporterProxy.toJSON() + : reporterProxy; + return Array.isArray(resolved) ? resolved : []; +} + +export function buildNestedObject( + segments: string[], + value: unknown, +): Record { + return segments.reduceRight>( + (nested, segment) => ({ [segment]: nested }), + value as Record, + ); +} diff --git a/packages/plugin-coverage/src/lib/config-file.unit.test.ts b/packages/plugin-coverage/src/lib/config-file.unit.test.ts new file mode 100644 index 0000000000..d045063fbc --- /dev/null +++ b/packages/plugin-coverage/src/lib/config-file.unit.test.ts @@ -0,0 +1,107 @@ +import { + addLcovReporter, + buildNestedObject, + hasLcovReporter, +} from './config-file.js'; + +describe('hasLcovReporter', () => { + it('should detect lcov in vitest config', () => { + expect(hasLcovReporter("reporter: ['text', 'lcov']", 'vitest')).toBeTrue(); + }); + + it('should return false when lcov is absent', () => { + expect(hasLcovReporter("coverageReporters: ['text']", 'jest')).toBeFalse(); + }); +}); + +describe('addLcovReporter', () => { + it('should append lcov to existing vitest reporter array', () => { + const input = `import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + reporter: ['text'], + }, + }, +}); +`; + expect(addLcovReporter(input, 'vitest')).toMatchInlineSnapshot(` + "import { defineConfig } from 'vitest/config'; + + export default defineConfig({ + test: { + coverage: { + reporter: ['text', 'lcov'], + }, + }, + });" + `); + }); + + it('should add coverage block to vitest config when missing', () => { + const input = `import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + }, +}); +`; + expect(addLcovReporter(input, 'vitest')).toMatchInlineSnapshot(` + "import { defineConfig } from 'vitest/config'; + + export default defineConfig({ + test: { + globals: true, + + coverage: { + reporter: ['lcov'], + }, + }, + });" + `); + }); + + it('should append lcov to existing jest coverageReporters', () => { + const input = `export default { + coverageReporters: ['text'], +}; +`; + expect(addLcovReporter(input, 'jest')).toMatchInlineSnapshot(` + "export default { + coverageReporters: ['text', 'lcov'], + };" + `); + }); + + it('should add coverageReporters to jest config when missing', () => { + const input = `export default { + testEnvironment: 'node', +}; +`; + expect(addLcovReporter(input, 'jest')).toMatchInlineSnapshot(` + "export default { + testEnvironment: 'node', + coverageReporters: ['lcov'], + };" + `); + }); + + it('should return CJS config unchanged', () => { + const input = `module.exports = { coverageReporters: ['text'] };`; + expect(addLcovReporter(input, 'jest')).toBe(input); + }); +}); + +describe('buildNestedObject', () => { + it('should wrap value in nested structure', () => { + expect( + buildNestedObject(['test', 'coverage', 'reporter'], ['lcov']), + ).toEqual({ test: { coverage: { reporter: ['lcov'] } } }); + }); + + it('should return value directly for empty segments', () => { + expect(buildNestedObject([], ['lcov'])).toEqual(['lcov']); + }); +}); From e4611f6061235f9848fb2f32ce5345078eebed5d Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Thu, 19 Mar 2026 14:36:46 -0400 Subject: [PATCH 2/6] refactor(plugin-coverage): improve config-file lcov handling --- .../plugin-coverage/src/lib/config-file.ts | 88 +++++++++---------- .../src/lib/config-file.unit.test.ts | 33 ++++--- 2 files changed, 65 insertions(+), 56 deletions(-) diff --git a/packages/plugin-coverage/src/lib/config-file.ts b/packages/plugin-coverage/src/lib/config-file.ts index 3a13542710..29a3899aed 100644 --- a/packages/plugin-coverage/src/lib/config-file.ts +++ b/packages/plugin-coverage/src/lib/config-file.ts @@ -1,68 +1,64 @@ import { generateCode, parseModule } from 'magicast'; -import { deepMergeObject } from 'magicast/helpers'; +import { deepMergeObject, getDefaultExportOptions } from 'magicast/helpers'; -type ProxyObject = Record & { - toJSON?: () => unknown; -}; - -const REPORTER_CONFIGS: Record = { - vitest: { path: ['test', 'coverage'], key: 'reporter' }, - jest: { path: [], key: 'coverageReporters' }, -}; +const VITEST_DEFAULTS = ['text', 'html', 'clover', 'json']; export function hasLcovReporter(content: string, framework: string): boolean { - const reporterConfig = REPORTER_CONFIGS[framework]; - if (!reporterConfig) { - return false; + switch (framework) { + case 'vitest': + return /['"]lcov['"]/.test(content) && content.includes('reporter'); + case 'jest': + return ( + !content.includes('coverageReporters') || /['"]lcov['"]/.test(content) + ); + default: + return false; } - return /['"]lcov['"]/.test(content) && content.includes(reporterConfig.key); } export function addLcovReporter(content: string, framework: string): string { - const reporterConfig = REPORTER_CONFIGS[framework]; - if (!reporterConfig) { - return content; + switch (framework) { + case 'vitest': + return addLcovToVitest(content); + case 'jest': + return addLcovToJest(content); + default: + return content; } +} + +function addLcovToVitest(content: string): string { try { const mod = parseModule(content); - const exported = mod.exports['default']; - const configObject = - exported.$type === 'function-call' ? exported.$args[0] : exported; - const currentReporters = readReporters(configObject, reporterConfig); - const updatedReporters = [...currentReporters, 'lcov']; - + const config = getDefaultExportOptions(mod); + const reporter = config['test']?.['coverage']?.['reporter']; + const base = reporter?.['length'] ? [...reporter] : VITEST_DEFAULTS; deepMergeObject( - configObject, - buildNestedObject( - [...reporterConfig.path, reporterConfig.key], - updatedReporters, - ), + config, + buildNestedObject(['test', 'coverage', 'reporter'], [...base, 'lcov']), ); - return generateCode(mod).code; } catch { return content; } } -function isProxyObject(value: unknown): value is ProxyObject { - return typeof value === 'object' && value != null; -} - -function readReporters( - configObject: ProxyObject, - { path, key }: { path: string[]; key: string }, -): string[] { - const container = path.reduce((parent, segment) => { - const nested = parent[segment]; - return isProxyObject(nested) ? nested : {}; - }, configObject); - const reporterProxy = container[key]; - const resolved = - isProxyObject(reporterProxy) && typeof reporterProxy.toJSON === 'function' - ? reporterProxy.toJSON() - : reporterProxy; - return Array.isArray(resolved) ? resolved : []; +function addLcovToJest(content: string): string { + try { + const mod = parseModule(content); + const config = getDefaultExportOptions(mod); + const reporters = config['coverageReporters']; + if (!reporters?.['length']) { + return content; + } + deepMergeObject( + config, + buildNestedObject(['coverageReporters'], [...reporters, 'lcov']), + ); + return generateCode(mod).code; + } catch { + return content; + } } export function buildNestedObject( diff --git a/packages/plugin-coverage/src/lib/config-file.unit.test.ts b/packages/plugin-coverage/src/lib/config-file.unit.test.ts index d045063fbc..b55c92882c 100644 --- a/packages/plugin-coverage/src/lib/config-file.unit.test.ts +++ b/packages/plugin-coverage/src/lib/config-file.unit.test.ts @@ -5,11 +5,29 @@ import { } from './config-file.js'; describe('hasLcovReporter', () => { - it('should detect lcov in vitest config', () => { + it('should return true for vitest reporter with lcov', () => { expect(hasLcovReporter("reporter: ['text', 'lcov']", 'vitest')).toBeTrue(); }); - it('should return false when lcov is absent', () => { + it('should return false for vitest reporter without lcov', () => { + expect(hasLcovReporter("reporter: ['text']", 'vitest')).toBeFalse(); + }); + + it('should return false for vitest without reporter key', () => { + expect(hasLcovReporter('globals: true', 'vitest')).toBeFalse(); + }); + + it('should return true for jest without coverageReporters (lcov is default)', () => { + expect(hasLcovReporter('testEnvironment: "node"', 'jest')).toBeTrue(); + }); + + it('should return true for jest coverageReporters with lcov', () => { + expect( + hasLcovReporter("coverageReporters: ['text', 'lcov']", 'jest'), + ).toBeTrue(); + }); + + it('should return false for jest coverageReporters without lcov', () => { expect(hasLcovReporter("coverageReporters: ['text']", 'jest')).toBeFalse(); }); }); @@ -56,7 +74,7 @@ export default defineConfig({ globals: true, coverage: { - reporter: ['lcov'], + reporter: ['text', 'html', 'clover', 'json', 'lcov'], }, }, });" @@ -75,17 +93,12 @@ export default defineConfig({ `); }); - it('should add coverageReporters to jest config when missing', () => { + it('should not modify jest config when coverageReporters is missing (lcov enabled by default)', () => { const input = `export default { testEnvironment: 'node', }; `; - expect(addLcovReporter(input, 'jest')).toMatchInlineSnapshot(` - "export default { - testEnvironment: 'node', - coverageReporters: ['lcov'], - };" - `); + expect(addLcovReporter(input, 'jest')).toBe(input); }); it('should return CJS config unchanged', () => { From 8b5433fc0c01740f1a1b0b2ad3201349cfedfeb7 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Thu, 19 Mar 2026 14:37:49 -0400 Subject: [PATCH 3/6] docs(plugin-coverage): fix option type --- packages/create-cli/README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/create-cli/README.md b/packages/create-cli/README.md index b8af2b6958..cb91fad4f0 100644 --- a/packages/create-cli/README.md +++ b/packages/create-cli/README.md @@ -39,15 +39,15 @@ Each plugin exposes its own configuration keys that can be passed as CLI argumen #### Coverage -| Option | Type | Default | Description | -| ------------------------------- | -------------------------------------- | -------------------- | ------------------------------ | -| **`--coverage.framework`** | `'jest'` \| `'vitest'` \| `'other'` | auto-detected | Test framework | -| **`--coverage.configFile`** | `string` | auto-detected | Path to test config file | -| **`--coverage.reportPath`** | `string` | `coverage/lcov.info` | Path to LCOV report file | -| **`--coverage.testCommand`** | `string` | auto-detected | Command to run tests | -| **`--coverage.types`** | `'function'` \| `'branch'` \| `'line'` | all | Coverage types to measure | -| **`--coverage.continueOnFail`** | `boolean` | `true` | Continue if test command fails | -| **`--coverage.categories`** | `boolean` | `true` | Add code coverage category | +| Option | Type | Default | Description | +| ------------------------------- | ------------------------------------------ | -------------------- | ------------------------------ | +| **`--coverage.framework`** | `'jest'` \| `'vitest'` \| `'other'` | auto-detected | Test framework | +| **`--coverage.configFile`** | `string` | auto-detected | Path to test config file | +| **`--coverage.reportPath`** | `string` | `coverage/lcov.info` | Path to LCOV report file | +| **`--coverage.testCommand`** | `string` | auto-detected | Command to run tests | +| **`--coverage.types`** | `('function'` \| `'branch'` \| `'line')[]` | all | Coverage types to measure | +| **`--coverage.continueOnFail`** | `boolean` | `true` | Continue if test command fails | +| **`--coverage.categories`** | `boolean` | `true` | Add code coverage category | ### Examples From 9bc20e0dd8b9d4e203230aa56f0edf6d124dae17 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Thu, 19 Mar 2026 16:28:13 -0400 Subject: [PATCH 4/6] refactor(plugin-coverage): replace adjustments with tree --- packages/create-cli/src/lib/setup/types.ts | 1 + packages/create-cli/src/lib/setup/wizard.ts | 22 +- packages/models/src/index.ts | 1 + packages/models/src/lib/plugin-setup.ts | 15 +- packages/plugin-coverage/src/lib/binding.ts | 94 +++++--- .../src/lib/binding.unit.test.ts | 213 +++++++++++------- 6 files changed, 205 insertions(+), 141 deletions(-) diff --git a/packages/create-cli/src/lib/setup/types.ts b/packages/create-cli/src/lib/setup/types.ts index fbc04c49e3..5891f163e2 100644 --- a/packages/create-cli/src/lib/setup/types.ts +++ b/packages/create-cli/src/lib/setup/types.ts @@ -7,6 +7,7 @@ export type { PluginCodegenResult, PluginPromptDescriptor, PluginSetupBinding, + PluginSetupTree, } from '@code-pushup/models'; export const CI_PROVIDERS = ['github', 'gitlab', 'none'] as const; diff --git a/packages/create-cli/src/lib/setup/wizard.ts b/packages/create-cli/src/lib/setup/wizard.ts index 3ede380b02..af743066f3 100644 --- a/packages/create-cli/src/lib/setup/wizard.ts +++ b/packages/create-cli/src/lib/setup/wizard.ts @@ -64,12 +64,10 @@ export async function runSetupWizard( selectedBindings, async binding => ({ scope: binding.scope ?? 'project', - result: await resolveBinding(binding, cliArgs, targetDir), + result: await resolveBinding(binding, cliArgs, targetDir, tree), }), ); - await applyAdjustments(tree, resolved); - const packageJson = await readPackageJson(targetDir); const isEsm = packageJson.type === 'module'; const configFilename = resolveFilename('code-pushup.config', format, isEsm); @@ -106,28 +104,14 @@ async function resolveBinding( binding: PluginSetupBinding, cliArgs: CliArgs, targetDir: string, + tree: Pick, ): Promise { const descriptors = binding.prompts ? await binding.prompts(targetDir) : []; const answers = descriptors.length > 0 ? await promptPluginOptions(descriptors, cliArgs) : {}; - return binding.generateConfig(answers); -} - -async function applyAdjustments( - tree: Pick, - resolved: ScopedPluginResult[], -): Promise { - await asyncSequential( - resolved.flatMap(({ result }) => result.adjustments ?? []), - async ({ path: filePath, transform }) => { - const content = await tree.read(filePath); - if (content != null) { - await tree.write(filePath, transform(content)); - } - }, - ); + return binding.generateConfig(answers, tree); } async function writeStandaloneConfig( diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index b12a5d344a..76aa96092e 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -118,6 +118,7 @@ export type { PluginCodegenResult, PluginPromptDescriptor, PluginSetupBinding, + PluginSetupTree, } from './lib/plugin-setup.js'; export { auditReportSchema, diff --git a/packages/models/src/lib/plugin-setup.ts b/packages/models/src/lib/plugin-setup.ts index 6bac83d288..704d591e26 100644 --- a/packages/models/src/lib/plugin-setup.ts +++ b/packages/models/src/lib/plugin-setup.ts @@ -47,15 +47,17 @@ export type ImportDeclarationStructure = { /** A single value in the answers record produced by plugin prompts. */ export type PluginAnswer = string | string[] | boolean; -/** Code and file changes a plugin binding contributes to the generated config. */ +/** Code a plugin binding contributes to the generated config. */ export type PluginCodegenResult = { imports: ImportDeclarationStructure[]; pluginInit: string; categories?: CategoryConfig[]; - adjustments?: { - path: string; - transform: (content: string) => string; - }[]; +}; + +/** Minimal file system abstraction passed to plugin bindings. */ +export type PluginSetupTree = { + read: (path: string) => Promise; + write: (path: string, content: string) => Promise; }; /** @@ -75,5 +77,6 @@ export type PluginSetupBinding = { isRecommended?: (targetDir: string) => Promise; generateConfig: ( answers: Record, - ) => PluginCodegenResult; + tree?: PluginSetupTree, + ) => PluginCodegenResult | Promise; }; diff --git a/packages/plugin-coverage/src/lib/binding.ts b/packages/plugin-coverage/src/lib/binding.ts index 50ab9f9f74..1abbc7965b 100644 --- a/packages/plugin-coverage/src/lib/binding.ts +++ b/packages/plugin-coverage/src/lib/binding.ts @@ -4,10 +4,15 @@ import path from 'node:path'; import type { CategoryConfig, PluginAnswer, - PluginCodegenResult, PluginSetupBinding, + PluginSetupTree, } from '@code-pushup/models'; -import { hasDependency, readJsonFile, singleQuote } from '@code-pushup/utils'; +import { + hasDependency, + pluralize, + readJsonFile, + singleQuote, +} from '@code-pushup/utils'; import { addLcovReporter, hasLcovReporter } from './config-file.js'; import { ALL_COVERAGE_TYPES, @@ -25,6 +30,9 @@ const VITEST_WORKSPACE = new RegExp(`^vitest\\.workspace\\.${CONFIG_EXT}$`); const JEST_CONFIG = new RegExp(`^jest\\.config\\.${CONFIG_EXT}$`); const DEFAULT_REPORT_PATH = 'coverage/lcov.info'; +const LCOV_COMMENT = + '// NOTE: Ensure your test config includes "lcov" in coverage reporters.'; + const FRAMEWORKS = [ { name: 'Jest', value: 'jest' }, { name: 'Vitest', value: 'vitest' }, @@ -63,6 +71,7 @@ export const coverageSetupBinding = { title: COVERAGE_PLUGIN_TITLE, packageName: PACKAGE_NAME, isRecommended, + // eslint-disable-next-line max-lines-per-function prompts: async (targetDir: string) => { const framework = await detectFramework(targetDir); const configFile = await detectConfigFile(targetDir, framework); @@ -96,7 +105,10 @@ export const coverageSetupBinding = { key: 'coverage.types', message: 'Coverage types to measure', type: 'checkbox', - choices: ALL_COVERAGE_TYPES.map(type => ({ name: type, value: type })), + choices: ALL_COVERAGE_TYPES.map(type => ({ + name: pluralize(type), + value: type, + })), default: [...ALL_COVERAGE_TYPES], }, { @@ -113,20 +125,22 @@ export const coverageSetupBinding = { }, ]; }, - generateConfig: (answers: Record) => { + generateConfig: async ( + answers: Record, + tree?: PluginSetupTree, + ) => { const args = parseAnswers(answers); + const lcovConfigured = await configureLcovReporter(args, tree); return { imports: [ { moduleSpecifier: PACKAGE_NAME, defaultImport: 'coveragePlugin' }, ], - pluginInit: formatPluginInit(args), + pluginInit: formatPluginInit(args, lcovConfigured), ...(args.categories ? { categories: CATEGORIES } : {}), - ...resolveAdjustments(args), }; }, } satisfies PluginSetupBinding; -/** Applies defaults for missing or empty values. */ function parseAnswers(answers: Record): CoverageOptions { const string = (key: string) => { const value = answers[key]; @@ -149,44 +163,54 @@ function parseAnswers(answers: Record): CoverageOptions { }; } -/** Omits options that match plugin defaults. */ -function formatPluginInit(options: CoverageOptions): string { +/** Returns true if lcov reporter is already present or was successfully added. */ +async function configureLcovReporter( + options: CoverageOptions, + tree?: PluginSetupTree, +): Promise { + const { framework, configFile } = options; + if (framework === 'other' || !configFile || !tree) { + return false; + } + const content = await tree.read(configFile); + if (content == null) { + return false; + } + if (hasLcovReporter(content, framework)) { + return true; + } + const modified = addLcovReporter(content, framework); + if (modified === content) { + return false; + } + await tree.write(configFile, modified); + return true; +} + +function formatPluginInit( + options: CoverageOptions, + lcovConfigured: boolean, +): string { const { reportPath, testCommand, types, continueOnFail } = options; - const args = [ + const hasCustomTypes = + types.length > 0 && types.length < ALL_COVERAGE_TYPES.length; + + const body = [ `reports: [${singleQuote(reportPath)}]`, testCommand ? `coverageToolCommand: { command: ${singleQuote(testCommand)} }` : '', - types.length > 0 && types.length < ALL_COVERAGE_TYPES.length + hasCustomTypes ? `coverageTypes: [${types.map(singleQuote).join(', ')}]` : '', continueOnFail ? '' : 'continueOnCommandFail: false', - ].filter(Boolean); - - return `await coveragePlugin({ - ${args.join(',\n ')}, - })`; -} + ] + .filter(Boolean) + .join(',\n '); -function resolveAdjustments( - options: CoverageOptions, -): Pick { - const { framework, configFile } = options; - if (framework === 'other' || !configFile) { - return {}; - } - return { - adjustments: [ - { - path: configFile, - transform: (content: string) => - hasLcovReporter(content, framework) - ? content - : addLcovReporter(content, framework), - }, - ], - }; + const init = `await coveragePlugin({\n ${body},\n })`; + return lcovConfigured ? init : `${LCOV_COMMENT}\n ${init}`; } async function isRecommended(targetDir: string): Promise { diff --git a/packages/plugin-coverage/src/lib/binding.unit.test.ts b/packages/plugin-coverage/src/lib/binding.unit.test.ts index 7564fc51f8..3b0a75e6f9 100644 --- a/packages/plugin-coverage/src/lib/binding.unit.test.ts +++ b/packages/plugin-coverage/src/lib/binding.unit.test.ts @@ -1,5 +1,5 @@ import { vol } from 'memfs'; -import type { PluginAnswer } from '@code-pushup/models'; +import type { PluginAnswer, PluginSetupTree } from '@code-pushup/models'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import { readJsonFile } from '@code-pushup/utils'; import { coverageSetupBinding } from './binding.js'; @@ -12,17 +12,36 @@ vi.mock('@code-pushup/utils', async () => { }; }); -function generateConfig(overrides: Record = {}) { - return coverageSetupBinding.generateConfig({ - 'coverage.framework': 'vitest', - 'coverage.configFile': '', - 'coverage.reportPath': 'coverage/lcov.info', - 'coverage.testCommand': 'npx vitest run --coverage.enabled', - 'coverage.types': ['function', 'branch', 'line'], - 'coverage.continueOnFail': true, - 'coverage.categories': true, - ...overrides, - }); +function generateConfig( + overrides: Record = {}, + tree?: PluginSetupTree, +) { + return coverageSetupBinding.generateConfig( + { + 'coverage.framework': 'vitest', + 'coverage.configFile': '', + 'coverage.reportPath': 'coverage/lcov.info', + 'coverage.testCommand': 'npx vitest run --coverage.enabled', + 'coverage.types': ['function', 'branch', 'line'], + 'coverage.continueOnFail': true, + 'coverage.categories': true, + ...overrides, + }, + tree, + ); +} + +function createMockTree( + files: Record = {}, +): PluginSetupTree & { written: Map } { + const written = new Map(); + return { + written, + read: async (filePath: string) => files[filePath] ?? null, + write: async (filePath: string, content: string) => { + written.set(filePath, content); + }, + }; } describe('coverageSetupBinding', () => { @@ -32,11 +51,11 @@ describe('coverageSetupBinding', () => { describe('isRecommended', () => { it.each([ - { file: 'vitest.config.ts' }, - { file: 'vite.config.mjs' }, - { file: 'vitest.workspace.cts' }, - { file: 'jest.config.js' }, - ])('should detect $file', async ({ file }) => { + 'vitest.config.ts', + 'vite.config.mjs', + 'vitest.workspace.cts', + 'jest.config.js', + ])('should detect %s', async file => { vol.fromJSON({ [file]: '' }, MEMFS_VOLUME); await expect( @@ -44,9 +63,9 @@ describe('coverageSetupBinding', () => { ).resolves.toBeTrue(); }); - it.each([{ field: 'dependencies' }, { field: 'devDependencies' }])( - 'should detect vitest in $field', - async ({ field }) => { + it.each(['dependencies', 'devDependencies'])( + 'should detect vitest in %s', + async field => { vi.mocked(readJsonFile).mockResolvedValue({ [field]: { vitest: '^2.0.0' }, }); @@ -57,9 +76,9 @@ describe('coverageSetupBinding', () => { }, ); - it.each([{ field: 'dependencies' }, { field: 'devDependencies' }])( - 'should detect jest in $field', - async ({ field }) => { + it.each(['dependencies', 'devDependencies'])( + 'should detect jest in %s', + async field => { vi.mocked(readJsonFile).mockResolvedValue({ [field]: { jest: '^29.0.0' }, }); @@ -117,77 +136,86 @@ describe('coverageSetupBinding', () => { }); describe('generateConfig', () => { - it('should generate vitest config', () => { - expect(generateConfig().pluginInit).toMatchInlineSnapshot(` - "await coveragePlugin({ + it('should generate vitest config', async () => { + const { pluginInit } = await generateConfig(); + expect(pluginInit).toMatchInlineSnapshot(` + "// NOTE: Ensure your test config includes "lcov" in coverage reporters. + await coveragePlugin({ reports: ['coverage/lcov.info'], coverageToolCommand: { command: 'npx vitest run --coverage.enabled' }, })" `); }); - it('should generate jest config', () => { - const { pluginInit } = generateConfig({ + it('should generate jest config', async () => { + const { pluginInit } = await generateConfig({ 'coverage.framework': 'jest', 'coverage.testCommand': 'npx jest --coverage', }); expect(pluginInit).toMatchInlineSnapshot(` - "await coveragePlugin({ + "// NOTE: Ensure your test config includes "lcov" in coverage reporters. + await coveragePlugin({ reports: ['coverage/lcov.info'], coverageToolCommand: { command: 'npx jest --coverage' }, })" `); }); - it('should omit coverageToolCommand when test command is empty', () => { - expect( - generateConfig({ 'coverage.testCommand': '' }).pluginInit, - ).not.toContain('coverageToolCommand'); + it('should omit coverageToolCommand when test command is empty', async () => { + const { pluginInit } = await generateConfig({ + 'coverage.testCommand': '', + }); + expect(pluginInit).not.toContain('coverageToolCommand'); }); - it('should use default report path when empty', () => { - expect( - generateConfig({ 'coverage.reportPath': '' }).pluginInit, - ).toContain("'coverage/lcov.info'"); + it('should use default report path when empty', async () => { + const { pluginInit } = await generateConfig({ + 'coverage.reportPath': '', + }); + expect(pluginInit).toContain("'coverage/lcov.info'"); }); - it('should use custom report path when provided', () => { - expect( - generateConfig({ 'coverage.reportPath': 'dist/coverage/lcov.info' }) - .pluginInit, - ).toContain("'dist/coverage/lcov.info'"); + it('should use custom report path when provided', async () => { + const { pluginInit } = await generateConfig({ + 'coverage.reportPath': 'dist/coverage/lcov.info', + }); + expect(pluginInit).toContain("'dist/coverage/lcov.info'"); }); - it('should omit coverageTypes when all selected', () => { - expect(generateConfig().pluginInit).not.toContain('coverageTypes'); + it('should omit coverageTypes when all selected', async () => { + const { pluginInit } = await generateConfig(); + expect(pluginInit).not.toContain('coverageTypes'); }); - it('should include coverageTypes when subset selected', () => { - expect( - generateConfig({ 'coverage.types': ['branch', 'line'] }).pluginInit, - ).toContain("coverageTypes: ['branch', 'line']"); + it('should include coverageTypes when subset selected', async () => { + const { pluginInit } = await generateConfig({ + 'coverage.types': ['branch', 'line'], + }); + expect(pluginInit).toContain("coverageTypes: ['branch', 'line']"); }); - it('should disable continueOnCommandFail when declined', () => { - expect( - generateConfig({ 'coverage.continueOnFail': false }).pluginInit, - ).toContain('continueOnCommandFail: false'); + it('should disable continueOnCommandFail when declined', async () => { + const { pluginInit } = await generateConfig({ + 'coverage.continueOnFail': false, + }); + expect(pluginInit).toContain('continueOnCommandFail: false'); }); - it('should omit continueOnCommandFail when default', () => { - expect(generateConfig().pluginInit).not.toContain( - 'continueOnCommandFail', - ); + it('should omit continueOnCommandFail when default', async () => { + const { pluginInit } = await generateConfig(); + expect(pluginInit).not.toContain('continueOnCommandFail'); }); - it('should omit categories when declined', () => { - expect( - generateConfig({ 'coverage.categories': false }).categories, - ).toBeUndefined(); + it('should omit categories when declined', async () => { + const { categories } = await generateConfig({ + 'coverage.categories': false, + }); + expect(categories).toBeUndefined(); }); - it('should import from @code-pushup/coverage-plugin', () => { - expect(generateConfig().imports).toEqual([ + it('should import from @code-pushup/coverage-plugin', async () => { + const { imports } = await generateConfig(); + expect(imports).toEqual([ { moduleSpecifier: '@code-pushup/coverage-plugin', defaultImport: 'coveragePlugin', @@ -196,33 +224,56 @@ describe('coverageSetupBinding', () => { }); }); - describe('adjustments', () => { - it('should target vitest config file', () => { - const { adjustments } = generateConfig({ - 'coverage.framework': 'vitest', - 'coverage.configFile': 'vitest.config.ts', + describe('lcov reporter configuration', () => { + const VITEST_ANSWERS = { + 'coverage.framework': 'vitest', + 'coverage.configFile': 'vitest.config.ts', + } as const; + + it('should not include comment when lcov is already present', async () => { + const tree = createMockTree({ + 'vitest.config.ts': + "export default { test: { coverage: { reporter: ['lcov'] } } };", }); - expect(adjustments).toHaveLength(1); - expect(adjustments![0]!.path).toBe('vitest.config.ts'); + const { pluginInit } = await generateConfig(VITEST_ANSWERS, tree); + expect(pluginInit).not.toContain('NOTE'); }); - it('should target jest config file', () => { - const { adjustments } = generateConfig({ - 'coverage.framework': 'jest', - 'coverage.configFile': 'jest.config.mjs', + it('should not include comment when lcov is successfully added', async () => { + const tree = createMockTree({ + 'vitest.config.ts': + "import { defineConfig } from 'vitest/config';\nexport default defineConfig({ test: { coverage: { reporter: ['text'] } } });", }); - expect(adjustments).toHaveLength(1); - expect(adjustments![0]!.path).toBe('jest.config.mjs'); + const { pluginInit } = await generateConfig(VITEST_ANSWERS, tree); + expect(pluginInit).not.toContain('NOTE'); + expect(tree.written.get('vitest.config.ts')).toContain('lcov'); }); - it('should skip for other framework', () => { - expect( - generateConfig({ 'coverage.framework': 'other' }).adjustments, - ).toBeUndefined(); + it('should include comment when framework is other', async () => { + const { pluginInit } = await generateConfig({ + 'coverage.framework': 'other', + }); + expect(pluginInit).toContain('NOTE'); }); - it('should skip when no config file detected', () => { - expect(generateConfig().adjustments).toBeUndefined(); + it('should include comment when config file cannot be read', async () => { + const tree = createMockTree({}); + const { pluginInit } = await generateConfig(VITEST_ANSWERS, tree); + expect(pluginInit).toContain('NOTE'); + }); + + it('should include comment when magicast cannot modify the file', async () => { + const tree = createMockTree({ + 'jest.config.js': "module.exports = { coverageReporters: ['text'] };", + }); + const { pluginInit } = await generateConfig( + { + 'coverage.framework': 'jest', + 'coverage.configFile': 'jest.config.js', + }, + tree, + ); + expect(pluginInit).toContain('NOTE'); }); }); }); From 4396fb45140f313d53d657f2735202b1c42cc0da Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Fri, 20 Mar 2026 09:06:00 -0400 Subject: [PATCH 5/6] refactor(plugin-coverage): use explicit calls in binding tests --- .../src/lib/binding.unit.test.ts | 95 +++++++++---------- 1 file changed, 44 insertions(+), 51 deletions(-) diff --git a/packages/plugin-coverage/src/lib/binding.unit.test.ts b/packages/plugin-coverage/src/lib/binding.unit.test.ts index 3b0a75e6f9..bb68472a1e 100644 --- a/packages/plugin-coverage/src/lib/binding.unit.test.ts +++ b/packages/plugin-coverage/src/lib/binding.unit.test.ts @@ -2,7 +2,7 @@ import { vol } from 'memfs'; import type { PluginAnswer, PluginSetupTree } from '@code-pushup/models'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import { readJsonFile } from '@code-pushup/utils'; -import { coverageSetupBinding } from './binding.js'; +import { coverageSetupBinding as binding } from './binding.js'; vi.mock('@code-pushup/utils', async () => { const actual = await vi.importActual('@code-pushup/utils'); @@ -12,24 +12,15 @@ vi.mock('@code-pushup/utils', async () => { }; }); -function generateConfig( - overrides: Record = {}, - tree?: PluginSetupTree, -) { - return coverageSetupBinding.generateConfig( - { - 'coverage.framework': 'vitest', - 'coverage.configFile': '', - 'coverage.reportPath': 'coverage/lcov.info', - 'coverage.testCommand': 'npx vitest run --coverage.enabled', - 'coverage.types': ['function', 'branch', 'line'], - 'coverage.continueOnFail': true, - 'coverage.categories': true, - ...overrides, - }, - tree, - ); -} +const defaultAnswers: Record = { + 'coverage.framework': 'vitest', + 'coverage.configFile': '', + 'coverage.reportPath': 'coverage/lcov.info', + 'coverage.testCommand': 'npx vitest run --coverage.enabled', + 'coverage.types': ['function', 'branch', 'line'], + 'coverage.continueOnFail': true, + 'coverage.categories': true, +}; function createMockTree( files: Record = {}, @@ -58,9 +49,7 @@ describe('coverageSetupBinding', () => { ])('should detect %s', async file => { vol.fromJSON({ [file]: '' }, MEMFS_VOLUME); - await expect( - coverageSetupBinding.isRecommended(MEMFS_VOLUME), - ).resolves.toBeTrue(); + await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeTrue(); }); it.each(['dependencies', 'devDependencies'])( @@ -70,9 +59,7 @@ describe('coverageSetupBinding', () => { [field]: { vitest: '^2.0.0' }, }); - await expect( - coverageSetupBinding.isRecommended(MEMFS_VOLUME), - ).resolves.toBeTrue(); + await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeTrue(); }, ); @@ -83,18 +70,14 @@ describe('coverageSetupBinding', () => { [field]: { jest: '^29.0.0' }, }); - await expect( - coverageSetupBinding.isRecommended(MEMFS_VOLUME), - ).resolves.toBeTrue(); + await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeTrue(); }, ); it('should not recommend when no test framework found', async () => { vi.mocked(readJsonFile).mockResolvedValue({}); - await expect( - coverageSetupBinding.isRecommended(MEMFS_VOLUME), - ).resolves.toBeFalse(); + await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeFalse(); }); }); @@ -103,7 +86,7 @@ describe('coverageSetupBinding', () => { vol.fromJSON({ 'vitest.config.ts': '' }, MEMFS_VOLUME); await expect( - coverageSetupBinding.prompts(MEMFS_VOLUME), + binding.prompts(MEMFS_VOLUME), ).resolves.toIncludeAllPartialMembers([ { key: 'coverage.framework', default: 'vitest' }, { key: 'coverage.configFile', default: 'vitest.config.ts' }, @@ -115,7 +98,7 @@ describe('coverageSetupBinding', () => { vol.fromJSON({ 'jest.config.js': '' }, MEMFS_VOLUME); await expect( - coverageSetupBinding.prompts(MEMFS_VOLUME), + binding.prompts(MEMFS_VOLUME), ).resolves.toIncludeAllPartialMembers([ { key: 'coverage.framework', default: 'jest' }, { key: 'coverage.configFile', default: 'jest.config.js' }, @@ -126,7 +109,7 @@ describe('coverageSetupBinding', () => { vi.mocked(readJsonFile).mockResolvedValue({}); await expect( - coverageSetupBinding.prompts(MEMFS_VOLUME), + binding.prompts(MEMFS_VOLUME), ).resolves.toIncludeAllPartialMembers([ { key: 'coverage.framework', default: 'other' }, { key: 'coverage.reportPath', default: '' }, @@ -137,7 +120,7 @@ describe('coverageSetupBinding', () => { describe('generateConfig', () => { it('should generate vitest config', async () => { - const { pluginInit } = await generateConfig(); + const { pluginInit } = await binding.generateConfig(defaultAnswers); expect(pluginInit).toMatchInlineSnapshot(` "// NOTE: Ensure your test config includes "lcov" in coverage reporters. await coveragePlugin({ @@ -148,7 +131,8 @@ describe('coverageSetupBinding', () => { }); it('should generate jest config', async () => { - const { pluginInit } = await generateConfig({ + const { pluginInit } = await binding.generateConfig({ + ...defaultAnswers, 'coverage.framework': 'jest', 'coverage.testCommand': 'npx jest --coverage', }); @@ -162,59 +146,65 @@ describe('coverageSetupBinding', () => { }); it('should omit coverageToolCommand when test command is empty', async () => { - const { pluginInit } = await generateConfig({ + const { pluginInit } = await binding.generateConfig({ + ...defaultAnswers, 'coverage.testCommand': '', }); expect(pluginInit).not.toContain('coverageToolCommand'); }); it('should use default report path when empty', async () => { - const { pluginInit } = await generateConfig({ + const { pluginInit } = await binding.generateConfig({ + ...defaultAnswers, 'coverage.reportPath': '', }); expect(pluginInit).toContain("'coverage/lcov.info'"); }); it('should use custom report path when provided', async () => { - const { pluginInit } = await generateConfig({ + const { pluginInit } = await binding.generateConfig({ + ...defaultAnswers, 'coverage.reportPath': 'dist/coverage/lcov.info', }); expect(pluginInit).toContain("'dist/coverage/lcov.info'"); }); it('should omit coverageTypes when all selected', async () => { - const { pluginInit } = await generateConfig(); + const { pluginInit } = await binding.generateConfig(defaultAnswers); expect(pluginInit).not.toContain('coverageTypes'); }); it('should include coverageTypes when subset selected', async () => { - const { pluginInit } = await generateConfig({ + const { pluginInit } = await binding.generateConfig({ + ...defaultAnswers, 'coverage.types': ['branch', 'line'], }); expect(pluginInit).toContain("coverageTypes: ['branch', 'line']"); }); it('should disable continueOnCommandFail when declined', async () => { - const { pluginInit } = await generateConfig({ + const { pluginInit } = await binding.generateConfig({ + ...defaultAnswers, 'coverage.continueOnFail': false, }); expect(pluginInit).toContain('continueOnCommandFail: false'); }); it('should omit continueOnCommandFail when default', async () => { - const { pluginInit } = await generateConfig(); + const { pluginInit } = await binding.generateConfig(defaultAnswers); expect(pluginInit).not.toContain('continueOnCommandFail'); }); it('should omit categories when declined', async () => { - const { categories } = await generateConfig({ + const { categories } = await binding.generateConfig({ + ...defaultAnswers, 'coverage.categories': false, }); expect(categories).toBeUndefined(); }); it('should import from @code-pushup/coverage-plugin', async () => { - const { imports } = await generateConfig(); + const { imports } = await binding.generateConfig(defaultAnswers); expect(imports).toEqual([ { moduleSpecifier: '@code-pushup/coverage-plugin', @@ -225,7 +215,8 @@ describe('coverageSetupBinding', () => { }); describe('lcov reporter configuration', () => { - const VITEST_ANSWERS = { + const vitestAnswers = { + ...defaultAnswers, 'coverage.framework': 'vitest', 'coverage.configFile': 'vitest.config.ts', } as const; @@ -235,7 +226,7 @@ describe('coverageSetupBinding', () => { 'vitest.config.ts': "export default { test: { coverage: { reporter: ['lcov'] } } };", }); - const { pluginInit } = await generateConfig(VITEST_ANSWERS, tree); + const { pluginInit } = await binding.generateConfig(vitestAnswers, tree); expect(pluginInit).not.toContain('NOTE'); }); @@ -244,13 +235,14 @@ describe('coverageSetupBinding', () => { 'vitest.config.ts': "import { defineConfig } from 'vitest/config';\nexport default defineConfig({ test: { coverage: { reporter: ['text'] } } });", }); - const { pluginInit } = await generateConfig(VITEST_ANSWERS, tree); + const { pluginInit } = await binding.generateConfig(vitestAnswers, tree); expect(pluginInit).not.toContain('NOTE'); expect(tree.written.get('vitest.config.ts')).toContain('lcov'); }); it('should include comment when framework is other', async () => { - const { pluginInit } = await generateConfig({ + const { pluginInit } = await binding.generateConfig({ + ...defaultAnswers, 'coverage.framework': 'other', }); expect(pluginInit).toContain('NOTE'); @@ -258,7 +250,7 @@ describe('coverageSetupBinding', () => { it('should include comment when config file cannot be read', async () => { const tree = createMockTree({}); - const { pluginInit } = await generateConfig(VITEST_ANSWERS, tree); + const { pluginInit } = await binding.generateConfig(vitestAnswers, tree); expect(pluginInit).toContain('NOTE'); }); @@ -266,8 +258,9 @@ describe('coverageSetupBinding', () => { const tree = createMockTree({ 'jest.config.js': "module.exports = { coverageReporters: ['text'] };", }); - const { pluginInit } = await generateConfig( + const { pluginInit } = await binding.generateConfig( { + ...defaultAnswers, 'coverage.framework': 'jest', 'coverage.configFile': 'jest.config.js', }, From c0584a53df1c958abb0fe95fcb2973cf35166e38 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Fri, 20 Mar 2026 09:08:15 -0400 Subject: [PATCH 6/6] refactor(models): require tree param in generateConfig --- packages/models/src/lib/plugin-setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/models/src/lib/plugin-setup.ts b/packages/models/src/lib/plugin-setup.ts index 704d591e26..8b6cfb8cf9 100644 --- a/packages/models/src/lib/plugin-setup.ts +++ b/packages/models/src/lib/plugin-setup.ts @@ -77,6 +77,6 @@ export type PluginSetupBinding = { isRecommended?: (targetDir: string) => Promise; generateConfig: ( answers: Record, - tree?: PluginSetupTree, + tree: PluginSetupTree, ) => PluginCodegenResult | Promise; };