diff --git a/package-lock.json b/package-lock.json index 15eb95757..d4651d4ec 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 8e677492a..3fbe99e93 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 8b3d2fed5..cb91fad4f 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 3bd9f6ffb..ddec67b60 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 160d2b2dc..97faa3c84 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/types.ts b/packages/create-cli/src/lib/setup/types.ts index fbc04c49e..5891f163e 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 a13e8819a..af743066f 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,19 +58,19 @@ 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 => ({ scope: binding.scope ?? 'project', - result: await resolveBinding(binding, cliArgs, targetDir), + result: await resolveBinding(binding, cliArgs, targetDir, tree), }), ); 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 @@ -103,13 +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); + return binding.generateConfig(answers, tree); } async function writeStandaloneConfig( diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index b12a5d344..76aa96092 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 1b684016e..8b6cfb8cf 100644 --- a/packages/models/src/lib/plugin-setup.ts +++ b/packages/models/src/lib/plugin-setup.ts @@ -47,13 +47,19 @@ 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 a plugin binding contributes to the generated config. */ export type PluginCodegenResult = { imports: ImportDeclarationStructure[]; pluginInit: string; categories?: CategoryConfig[]; }; +/** Minimal file system abstraction passed to plugin bindings. */ +export type PluginSetupTree = { + read: (path: string) => Promise; + write: (path: string, content: string) => Promise; +}; + /** * Defines how a plugin integrates with the setup wizard. * @@ -71,5 +77,6 @@ export type PluginSetupBinding = { isRecommended?: (targetDir: string) => Promise; generateConfig: ( answers: Record, - ) => PluginCodegenResult; + tree: PluginSetupTree, + ) => PluginCodegenResult | Promise; }; diff --git a/packages/plugin-coverage/package.json b/packages/plugin-coverage/package.json index d1f9928a9..156dd1af4 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 50a0bb48b..7dadfff93 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 000000000..1abbc7965 --- /dev/null +++ b/packages/plugin-coverage/src/lib/binding.ts @@ -0,0 +1,270 @@ +import { readdir } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import type { + CategoryConfig, + PluginAnswer, + PluginSetupBinding, + PluginSetupTree, +} from '@code-pushup/models'; +import { + hasDependency, + pluralize, + 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 LCOV_COMMENT = + '// NOTE: Ensure your test config includes "lcov" in coverage reporters.'; + +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, + // eslint-disable-next-line max-lines-per-function + 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: pluralize(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: 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, lcovConfigured), + ...(args.categories ? { categories: CATEGORIES } : {}), + }; + }, +} satisfies PluginSetupBinding; + +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, + }; +} + +/** 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 hasCustomTypes = + types.length > 0 && types.length < ALL_COVERAGE_TYPES.length; + + const body = [ + `reports: [${singleQuote(reportPath)}]`, + testCommand + ? `coverageToolCommand: { command: ${singleQuote(testCommand)} }` + : '', + hasCustomTypes + ? `coverageTypes: [${types.map(singleQuote).join(', ')}]` + : '', + continueOnFail ? '' : 'continueOnCommandFail: false', + ] + .filter(Boolean) + .join(',\n '); + + const init = `await coveragePlugin({\n ${body},\n })`; + return lcovConfigured ? init : `${LCOV_COMMENT}\n ${init}`; +} + +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 000000000..bb68472a1 --- /dev/null +++ b/packages/plugin-coverage/src/lib/binding.unit.test.ts @@ -0,0 +1,272 @@ +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 as binding } 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')), + }; +}); + +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 = {}, +): 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', () => { + beforeEach(() => { + vol.fromJSON({ '.gitkeep': '' }, MEMFS_VOLUME); + }); + + describe('isRecommended', () => { + it.each([ + 'vitest.config.ts', + 'vite.config.mjs', + 'vitest.workspace.cts', + 'jest.config.js', + ])('should detect %s', async file => { + vol.fromJSON({ [file]: '' }, MEMFS_VOLUME); + + await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeTrue(); + }); + + it.each(['dependencies', 'devDependencies'])( + 'should detect vitest in %s', + async field => { + vi.mocked(readJsonFile).mockResolvedValue({ + [field]: { vitest: '^2.0.0' }, + }); + + await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeTrue(); + }, + ); + + it.each(['dependencies', 'devDependencies'])( + 'should detect jest in %s', + async field => { + vi.mocked(readJsonFile).mockResolvedValue({ + [field]: { jest: '^29.0.0' }, + }); + + await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeTrue(); + }, + ); + + it('should not recommend when no test framework found', async () => { + vi.mocked(readJsonFile).mockResolvedValue({}); + + await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeFalse(); + }); + }); + + describe('prompts', () => { + it('should detect vitest defaults', async () => { + vol.fromJSON({ 'vitest.config.ts': '' }, MEMFS_VOLUME); + + await expect( + binding.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( + binding.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( + binding.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', async () => { + const { pluginInit } = await binding.generateConfig(defaultAnswers); + 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', async () => { + const { pluginInit } = await binding.generateConfig({ + ...defaultAnswers, + 'coverage.framework': 'jest', + 'coverage.testCommand': 'npx jest --coverage', + }); + expect(pluginInit).toMatchInlineSnapshot(` + "// 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', async () => { + 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 binding.generateConfig({ + ...defaultAnswers, + 'coverage.reportPath': '', + }); + expect(pluginInit).toContain("'coverage/lcov.info'"); + }); + + it('should use custom report path when provided', async () => { + 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 binding.generateConfig(defaultAnswers); + expect(pluginInit).not.toContain('coverageTypes'); + }); + + it('should include coverageTypes when subset selected', async () => { + 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 binding.generateConfig({ + ...defaultAnswers, + 'coverage.continueOnFail': false, + }); + expect(pluginInit).toContain('continueOnCommandFail: false'); + }); + + it('should omit continueOnCommandFail when default', async () => { + const { pluginInit } = await binding.generateConfig(defaultAnswers); + expect(pluginInit).not.toContain('continueOnCommandFail'); + }); + + it('should omit categories when declined', async () => { + const { categories } = await binding.generateConfig({ + ...defaultAnswers, + 'coverage.categories': false, + }); + expect(categories).toBeUndefined(); + }); + + it('should import from @code-pushup/coverage-plugin', async () => { + const { imports } = await binding.generateConfig(defaultAnswers); + expect(imports).toEqual([ + { + moduleSpecifier: '@code-pushup/coverage-plugin', + defaultImport: 'coveragePlugin', + }, + ]); + }); + }); + + describe('lcov reporter configuration', () => { + const vitestAnswers = { + ...defaultAnswers, + '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'] } } };", + }); + const { pluginInit } = await binding.generateConfig(vitestAnswers, tree); + expect(pluginInit).not.toContain('NOTE'); + }); + + 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'] } } });", + }); + 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 binding.generateConfig({ + ...defaultAnswers, + 'coverage.framework': 'other', + }); + expect(pluginInit).toContain('NOTE'); + }); + + it('should include comment when config file cannot be read', async () => { + const tree = createMockTree({}); + const { pluginInit } = await binding.generateConfig(vitestAnswers, 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 binding.generateConfig( + { + ...defaultAnswers, + 'coverage.framework': 'jest', + 'coverage.configFile': 'jest.config.js', + }, + tree, + ); + expect(pluginInit).toContain('NOTE'); + }); + }); +}); 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 000000000..29a3899ae --- /dev/null +++ b/packages/plugin-coverage/src/lib/config-file.ts @@ -0,0 +1,72 @@ +import { generateCode, parseModule } from 'magicast'; +import { deepMergeObject, getDefaultExportOptions } from 'magicast/helpers'; + +const VITEST_DEFAULTS = ['text', 'html', 'clover', 'json']; + +export function hasLcovReporter(content: string, framework: string): boolean { + switch (framework) { + case 'vitest': + return /['"]lcov['"]/.test(content) && content.includes('reporter'); + case 'jest': + return ( + !content.includes('coverageReporters') || /['"]lcov['"]/.test(content) + ); + default: + return false; + } +} + +export function addLcovReporter(content: string, framework: string): string { + 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 config = getDefaultExportOptions(mod); + const reporter = config['test']?.['coverage']?.['reporter']; + const base = reporter?.['length'] ? [...reporter] : VITEST_DEFAULTS; + deepMergeObject( + config, + buildNestedObject(['test', 'coverage', 'reporter'], [...base, 'lcov']), + ); + return generateCode(mod).code; + } catch { + return content; + } +} + +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( + 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 000000000..b55c92882 --- /dev/null +++ b/packages/plugin-coverage/src/lib/config-file.unit.test.ts @@ -0,0 +1,120 @@ +import { + addLcovReporter, + buildNestedObject, + hasLcovReporter, +} from './config-file.js'; + +describe('hasLcovReporter', () => { + it('should return true for vitest reporter with lcov', () => { + expect(hasLcovReporter("reporter: ['text', 'lcov']", 'vitest')).toBeTrue(); + }); + + 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(); + }); +}); + +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: ['text', 'html', 'clover', 'json', '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 not modify jest config when coverageReporters is missing (lcov enabled by default)', () => { + const input = `export default { + testEnvironment: 'node', +}; +`; + expect(addLcovReporter(input, 'jest')).toBe(input); + }); + + 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']); + }); +});