Skip to content

Commit 513c518

Browse files
committed
feat(plugin-coverage): implement plugin configuration
1 parent 8b18a0f commit 513c518

File tree

7 files changed

+335
-0
lines changed

7 files changed

+335
-0
lines changed

packages/plugin-coverage/src/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { coveragePlugin } from './lib/coverage-plugin';
2+
3+
export default coveragePlugin;
4+
export type { CoveragePluginConfig } from './lib/config';
+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { z } from 'zod';
2+
3+
export const coverageTypeSchema = z.enum(['function', 'branch', 'line']);
4+
export type CoverageType = z.infer<typeof coverageTypeSchema>;
5+
6+
export const coveragePluginConfigSchema = z.object({
7+
coverageToolCommand: z
8+
.object({
9+
command: z
10+
.string({ description: 'Command to run coverage tool.' })
11+
.min(1),
12+
args: z
13+
.array(z.string(), {
14+
description: 'Arguments to be passed to the coverage tool.',
15+
})
16+
.optional(),
17+
})
18+
.optional(),
19+
coverageType: z.array(coverageTypeSchema).min(1),
20+
reports: z
21+
.array(z.string().includes('lcov'), {
22+
description:
23+
'Path to all code coverage report files. Only LCOV format is supported for now.',
24+
})
25+
.min(1),
26+
perfectScoreThreshold: z
27+
.number({ description: 'Score will be 100 for this coverage and above.' })
28+
.min(1)
29+
.max(100)
30+
.optional(),
31+
});
32+
33+
export type CoveragePluginConfig = z.infer<typeof coveragePluginConfigSchema>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { CoveragePluginConfig, coveragePluginConfigSchema } from './config';
3+
4+
describe('coveragePluginConfigSchema', () => {
5+
it('accepts a code coverage configuration with all entities', () => {
6+
expect(() =>
7+
coveragePluginConfigSchema.parse({
8+
coverageType: ['branch', 'function'],
9+
reports: ['coverage/cli/lcov.info'],
10+
coverageToolCommand: {
11+
command: 'npx nx run-many',
12+
args: ['-t', 'test', '--coverage'],
13+
},
14+
perfectScoreThreshold: 85,
15+
} satisfies CoveragePluginConfig),
16+
).not.toThrow();
17+
});
18+
19+
it('accepts a minimal code coverage configuration', () => {
20+
expect(() =>
21+
coveragePluginConfigSchema.parse({
22+
coverageType: ['line'],
23+
reports: ['coverage/cli/lcov.info'],
24+
} satisfies CoveragePluginConfig),
25+
).not.toThrow();
26+
});
27+
28+
it('throws for no coverage type', () => {
29+
expect(() =>
30+
coveragePluginConfigSchema.parse({
31+
coverageType: [],
32+
reports: ['coverage/cli/lcov.info'],
33+
} satisfies CoveragePluginConfig),
34+
).toThrow('too_small');
35+
});
36+
37+
it('throws for no report', () => {
38+
expect(() =>
39+
coveragePluginConfigSchema.parse({
40+
coverageType: ['branch'],
41+
reports: [],
42+
} satisfies CoveragePluginConfig),
43+
).toThrow('too_small');
44+
});
45+
46+
it('throws for unsupported report format', () => {
47+
expect(() =>
48+
coveragePluginConfigSchema.parse({
49+
coverageType: ['line'],
50+
reports: ['coverage/cli/coverage-final.json'],
51+
}),
52+
).toThrow(/Invalid input: must include.+lcov/);
53+
});
54+
55+
it('throws for missing command', () => {
56+
expect(() =>
57+
coveragePluginConfigSchema.parse({
58+
coverageType: ['line'],
59+
reports: ['coverage/cli/lcov.info'],
60+
coverageToolCommand: {
61+
args: ['npx', 'nx', 'run-many', '-t', 'test', '--coverage'],
62+
},
63+
}),
64+
).toThrow('invalid_type');
65+
});
66+
67+
it('throws for invalid score threshold', () => {
68+
expect(() =>
69+
coveragePluginConfigSchema.parse({
70+
coverageType: ['line'],
71+
reports: ['coverage/cli/lcov.info'],
72+
perfectScoreThreshold: 110,
73+
} satisfies CoveragePluginConfig),
74+
).toThrow('too_big');
75+
});
76+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { join } from 'node:path';
2+
import { describe, expect, it } from 'vitest';
3+
import { CoveragePluginConfig } from './config';
4+
import { coveragePlugin } from './coverage-plugin';
5+
6+
describe('coveragePluginConfigSchema', () => {
7+
it('should initialise a Code coverage plugin', () => {
8+
expect(
9+
coveragePlugin({
10+
coverageType: ['function'],
11+
reports: [join('packages', 'plugin-coverage', 'mocks', 'lcov.info')],
12+
}),
13+
).toStrictEqual(
14+
expect.objectContaining({
15+
slug: 'coverage',
16+
title: 'Code coverage',
17+
audits: expect.any(Array),
18+
}),
19+
);
20+
});
21+
22+
it('should generate audits from coverage types', () => {
23+
expect(
24+
coveragePlugin({
25+
coverageType: ['function', 'branch'],
26+
reports: [join('packages', 'plugin-coverage', 'mocks', 'lcov.info')],
27+
}),
28+
).toStrictEqual(
29+
expect.objectContaining({
30+
audits: [
31+
{
32+
slug: 'function-coverage',
33+
title: 'function coverage',
34+
description: 'function coverage percentage on the project',
35+
},
36+
expect.objectContaining({ slug: 'branch-coverage' }),
37+
],
38+
}),
39+
);
40+
});
41+
42+
it('should assign RunnerConfig when a command is passed', () => {
43+
expect(
44+
coveragePlugin({
45+
coverageType: ['line'],
46+
reports: [join('packages', 'plugin-coverage', 'mocks', 'lcov.info')],
47+
coverageToolCommand: {
48+
command: 'npm run-many',
49+
args: ['-t', 'test', '--coverage'],
50+
},
51+
} satisfies CoveragePluginConfig),
52+
).toStrictEqual(
53+
expect.objectContaining({
54+
slug: 'coverage',
55+
runner: {
56+
command: 'npm run-many',
57+
args: ['-t', 'test', '--coverage'],
58+
outputFile: expect.stringContaining('runner-output.json'),
59+
outputTransform: expect.any(Function),
60+
},
61+
}),
62+
);
63+
});
64+
65+
it('should assign a RunnerFunction when only reports are passed', () => {
66+
expect(
67+
coveragePlugin({
68+
coverageType: ['line'],
69+
reports: [join('packages', 'plugin-coverage', 'mocks', 'lcov.info')],
70+
} satisfies CoveragePluginConfig),
71+
).toStrictEqual(
72+
expect.objectContaining({
73+
slug: 'coverage',
74+
runner: expect.any(Function),
75+
}),
76+
);
77+
});
78+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { join } from 'node:path';
2+
import type {
3+
Audit,
4+
PluginConfig,
5+
RunnerConfig,
6+
RunnerFunction,
7+
} from '@code-pushup/models';
8+
import { pluginWorkDir } from '@code-pushup/utils';
9+
import { name, version } from '../../package.json';
10+
import { CoveragePluginConfig, coveragePluginConfigSchema } from './config';
11+
import { lcovResultsToAuditOutputs } from './runner/lcov/runner';
12+
import { applyMaxScoreAboveThreshold } from './utils';
13+
14+
export const RUNNER_OUTPUT_PATH = join(
15+
pluginWorkDir('coverage'),
16+
'runner-output.json',
17+
);
18+
19+
/**
20+
* Instantiates Code PushUp code coverage plugin for core config.
21+
*
22+
* @example
23+
* import coveragePlugin from '@code-pushup/coverage-plugin'
24+
*
25+
* export default {
26+
* // ... core config ...
27+
* plugins: [
28+
* // ... other plugins ...
29+
* await coveragePlugin({
30+
* coverageType: ['function', 'line'],
31+
* reports: ['coverage/cli/lcov.info']
32+
* })
33+
* ]
34+
* }
35+
*
36+
* @returns Plugin configuration as a promise.
37+
*/
38+
export function coveragePlugin(config: CoveragePluginConfig): PluginConfig {
39+
const { reports, perfectScoreThreshold, coverageType, coverageToolCommand } =
40+
coveragePluginConfigSchema.parse(config);
41+
42+
const audits = coverageType.map(
43+
type =>
44+
({
45+
slug: `${type}-coverage`,
46+
title: `${type} coverage`,
47+
description: `${type} coverage percentage on the project`,
48+
} satisfies Audit),
49+
);
50+
51+
const getAuditOutputs = async () =>
52+
perfectScoreThreshold
53+
? applyMaxScoreAboveThreshold(
54+
await lcovResultsToAuditOutputs(reports, coverageType),
55+
perfectScoreThreshold,
56+
)
57+
: await lcovResultsToAuditOutputs(reports, coverageType);
58+
59+
// if coverage results are provided, only convert them to AuditOutputs
60+
// if not, run coverage command and then run result conversion
61+
const runner: RunnerConfig | RunnerFunction =
62+
coverageToolCommand == null
63+
? getAuditOutputs
64+
: ({
65+
command: coverageToolCommand.command,
66+
args: coverageToolCommand.args,
67+
outputFile: RUNNER_OUTPUT_PATH,
68+
outputTransform: getAuditOutputs,
69+
} satisfies RunnerConfig);
70+
return {
71+
slug: 'coverage',
72+
title: 'Code coverage',
73+
icon: 'folder-coverage-open',
74+
description: 'Official Code PushUp code coverage plugin',
75+
docsUrl: 'https://www.softwaretestinghelp.com/code-coverage-tutorial/',
76+
packageName: name,
77+
version,
78+
audits,
79+
runner,
80+
} satisfies PluginConfig;
81+
}
+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { AuditOutputs } from '@code-pushup/models';
2+
3+
/**
4+
* Since more code coverage does not necessarily mean better score, this optional override allows for defining custom coverage goals.
5+
* @param outputs original results
6+
* @param threshold threshold above which the score is to be 1
7+
* @returns Outputs with overriden score (not value) to 1 if it reached a defined threshold.
8+
*/
9+
export function applyMaxScoreAboveThreshold(
10+
outputs: AuditOutputs,
11+
threshold: number,
12+
): AuditOutputs {
13+
return outputs.map(output =>
14+
output.score >= threshold ? { ...output, score: 1 } : output,
15+
);
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { AuditOutput } from '@code-pushup/models';
3+
import { applyMaxScoreAboveThreshold } from './utils';
4+
5+
describe('applyMaxScoreAboveThreshold', () => {
6+
it('should transform score above threshold to maximum', () => {
7+
expect(
8+
applyMaxScoreAboveThreshold(
9+
[
10+
{
11+
slug: 'branch-coverage',
12+
value: 75,
13+
score: 0.75,
14+
} satisfies AuditOutput,
15+
],
16+
0.7,
17+
),
18+
).toEqual([
19+
{
20+
slug: 'branch-coverage',
21+
value: 75,
22+
score: 1,
23+
} satisfies AuditOutput,
24+
]);
25+
});
26+
27+
it('should leave score below threshold untouched', () => {
28+
expect(
29+
applyMaxScoreAboveThreshold(
30+
[
31+
{
32+
slug: 'line-coverage',
33+
value: 60,
34+
score: 0.6,
35+
} satisfies AuditOutput,
36+
],
37+
0.7,
38+
),
39+
).toEqual([
40+
{
41+
slug: 'line-coverage',
42+
value: 60,
43+
score: 0.6,
44+
} satisfies AuditOutput,
45+
]);
46+
});
47+
});

0 commit comments

Comments
 (0)