Skip to content

Commit

Permalink
feat(plugin-coverage): implement plugin configuration
Browse files Browse the repository at this point in the history
  • Loading branch information
Tlacenka committed Feb 5, 2024
1 parent 8b18a0f commit 513c518
Show file tree
Hide file tree
Showing 7 changed files with 335 additions and 0 deletions.
4 changes: 4 additions & 0 deletions packages/plugin-coverage/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { coveragePlugin } from './lib/coverage-plugin';

export default coveragePlugin;
export type { CoveragePluginConfig } from './lib/config';
33 changes: 33 additions & 0 deletions packages/plugin-coverage/src/lib/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { z } from 'zod';

export const coverageTypeSchema = z.enum(['function', 'branch', 'line']);
export type CoverageType = z.infer<typeof coverageTypeSchema>;

export const coveragePluginConfigSchema = z.object({
coverageToolCommand: z
.object({
command: z
.string({ description: 'Command to run coverage tool.' })
.min(1),
args: z
.array(z.string(), {
description: 'Arguments to be passed to the coverage tool.',
})
.optional(),
})
.optional(),
coverageType: z.array(coverageTypeSchema).min(1),
reports: z
.array(z.string().includes('lcov'), {
description:
'Path to all code coverage report files. Only LCOV format is supported for now.',
})
.min(1),
perfectScoreThreshold: z
.number({ description: 'Score will be 100 for this coverage and above.' })
.min(1)
.max(100)
.optional(),
});

export type CoveragePluginConfig = z.infer<typeof coveragePluginConfigSchema>;
76 changes: 76 additions & 0 deletions packages/plugin-coverage/src/lib/config.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { CoveragePluginConfig, coveragePluginConfigSchema } from './config';

describe('coveragePluginConfigSchema', () => {
it('accepts a code coverage configuration with all entities', () => {
expect(() =>
coveragePluginConfigSchema.parse({
coverageType: ['branch', 'function'],
reports: ['coverage/cli/lcov.info'],
coverageToolCommand: {
command: 'npx nx run-many',
args: ['-t', 'test', '--coverage'],
},
perfectScoreThreshold: 85,
} satisfies CoveragePluginConfig),
).not.toThrow();
});

it('accepts a minimal code coverage configuration', () => {
expect(() =>
coveragePluginConfigSchema.parse({
coverageType: ['line'],
reports: ['coverage/cli/lcov.info'],
} satisfies CoveragePluginConfig),
).not.toThrow();
});

it('throws for no coverage type', () => {
expect(() =>
coveragePluginConfigSchema.parse({
coverageType: [],
reports: ['coverage/cli/lcov.info'],
} satisfies CoveragePluginConfig),
).toThrow('too_small');
});

it('throws for no report', () => {
expect(() =>
coveragePluginConfigSchema.parse({
coverageType: ['branch'],
reports: [],
} satisfies CoveragePluginConfig),
).toThrow('too_small');
});

it('throws for unsupported report format', () => {
expect(() =>
coveragePluginConfigSchema.parse({
coverageType: ['line'],
reports: ['coverage/cli/coverage-final.json'],
}),
).toThrow(/Invalid input: must include.+lcov/);
});

it('throws for missing command', () => {
expect(() =>
coveragePluginConfigSchema.parse({
coverageType: ['line'],
reports: ['coverage/cli/lcov.info'],
coverageToolCommand: {
args: ['npx', 'nx', 'run-many', '-t', 'test', '--coverage'],
},
}),
).toThrow('invalid_type');
});

it('throws for invalid score threshold', () => {
expect(() =>
coveragePluginConfigSchema.parse({
coverageType: ['line'],
reports: ['coverage/cli/lcov.info'],
perfectScoreThreshold: 110,
} satisfies CoveragePluginConfig),
).toThrow('too_big');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { CoveragePluginConfig } from './config';
import { coveragePlugin } from './coverage-plugin';

describe('coveragePluginConfigSchema', () => {
it('should initialise a Code coverage plugin', () => {
expect(
coveragePlugin({
coverageType: ['function'],
reports: [join('packages', 'plugin-coverage', 'mocks', 'lcov.info')],
}),
).toStrictEqual(
expect.objectContaining({
slug: 'coverage',
title: 'Code coverage',
audits: expect.any(Array),
}),
);
});

it('should generate audits from coverage types', () => {
expect(
coveragePlugin({
coverageType: ['function', 'branch'],
reports: [join('packages', 'plugin-coverage', 'mocks', 'lcov.info')],
}),
).toStrictEqual(
expect.objectContaining({
audits: [
{
slug: 'function-coverage',
title: 'function coverage',
description: 'function coverage percentage on the project',
},
expect.objectContaining({ slug: 'branch-coverage' }),
],
}),
);
});

it('should assign RunnerConfig when a command is passed', () => {
expect(
coveragePlugin({
coverageType: ['line'],
reports: [join('packages', 'plugin-coverage', 'mocks', 'lcov.info')],
coverageToolCommand: {
command: 'npm run-many',
args: ['-t', 'test', '--coverage'],
},
} satisfies CoveragePluginConfig),
).toStrictEqual(
expect.objectContaining({
slug: 'coverage',
runner: {
command: 'npm run-many',
args: ['-t', 'test', '--coverage'],
outputFile: expect.stringContaining('runner-output.json'),
outputTransform: expect.any(Function),
},
}),
);
});

it('should assign a RunnerFunction when only reports are passed', () => {
expect(
coveragePlugin({
coverageType: ['line'],
reports: [join('packages', 'plugin-coverage', 'mocks', 'lcov.info')],
} satisfies CoveragePluginConfig),
).toStrictEqual(
expect.objectContaining({
slug: 'coverage',
runner: expect.any(Function),
}),
);
});
});
81 changes: 81 additions & 0 deletions packages/plugin-coverage/src/lib/coverage-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { join } from 'node:path';
import type {
Audit,
PluginConfig,
RunnerConfig,
RunnerFunction,
} from '@code-pushup/models';
import { pluginWorkDir } from '@code-pushup/utils';
import { name, version } from '../../package.json';
import { CoveragePluginConfig, coveragePluginConfigSchema } from './config';
import { lcovResultsToAuditOutputs } from './runner/lcov/runner';
import { applyMaxScoreAboveThreshold } from './utils';

export const RUNNER_OUTPUT_PATH = join(
pluginWorkDir('coverage'),
'runner-output.json',
);

/**
* Instantiates Code PushUp code coverage plugin for core config.
*
* @example
* import coveragePlugin from '@code-pushup/coverage-plugin'
*
* export default {
* // ... core config ...
* plugins: [
* // ... other plugins ...
* await coveragePlugin({
* coverageType: ['function', 'line'],
* reports: ['coverage/cli/lcov.info']
* })
* ]
* }
*
* @returns Plugin configuration as a promise.
*/
export function coveragePlugin(config: CoveragePluginConfig): PluginConfig {
const { reports, perfectScoreThreshold, coverageType, coverageToolCommand } =
coveragePluginConfigSchema.parse(config);

const audits = coverageType.map(
type =>
({
slug: `${type}-coverage`,
title: `${type} coverage`,
description: `${type} coverage percentage on the project`,
} satisfies Audit),
);

const getAuditOutputs = async () =>
perfectScoreThreshold
? applyMaxScoreAboveThreshold(
await lcovResultsToAuditOutputs(reports, coverageType),
perfectScoreThreshold,
)
: await lcovResultsToAuditOutputs(reports, coverageType);

// if coverage results are provided, only convert them to AuditOutputs
// if not, run coverage command and then run result conversion
const runner: RunnerConfig | RunnerFunction =
coverageToolCommand == null
? getAuditOutputs
: ({
command: coverageToolCommand.command,
args: coverageToolCommand.args,
outputFile: RUNNER_OUTPUT_PATH,
outputTransform: getAuditOutputs,
} satisfies RunnerConfig);
return {
slug: 'coverage',
title: 'Code coverage',
icon: 'folder-coverage-open',
description: 'Official Code PushUp code coverage plugin',
docsUrl: 'https://www.softwaretestinghelp.com/code-coverage-tutorial/',
packageName: name,
version,
audits,
runner,
} satisfies PluginConfig;
}
16 changes: 16 additions & 0 deletions packages/plugin-coverage/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { AuditOutputs } from '@code-pushup/models';

/**
* Since more code coverage does not necessarily mean better score, this optional override allows for defining custom coverage goals.
* @param outputs original results
* @param threshold threshold above which the score is to be 1
* @returns Outputs with overriden score (not value) to 1 if it reached a defined threshold.
*/
export function applyMaxScoreAboveThreshold(
outputs: AuditOutputs,
threshold: number,
): AuditOutputs {
return outputs.map(output =>
output.score >= threshold ? { ...output, score: 1 } : output,
);
}
47 changes: 47 additions & 0 deletions packages/plugin-coverage/src/lib/utils.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import type { AuditOutput } from '@code-pushup/models';
import { applyMaxScoreAboveThreshold } from './utils';

describe('applyMaxScoreAboveThreshold', () => {
it('should transform score above threshold to maximum', () => {
expect(
applyMaxScoreAboveThreshold(
[
{
slug: 'branch-coverage',
value: 75,
score: 0.75,
} satisfies AuditOutput,
],
0.7,
),
).toEqual([
{
slug: 'branch-coverage',
value: 75,
score: 1,
} satisfies AuditOutput,
]);
});

it('should leave score below threshold untouched', () => {
expect(
applyMaxScoreAboveThreshold(
[
{
slug: 'line-coverage',
value: 60,
score: 0.6,
} satisfies AuditOutput,
],
0.7,
),
).toEqual([
{
slug: 'line-coverage',
value: 60,
score: 0.6,
} satisfies AuditOutput,
]);
});
});

0 comments on commit 513c518

Please sign in to comment.