@code-pushup/coverage-plugin 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -0
- package/index.js +848 -0
- package/package.json +50 -0
- package/src/index.d.ts +3 -0
- package/src/lib/config.d.ts +61 -0
- package/src/lib/coverage-plugin.d.ts +22 -0
- package/src/lib/runner/lcov/parse-lcov.d.ts +4 -0
- package/src/lib/runner/lcov/runner.d.ts +9 -0
- package/src/lib/runner/lcov/transform.d.ts +19 -0
- package/src/lib/runner/lcov/types.d.ts +12 -0
- package/src/lib/runner/lcov/utils.d.ts +9 -0
- package/src/lib/utils.d.ts +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# @code-pushup/coverage-plugin
|
|
2
|
+
|
|
3
|
+
๐งช **Code PushUp plugin for tracking code coverage.** โ๏ธ
|
|
4
|
+
|
|
5
|
+
This plugin allows you to measure and track code coverage on your project.
|
|
6
|
+
|
|
7
|
+
Measured coverage types are mapped to Code PushUp audits in the following way
|
|
8
|
+
|
|
9
|
+
- The value is in range 0-100 and represents the code coverage for all passed results (_covered / total_)
|
|
10
|
+
- the score is value converted to 0-1 range
|
|
11
|
+
- missing coverage is mapped to issues in the audit details (uncalled functions, uncovered branches or lines)
|
|
12
|
+
|
|
13
|
+
## Getting started
|
|
14
|
+
|
|
15
|
+
1. If you haven't already, install [@code-pushup/cli](../cli/README.md) and create a configuration file.
|
|
16
|
+
|
|
17
|
+
2. Prepare either existing code coverage result files or a command for a coverage tool of your choice that will generate the results. Set lcov as the reporter to the configuration (example for Jest [here](https://jestjs.io/docs/configuration#coveragereporters-arraystring--string-options)).
|
|
18
|
+
|
|
19
|
+
3. Add this plugin to the `plugins` array in your Code PushUp CLI config file (e.g. `code-pushup.config.js`).
|
|
20
|
+
|
|
21
|
+
Pass paths to the code coverage results in LCOV format and optionally define your code coverage tool to be run first.
|
|
22
|
+
All coverage types are measured by default. If you wish to focus on a subset of offered types of coverage, define them in `coverageTypes`.
|
|
23
|
+
|
|
24
|
+
๐ Please note that when you define the tool command, you still need to define the paths to all relevant coverage results.
|
|
25
|
+
|
|
26
|
+
The configuration will look similarly to the following:
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
import coveragePlugin from '@code-pushup/coverage-plugin';
|
|
30
|
+
|
|
31
|
+
export default {
|
|
32
|
+
// ...
|
|
33
|
+
plugins: [
|
|
34
|
+
// ...
|
|
35
|
+
await coveragePlugin({
|
|
36
|
+
reports: [{ resultsPath: 'coverage/lcov.info' }],
|
|
37
|
+
coverageToolCommand: {
|
|
38
|
+
command: 'npx',
|
|
39
|
+
args: ['jest', '--coverage', '--coverageReporters=lcov'],
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
4. (Optional) Reference audits which you wish to include in custom categories (use `npx code-pushup print-config` to list audits and groups).
|
|
47
|
+
|
|
48
|
+
๐ก Assign weights based on what influence each coverage type should have on the overall category score (assign weight 0 to only include as extra info, without influencing category score).
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
export default {
|
|
52
|
+
// ...
|
|
53
|
+
categories: [
|
|
54
|
+
{
|
|
55
|
+
slug: 'code-coverage',
|
|
56
|
+
title: 'Code coverage',
|
|
57
|
+
refs: [
|
|
58
|
+
{
|
|
59
|
+
type: 'audit',
|
|
60
|
+
plugin: 'coverage',
|
|
61
|
+
slug: 'function-coverage',
|
|
62
|
+
weight: 2,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
type: 'audit',
|
|
66
|
+
plugin: 'coverage',
|
|
67
|
+
slug: 'branch-coverage',
|
|
68
|
+
weight: 1,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
type: 'audit',
|
|
72
|
+
plugin: 'coverage',
|
|
73
|
+
slug: 'line-coverage',
|
|
74
|
+
weight: 1,
|
|
75
|
+
},
|
|
76
|
+
// ...
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
// ...
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
5. Run the CLI with `npx code-pushup collect` and view or upload report (refer to [CLI docs](../cli/README.md)).
|
|
85
|
+
|
|
86
|
+
## About code coverage
|
|
87
|
+
|
|
88
|
+
Code coverage is a metric that indicates what percentage of source code is executed by unit tests. It can give insights into test effectiveness and uncover parts of source code that would otherwise go untested.
|
|
89
|
+
|
|
90
|
+
> [!IMPORTANT]
|
|
91
|
+
> Please note that code coverage is not the same as test coverage. Test coverage measures the amount of acceptance criteria covered by tests and is hard to formally verify. This means that code coverage cannot guarantee that the designed software caters to the business requirements.
|
|
92
|
+
|
|
93
|
+
If you want to know more code coverage and how each type of coverage is measured, go to [Software Testing Help](https://www.softwaretestinghelp.com/code-coverage-tutorial/).
|
|
94
|
+
|
|
95
|
+
### LCOV format
|
|
96
|
+
|
|
97
|
+
The LCOV format was originally used by [GCOV](https://gcc.gnu.org/onlinedocs/gcc/gcov/introduction-to-gcov.html) tool for coverage results in C/C++ projects.
|
|
98
|
+
It recognises the following entities:
|
|
99
|
+
|
|
100
|
+
- TN [test name]
|
|
101
|
+
- SF [source file]
|
|
102
|
+
- FN [line number] [function name]
|
|
103
|
+
- FNF [number of functions found]
|
|
104
|
+
- FNH [number of functions hit]
|
|
105
|
+
- FNDA [number of hits] [function name]
|
|
106
|
+
- BRDA [line number] [block number] [branch name] [number of hits]
|
|
107
|
+
- BRF [number of branches found]
|
|
108
|
+
- BRH [number of branches taken]
|
|
109
|
+
- DA [line number] [number of hits]
|
|
110
|
+
- LF [lines found]
|
|
111
|
+
- LH [lines hit]
|
|
112
|
+
|
|
113
|
+
[Here](https://github.com/linux-test-project/lcov/issues/113#issuecomment-762335134) is the source of the information above.
|
|
114
|
+
|
|
115
|
+
> [!NOTE]
|
|
116
|
+
> Branch name is usually a number indexed from 0, indicating either truthy/falsy condition or loop conditions.
|
|
117
|
+
|
|
118
|
+
## Plugin architecture
|
|
119
|
+
|
|
120
|
+
### Plugin configuration specification
|
|
121
|
+
|
|
122
|
+
The plugin accepts the following parameters:
|
|
123
|
+
|
|
124
|
+
- `coverageTypes`: An array of types of coverage that you wish to track. Supported values: `function`, `branch`, `line`. Defaults to all available types.
|
|
125
|
+
- `reports`: Array of information about files with code coverage results - paths to results, path to project root the results belong to. LCOV format is supported for now.
|
|
126
|
+
- (optional) `coverageToolCommand`: If you wish to run your coverage tool to generate the results first, you may define it here.
|
|
127
|
+
- (optional) `perfectScoreThreshold`: If your coverage goal is not 100%, you may define it here in range 0-1. Any score above the defined threshold will be given the perfect score. The value will stay unaffected.
|
|
128
|
+
|
|
129
|
+
### Audit output
|
|
130
|
+
|
|
131
|
+
An audit is an aggregation of all results for one coverage type passed to the plugin.
|
|
132
|
+
|
|
133
|
+
For functions and branches, an issue points to a single instance of a branch or function not covered in any test and counts as an error. In line coverage, one issue groups any amount of consecutive lines together to reduce the total amount of issues and counts as a warning.
|
|
134
|
+
|
|
135
|
+
For instance, the following can be an audit output for line coverage.
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"slug": "line-coverage",
|
|
140
|
+
"displayValue": "95 %",
|
|
141
|
+
"score": 0.95,
|
|
142
|
+
"value": 95,
|
|
143
|
+
"details": {
|
|
144
|
+
"issues": [
|
|
145
|
+
{
|
|
146
|
+
"message": "Lines 7-9 are not covered in any test case.",
|
|
147
|
+
"severity": "warning",
|
|
148
|
+
"source": {
|
|
149
|
+
"file": "packages/cli/src/lib/utils.ts",
|
|
150
|
+
"position": {
|
|
151
|
+
"startLine": 7,
|
|
152
|
+
"endLine": 9
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
// packages/plugin-coverage/src/lib/coverage-plugin.ts
|
|
2
|
+
import { join as join3 } from "node:path";
|
|
3
|
+
|
|
4
|
+
// packages/models/src/lib/audit.ts
|
|
5
|
+
import { z as z2 } from "zod";
|
|
6
|
+
|
|
7
|
+
// packages/models/src/lib/implementation/schemas.ts
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { MATERIAL_ICONS } from "@code-pushup/portal-client";
|
|
10
|
+
|
|
11
|
+
// packages/models/src/lib/implementation/limits.ts
|
|
12
|
+
var MAX_SLUG_LENGTH = 128;
|
|
13
|
+
var MAX_TITLE_LENGTH = 256;
|
|
14
|
+
var MAX_DESCRIPTION_LENGTH = 65536;
|
|
15
|
+
var MAX_ISSUE_MESSAGE_LENGTH = 1024;
|
|
16
|
+
|
|
17
|
+
// packages/models/src/lib/implementation/utils.ts
|
|
18
|
+
var slugRegex = /^[a-z\d]+(?:-[a-z\d]+)*$/;
|
|
19
|
+
var filenameRegex = /^(?!.*[ \\/:*?"<>|]).+$/;
|
|
20
|
+
function hasDuplicateStrings(strings) {
|
|
21
|
+
const sortedStrings = [...strings].sort();
|
|
22
|
+
const duplStrings = sortedStrings.filter(
|
|
23
|
+
(item, index) => index !== 0 && item === sortedStrings[index - 1]
|
|
24
|
+
);
|
|
25
|
+
return duplStrings.length === 0 ? false : [...new Set(duplStrings)];
|
|
26
|
+
}
|
|
27
|
+
function hasMissingStrings(toCheck, existing) {
|
|
28
|
+
const nonExisting = toCheck.filter((s) => !existing.includes(s));
|
|
29
|
+
return nonExisting.length === 0 ? false : nonExisting;
|
|
30
|
+
}
|
|
31
|
+
function errorItems(items, transform = (itemArr) => itemArr.join(", ")) {
|
|
32
|
+
return transform(items || []);
|
|
33
|
+
}
|
|
34
|
+
function exists(value) {
|
|
35
|
+
return value != null;
|
|
36
|
+
}
|
|
37
|
+
function getMissingRefsForCategories(categories, plugins) {
|
|
38
|
+
if (categories.length === 0) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const auditRefsFromCategory = categories.flatMap(
|
|
42
|
+
({ refs }) => refs.filter(({ type }) => type === "audit").map(({ plugin, slug }) => `${plugin}/${slug}`)
|
|
43
|
+
);
|
|
44
|
+
const auditRefsFromPlugins = plugins.flatMap(
|
|
45
|
+
({ audits, slug: pluginSlug }) => audits.map(({ slug }) => `${pluginSlug}/${slug}`)
|
|
46
|
+
);
|
|
47
|
+
const missingAuditRefs = hasMissingStrings(
|
|
48
|
+
auditRefsFromCategory,
|
|
49
|
+
auditRefsFromPlugins
|
|
50
|
+
);
|
|
51
|
+
const groupRefsFromCategory = categories.flatMap(
|
|
52
|
+
({ refs }) => refs.filter(({ type }) => type === "group").map(({ plugin, slug }) => `${plugin}#${slug} (group)`)
|
|
53
|
+
);
|
|
54
|
+
const groupRefsFromPlugins = plugins.flatMap(
|
|
55
|
+
({ groups, slug: pluginSlug }) => Array.isArray(groups) ? groups.map(({ slug }) => `${pluginSlug}#${slug} (group)`) : []
|
|
56
|
+
);
|
|
57
|
+
const missingGroupRefs = hasMissingStrings(
|
|
58
|
+
groupRefsFromCategory,
|
|
59
|
+
groupRefsFromPlugins
|
|
60
|
+
);
|
|
61
|
+
const missingRefs = [missingAuditRefs, missingGroupRefs].filter((refs) => Array.isArray(refs) && refs.length > 0).flat();
|
|
62
|
+
return missingRefs.length > 0 ? missingRefs : false;
|
|
63
|
+
}
|
|
64
|
+
function missingRefsForCategoriesErrorMsg(categories, plugins) {
|
|
65
|
+
const missingRefs = getMissingRefsForCategories(categories, plugins);
|
|
66
|
+
return `The following category references need to point to an audit or group: ${errorItems(
|
|
67
|
+
missingRefs
|
|
68
|
+
)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// packages/models/src/lib/implementation/schemas.ts
|
|
72
|
+
function executionMetaSchema(options = {
|
|
73
|
+
descriptionDate: "Execution start date and time",
|
|
74
|
+
descriptionDuration: "Execution duration in ms"
|
|
75
|
+
}) {
|
|
76
|
+
return z.object({
|
|
77
|
+
date: z.string({ description: options.descriptionDate }),
|
|
78
|
+
duration: z.number({ description: options.descriptionDuration })
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function slugSchema(description = "Unique ID (human-readable, URL-safe)") {
|
|
82
|
+
return z.string({ description }).regex(slugRegex, {
|
|
83
|
+
message: "The slug has to follow the pattern [0-9a-z] followed by multiple optional groups of -[0-9a-z]. e.g. my-slug"
|
|
84
|
+
}).max(MAX_SLUG_LENGTH, {
|
|
85
|
+
message: `slug can be max ${MAX_SLUG_LENGTH} characters long`
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function descriptionSchema(description = "Description (markdown)") {
|
|
89
|
+
return z.string({ description }).max(MAX_DESCRIPTION_LENGTH).optional();
|
|
90
|
+
}
|
|
91
|
+
function docsUrlSchema(description = "Documentation site") {
|
|
92
|
+
return urlSchema(description).optional().or(z.string().max(0));
|
|
93
|
+
}
|
|
94
|
+
function urlSchema(description) {
|
|
95
|
+
return z.string({ description }).url();
|
|
96
|
+
}
|
|
97
|
+
function titleSchema(description = "Descriptive name") {
|
|
98
|
+
return z.string({ description }).max(MAX_TITLE_LENGTH);
|
|
99
|
+
}
|
|
100
|
+
function metaSchema(options) {
|
|
101
|
+
const {
|
|
102
|
+
descriptionDescription,
|
|
103
|
+
titleDescription,
|
|
104
|
+
docsUrlDescription,
|
|
105
|
+
description
|
|
106
|
+
} = options ?? {};
|
|
107
|
+
return z.object(
|
|
108
|
+
{
|
|
109
|
+
title: titleSchema(titleDescription),
|
|
110
|
+
description: descriptionSchema(descriptionDescription),
|
|
111
|
+
docsUrl: docsUrlSchema(docsUrlDescription)
|
|
112
|
+
},
|
|
113
|
+
{ description }
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
function filePathSchema(description) {
|
|
117
|
+
return z.string({ description }).trim().min(1, { message: "path is invalid" });
|
|
118
|
+
}
|
|
119
|
+
function fileNameSchema(description) {
|
|
120
|
+
return z.string({ description }).trim().regex(filenameRegex, {
|
|
121
|
+
message: `The filename has to be valid`
|
|
122
|
+
}).min(1, { message: "file name is invalid" });
|
|
123
|
+
}
|
|
124
|
+
function positiveIntSchema(description) {
|
|
125
|
+
return z.number({ description }).int().nonnegative();
|
|
126
|
+
}
|
|
127
|
+
function packageVersionSchema(options) {
|
|
128
|
+
const { versionDescription = "NPM version of the package", required } = options ?? {};
|
|
129
|
+
const packageSchema = z.string({ description: "NPM package name" });
|
|
130
|
+
const versionSchema = z.string({ description: versionDescription });
|
|
131
|
+
return z.object(
|
|
132
|
+
{
|
|
133
|
+
packageName: required ? packageSchema : packageSchema.optional(),
|
|
134
|
+
version: required ? versionSchema : versionSchema.optional()
|
|
135
|
+
},
|
|
136
|
+
{ description: "NPM package name and version of a published package" }
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
function weightSchema(description = "Coefficient for the given score (use weight 0 if only for display)") {
|
|
140
|
+
return positiveIntSchema(description);
|
|
141
|
+
}
|
|
142
|
+
function weightedRefSchema(description, slugDescription) {
|
|
143
|
+
return z.object(
|
|
144
|
+
{
|
|
145
|
+
slug: slugSchema(slugDescription),
|
|
146
|
+
weight: weightSchema("Weight used to calculate score")
|
|
147
|
+
},
|
|
148
|
+
{ description }
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
function scorableSchema(description, refSchema, duplicateCheckFn, duplicateMessageFn) {
|
|
152
|
+
return z.object(
|
|
153
|
+
{
|
|
154
|
+
slug: slugSchema('Human-readable unique ID, e.g. "performance"'),
|
|
155
|
+
refs: z.array(refSchema).min(1).refine(
|
|
156
|
+
(refs) => !duplicateCheckFn(refs),
|
|
157
|
+
(refs) => ({
|
|
158
|
+
message: duplicateMessageFn(refs)
|
|
159
|
+
})
|
|
160
|
+
).refine(hasWeightedRefsInCategories, () => ({
|
|
161
|
+
message: `In a category there has to be at least one ref with weight > 0`
|
|
162
|
+
}))
|
|
163
|
+
},
|
|
164
|
+
{ description }
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
var materialIconSchema = z.enum(
|
|
168
|
+
MATERIAL_ICONS,
|
|
169
|
+
{ description: "Icon from VSCode Material Icons extension" }
|
|
170
|
+
);
|
|
171
|
+
function hasWeightedRefsInCategories(categoryRefs) {
|
|
172
|
+
return categoryRefs.reduce((acc, { weight }) => weight + acc, 0) !== 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// packages/models/src/lib/audit.ts
|
|
176
|
+
var auditSchema = z2.object({
|
|
177
|
+
slug: slugSchema("ID (unique within plugin)")
|
|
178
|
+
}).merge(
|
|
179
|
+
metaSchema({
|
|
180
|
+
titleDescription: "Descriptive name",
|
|
181
|
+
descriptionDescription: "Description (markdown)",
|
|
182
|
+
docsUrlDescription: "Link to documentation (rationale)",
|
|
183
|
+
description: "List of scorable metrics for the given plugin"
|
|
184
|
+
})
|
|
185
|
+
);
|
|
186
|
+
var pluginAuditsSchema = z2.array(auditSchema, {
|
|
187
|
+
description: "List of audits maintained in a plugin"
|
|
188
|
+
}).refine(
|
|
189
|
+
(auditMetadata) => !getDuplicateSlugsInAudits(auditMetadata),
|
|
190
|
+
(auditMetadata) => ({
|
|
191
|
+
message: duplicateSlugsInAuditsErrorMsg(auditMetadata)
|
|
192
|
+
})
|
|
193
|
+
);
|
|
194
|
+
function duplicateSlugsInAuditsErrorMsg(audits) {
|
|
195
|
+
const duplicateRefs = getDuplicateSlugsInAudits(audits);
|
|
196
|
+
return `In plugin audits the following slugs are not unique: ${errorItems(
|
|
197
|
+
duplicateRefs
|
|
198
|
+
)}`;
|
|
199
|
+
}
|
|
200
|
+
function getDuplicateSlugsInAudits(audits) {
|
|
201
|
+
return hasDuplicateStrings(audits.map(({ slug }) => slug));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// packages/models/src/lib/audit-issue.ts
|
|
205
|
+
import { z as z3 } from "zod";
|
|
206
|
+
var sourceFileLocationSchema = z3.object(
|
|
207
|
+
{
|
|
208
|
+
file: filePathSchema("Relative path to source file in Git repo"),
|
|
209
|
+
position: z3.object(
|
|
210
|
+
{
|
|
211
|
+
startLine: positiveIntSchema("Start line"),
|
|
212
|
+
startColumn: positiveIntSchema("Start column").optional(),
|
|
213
|
+
endLine: positiveIntSchema("End line").optional(),
|
|
214
|
+
endColumn: positiveIntSchema("End column").optional()
|
|
215
|
+
},
|
|
216
|
+
{ description: "Location in file" }
|
|
217
|
+
).optional()
|
|
218
|
+
},
|
|
219
|
+
{ description: "Source file location" }
|
|
220
|
+
);
|
|
221
|
+
var issueSeveritySchema = z3.enum(["info", "warning", "error"], {
|
|
222
|
+
description: "Severity level"
|
|
223
|
+
});
|
|
224
|
+
var issueSchema = z3.object(
|
|
225
|
+
{
|
|
226
|
+
message: z3.string({ description: "Descriptive error message" }).max(MAX_ISSUE_MESSAGE_LENGTH),
|
|
227
|
+
severity: issueSeveritySchema,
|
|
228
|
+
source: sourceFileLocationSchema.optional()
|
|
229
|
+
},
|
|
230
|
+
{ description: "Issue information" }
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// packages/models/src/lib/audit-output.ts
|
|
234
|
+
import { z as z4 } from "zod";
|
|
235
|
+
var auditOutputSchema = z4.object(
|
|
236
|
+
{
|
|
237
|
+
slug: slugSchema("Reference to audit"),
|
|
238
|
+
displayValue: z4.string({ description: "Formatted value (e.g. '0.9 s', '2.1 MB')" }).optional(),
|
|
239
|
+
value: positiveIntSchema("Raw numeric value"),
|
|
240
|
+
score: z4.number({
|
|
241
|
+
description: "Value between 0 and 1"
|
|
242
|
+
}).min(0).max(1),
|
|
243
|
+
details: z4.object(
|
|
244
|
+
{
|
|
245
|
+
issues: z4.array(issueSchema, { description: "List of findings" })
|
|
246
|
+
},
|
|
247
|
+
{ description: "Detailed information" }
|
|
248
|
+
).optional()
|
|
249
|
+
},
|
|
250
|
+
{ description: "Audit information" }
|
|
251
|
+
);
|
|
252
|
+
var auditOutputsSchema = z4.array(auditOutputSchema, {
|
|
253
|
+
description: "List of JSON formatted audit output emitted by the runner process of a plugin"
|
|
254
|
+
}).refine(
|
|
255
|
+
(audits) => !getDuplicateSlugsInAudits2(audits),
|
|
256
|
+
(audits) => ({ message: duplicateSlugsInAuditsErrorMsg2(audits) })
|
|
257
|
+
);
|
|
258
|
+
function duplicateSlugsInAuditsErrorMsg2(audits) {
|
|
259
|
+
const duplicateRefs = getDuplicateSlugsInAudits2(audits);
|
|
260
|
+
return `In plugin audits the slugs are not unique: ${errorItems(
|
|
261
|
+
duplicateRefs
|
|
262
|
+
)}`;
|
|
263
|
+
}
|
|
264
|
+
function getDuplicateSlugsInAudits2(audits) {
|
|
265
|
+
return hasDuplicateStrings(audits.map(({ slug }) => slug));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// packages/models/src/lib/category-config.ts
|
|
269
|
+
import { z as z5 } from "zod";
|
|
270
|
+
var categoryRefSchema = weightedRefSchema(
|
|
271
|
+
"Weighted references to audits and/or groups for the category",
|
|
272
|
+
"Slug of an audit or group (depending on `type`)"
|
|
273
|
+
).merge(
|
|
274
|
+
z5.object({
|
|
275
|
+
type: z5.enum(["audit", "group"], {
|
|
276
|
+
description: "Discriminant for reference kind, affects where `slug` is looked up"
|
|
277
|
+
}),
|
|
278
|
+
plugin: slugSchema(
|
|
279
|
+
"Plugin slug (plugin should contain referenced audit or group)"
|
|
280
|
+
)
|
|
281
|
+
})
|
|
282
|
+
);
|
|
283
|
+
var categoryConfigSchema = scorableSchema(
|
|
284
|
+
"Category with a score calculated from audits and groups from various plugins",
|
|
285
|
+
categoryRefSchema,
|
|
286
|
+
getDuplicateRefsInCategoryMetrics,
|
|
287
|
+
duplicateRefsInCategoryMetricsErrorMsg
|
|
288
|
+
).merge(
|
|
289
|
+
metaSchema({
|
|
290
|
+
titleDescription: "Category Title",
|
|
291
|
+
docsUrlDescription: "Category docs URL",
|
|
292
|
+
descriptionDescription: "Category description",
|
|
293
|
+
description: "Meta info for category"
|
|
294
|
+
})
|
|
295
|
+
).merge(
|
|
296
|
+
z5.object({
|
|
297
|
+
isBinary: z5.boolean({
|
|
298
|
+
description: 'Is this a binary category (i.e. only a perfect score considered a "pass")?'
|
|
299
|
+
}).optional()
|
|
300
|
+
})
|
|
301
|
+
);
|
|
302
|
+
function duplicateRefsInCategoryMetricsErrorMsg(metrics) {
|
|
303
|
+
const duplicateRefs = getDuplicateRefsInCategoryMetrics(metrics);
|
|
304
|
+
return `In the categories, the following audit or group refs are duplicates: ${errorItems(
|
|
305
|
+
duplicateRefs
|
|
306
|
+
)}`;
|
|
307
|
+
}
|
|
308
|
+
function getDuplicateRefsInCategoryMetrics(metrics) {
|
|
309
|
+
return hasDuplicateStrings(
|
|
310
|
+
metrics.map(({ slug, type, plugin }) => `${type} :: ${plugin} / ${slug}`)
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
var categoriesSchema = z5.array(categoryConfigSchema, {
|
|
314
|
+
description: "Categorization of individual audits"
|
|
315
|
+
}).refine(
|
|
316
|
+
(categoryCfg) => !getDuplicateSlugCategories(categoryCfg),
|
|
317
|
+
(categoryCfg) => ({
|
|
318
|
+
message: duplicateSlugCategoriesErrorMsg(categoryCfg)
|
|
319
|
+
})
|
|
320
|
+
);
|
|
321
|
+
function duplicateSlugCategoriesErrorMsg(categories) {
|
|
322
|
+
const duplicateStringSlugs = getDuplicateSlugCategories(categories);
|
|
323
|
+
return `In the categories, the following slugs are duplicated: ${errorItems(
|
|
324
|
+
duplicateStringSlugs
|
|
325
|
+
)}`;
|
|
326
|
+
}
|
|
327
|
+
function getDuplicateSlugCategories(categories) {
|
|
328
|
+
return hasDuplicateStrings(categories.map(({ slug }) => slug));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// packages/models/src/lib/core-config.ts
|
|
332
|
+
import { z as z11 } from "zod";
|
|
333
|
+
|
|
334
|
+
// packages/models/src/lib/persist-config.ts
|
|
335
|
+
import { z as z6 } from "zod";
|
|
336
|
+
var formatSchema = z6.enum(["json", "md"]);
|
|
337
|
+
var persistConfigSchema = z6.object({
|
|
338
|
+
outputDir: filePathSchema("Artifacts folder").optional(),
|
|
339
|
+
filename: fileNameSchema(
|
|
340
|
+
"Artifacts file name (without extension)"
|
|
341
|
+
).optional(),
|
|
342
|
+
format: z6.array(formatSchema).optional()
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// packages/models/src/lib/plugin-config.ts
|
|
346
|
+
import { z as z9 } from "zod";
|
|
347
|
+
|
|
348
|
+
// packages/models/src/lib/group.ts
|
|
349
|
+
import { z as z7 } from "zod";
|
|
350
|
+
var groupRefSchema = weightedRefSchema(
|
|
351
|
+
"Weighted reference to a group",
|
|
352
|
+
"Reference slug to a group within this plugin (e.g. 'max-lines')"
|
|
353
|
+
);
|
|
354
|
+
var groupMetaSchema = metaSchema({
|
|
355
|
+
titleDescription: "Descriptive name for the group",
|
|
356
|
+
descriptionDescription: "Description of the group (markdown)",
|
|
357
|
+
docsUrlDescription: "Group documentation site",
|
|
358
|
+
description: "Group metadata"
|
|
359
|
+
});
|
|
360
|
+
var groupSchema = scorableSchema(
|
|
361
|
+
'A group aggregates a set of audits into a single score which can be referenced from a category. E.g. the group slug "performance" groups audits and can be referenced in a category',
|
|
362
|
+
groupRefSchema,
|
|
363
|
+
getDuplicateRefsInGroups,
|
|
364
|
+
duplicateRefsInGroupsErrorMsg
|
|
365
|
+
).merge(groupMetaSchema);
|
|
366
|
+
var groupsSchema = z7.array(groupSchema, {
|
|
367
|
+
description: "List of groups"
|
|
368
|
+
}).optional().refine(
|
|
369
|
+
(groups) => !getDuplicateSlugsInGroups(groups),
|
|
370
|
+
(groups) => ({
|
|
371
|
+
message: duplicateSlugsInGroupsErrorMsg(groups)
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
function duplicateRefsInGroupsErrorMsg(groups) {
|
|
375
|
+
const duplicateRefs = getDuplicateRefsInGroups(groups);
|
|
376
|
+
return `In plugin groups the following references are not unique: ${errorItems(
|
|
377
|
+
duplicateRefs
|
|
378
|
+
)}`;
|
|
379
|
+
}
|
|
380
|
+
function getDuplicateRefsInGroups(groups) {
|
|
381
|
+
return hasDuplicateStrings(groups.map(({ slug: ref }) => ref).filter(exists));
|
|
382
|
+
}
|
|
383
|
+
function duplicateSlugsInGroupsErrorMsg(groups) {
|
|
384
|
+
const duplicateRefs = getDuplicateSlugsInGroups(groups);
|
|
385
|
+
return `In groups the following slugs are not unique: ${errorItems(
|
|
386
|
+
duplicateRefs
|
|
387
|
+
)}`;
|
|
388
|
+
}
|
|
389
|
+
function getDuplicateSlugsInGroups(groups) {
|
|
390
|
+
return Array.isArray(groups) ? hasDuplicateStrings(groups.map(({ slug }) => slug)) : false;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// packages/models/src/lib/runner-config.ts
|
|
394
|
+
import { z as z8 } from "zod";
|
|
395
|
+
var outputTransformSchema = z8.function().args(z8.unknown()).returns(z8.union([auditOutputsSchema, z8.promise(auditOutputsSchema)]));
|
|
396
|
+
var runnerConfigSchema = z8.object(
|
|
397
|
+
{
|
|
398
|
+
command: z8.string({
|
|
399
|
+
description: "Shell command to execute"
|
|
400
|
+
}),
|
|
401
|
+
args: z8.array(z8.string({ description: "Command arguments" })).optional(),
|
|
402
|
+
outputFile: filePathSchema("Output path"),
|
|
403
|
+
outputTransform: outputTransformSchema.optional()
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
description: "How to execute runner"
|
|
407
|
+
}
|
|
408
|
+
);
|
|
409
|
+
var onProgressSchema = z8.function().args(z8.unknown()).returns(z8.void());
|
|
410
|
+
var runnerFunctionSchema = z8.function().args(onProgressSchema.optional()).returns(z8.union([auditOutputsSchema, z8.promise(auditOutputsSchema)]));
|
|
411
|
+
|
|
412
|
+
// packages/models/src/lib/plugin-config.ts
|
|
413
|
+
var pluginMetaSchema = packageVersionSchema().merge(
|
|
414
|
+
metaSchema({
|
|
415
|
+
titleDescription: "Descriptive name",
|
|
416
|
+
descriptionDescription: "Description (markdown)",
|
|
417
|
+
docsUrlDescription: "Plugin documentation site",
|
|
418
|
+
description: "Plugin metadata"
|
|
419
|
+
})
|
|
420
|
+
).merge(
|
|
421
|
+
z9.object({
|
|
422
|
+
slug: slugSchema("Unique plugin slug within core config"),
|
|
423
|
+
icon: materialIconSchema
|
|
424
|
+
})
|
|
425
|
+
);
|
|
426
|
+
var pluginDataSchema = z9.object({
|
|
427
|
+
runner: z9.union([runnerConfigSchema, runnerFunctionSchema]),
|
|
428
|
+
audits: pluginAuditsSchema,
|
|
429
|
+
groups: groupsSchema
|
|
430
|
+
});
|
|
431
|
+
var pluginConfigSchema = pluginMetaSchema.merge(pluginDataSchema).refine(
|
|
432
|
+
(pluginCfg) => !getMissingRefsFromGroups(pluginCfg),
|
|
433
|
+
(pluginCfg) => ({
|
|
434
|
+
message: missingRefsFromGroupsErrorMsg(pluginCfg)
|
|
435
|
+
})
|
|
436
|
+
);
|
|
437
|
+
function missingRefsFromGroupsErrorMsg(pluginCfg) {
|
|
438
|
+
const missingRefs = getMissingRefsFromGroups(pluginCfg);
|
|
439
|
+
return `The following group references need to point to an existing audit in this plugin config: ${errorItems(
|
|
440
|
+
missingRefs
|
|
441
|
+
)}`;
|
|
442
|
+
}
|
|
443
|
+
function getMissingRefsFromGroups(pluginCfg) {
|
|
444
|
+
return hasMissingStrings(
|
|
445
|
+
pluginCfg.groups?.flatMap(
|
|
446
|
+
({ refs: audits }) => audits.map(({ slug: ref }) => ref)
|
|
447
|
+
) ?? [],
|
|
448
|
+
pluginCfg.audits.map(({ slug }) => slug)
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// packages/models/src/lib/upload-config.ts
|
|
453
|
+
import { z as z10 } from "zod";
|
|
454
|
+
var uploadConfigSchema = z10.object({
|
|
455
|
+
server: urlSchema("URL of deployed portal API"),
|
|
456
|
+
apiKey: z10.string({
|
|
457
|
+
description: "API key with write access to portal (use `process.env` for security)"
|
|
458
|
+
}),
|
|
459
|
+
organization: slugSchema("Organization slug from Code PushUp portal"),
|
|
460
|
+
project: slugSchema("Project slug from Code PushUp portal"),
|
|
461
|
+
timeout: z10.number({ description: "Request timeout in minutes (default is 5)" }).positive().int().optional()
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// packages/models/src/lib/core-config.ts
|
|
465
|
+
var unrefinedCoreConfigSchema = z11.object({
|
|
466
|
+
plugins: z11.array(pluginConfigSchema, {
|
|
467
|
+
description: "List of plugins to be used (official, community-provided, or custom)"
|
|
468
|
+
}).min(1),
|
|
469
|
+
/** portal configuration for persisting results */
|
|
470
|
+
persist: persistConfigSchema.optional(),
|
|
471
|
+
/** portal configuration for uploading results */
|
|
472
|
+
upload: uploadConfigSchema.optional(),
|
|
473
|
+
categories: categoriesSchema.optional()
|
|
474
|
+
});
|
|
475
|
+
var coreConfigSchema = refineCoreConfig(unrefinedCoreConfigSchema);
|
|
476
|
+
function refineCoreConfig(schema) {
|
|
477
|
+
return schema.refine(
|
|
478
|
+
(coreCfg) => !getMissingRefsForCategories(coreCfg.categories ?? [], coreCfg.plugins),
|
|
479
|
+
(coreCfg) => ({
|
|
480
|
+
message: missingRefsForCategoriesErrorMsg(
|
|
481
|
+
coreCfg.categories ?? [],
|
|
482
|
+
coreCfg.plugins
|
|
483
|
+
)
|
|
484
|
+
})
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// packages/models/src/lib/report.ts
|
|
489
|
+
import { z as z12 } from "zod";
|
|
490
|
+
var auditReportSchema = auditSchema.merge(auditOutputSchema);
|
|
491
|
+
var pluginReportSchema = pluginMetaSchema.merge(
|
|
492
|
+
executionMetaSchema({
|
|
493
|
+
descriptionDate: "Start date and time of plugin run",
|
|
494
|
+
descriptionDuration: "Duration of the plugin run in ms"
|
|
495
|
+
})
|
|
496
|
+
).merge(
|
|
497
|
+
z12.object({
|
|
498
|
+
audits: z12.array(auditReportSchema),
|
|
499
|
+
groups: z12.array(groupSchema).optional()
|
|
500
|
+
})
|
|
501
|
+
).refine(
|
|
502
|
+
(pluginReport) => !getMissingRefsFromGroups2(pluginReport.audits, pluginReport.groups ?? []),
|
|
503
|
+
(pluginReport) => ({
|
|
504
|
+
message: missingRefsFromGroupsErrorMsg2(
|
|
505
|
+
pluginReport.audits,
|
|
506
|
+
pluginReport.groups ?? []
|
|
507
|
+
)
|
|
508
|
+
})
|
|
509
|
+
);
|
|
510
|
+
function missingRefsFromGroupsErrorMsg2(audits, groups) {
|
|
511
|
+
const missingRefs = getMissingRefsFromGroups2(audits, groups);
|
|
512
|
+
return `group references need to point to an existing audit in this plugin report: ${errorItems(
|
|
513
|
+
missingRefs
|
|
514
|
+
)}`;
|
|
515
|
+
}
|
|
516
|
+
function getMissingRefsFromGroups2(audits, groups) {
|
|
517
|
+
return hasMissingStrings(
|
|
518
|
+
groups.flatMap(
|
|
519
|
+
({ refs: auditRefs }) => auditRefs.map(({ slug: ref }) => ref)
|
|
520
|
+
),
|
|
521
|
+
audits.map(({ slug }) => slug)
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
var reportSchema = packageVersionSchema({
|
|
525
|
+
versionDescription: "NPM version of the CLI",
|
|
526
|
+
required: true
|
|
527
|
+
}).merge(
|
|
528
|
+
executionMetaSchema({
|
|
529
|
+
descriptionDate: "Start date and time of the collect run",
|
|
530
|
+
descriptionDuration: "Duration of the collect run in ms"
|
|
531
|
+
})
|
|
532
|
+
).merge(
|
|
533
|
+
z12.object(
|
|
534
|
+
{
|
|
535
|
+
categories: z12.array(categoryConfigSchema),
|
|
536
|
+
plugins: z12.array(pluginReportSchema).min(1)
|
|
537
|
+
},
|
|
538
|
+
{ description: "Collect output data" }
|
|
539
|
+
)
|
|
540
|
+
).refine(
|
|
541
|
+
(report) => !getMissingRefsForCategories(report.categories, report.plugins),
|
|
542
|
+
(report) => ({
|
|
543
|
+
message: missingRefsForCategoriesErrorMsg(
|
|
544
|
+
report.categories,
|
|
545
|
+
report.plugins
|
|
546
|
+
)
|
|
547
|
+
})
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
// packages/utils/src/lib/file-system.ts
|
|
551
|
+
import { bundleRequire } from "bundle-require";
|
|
552
|
+
import chalk from "chalk";
|
|
553
|
+
import { mkdir, readFile, readdir, stat } from "node:fs/promises";
|
|
554
|
+
import { join } from "node:path";
|
|
555
|
+
async function readTextFile(path) {
|
|
556
|
+
const buffer = await readFile(path);
|
|
557
|
+
return buffer.toString();
|
|
558
|
+
}
|
|
559
|
+
function pluginWorkDir(slug) {
|
|
560
|
+
return join("node_modules", ".code-pushup", slug);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// packages/utils/src/lib/git.ts
|
|
564
|
+
import { simpleGit } from "simple-git";
|
|
565
|
+
var git = simpleGit();
|
|
566
|
+
|
|
567
|
+
// packages/utils/src/lib/progress.ts
|
|
568
|
+
import chalk2 from "chalk";
|
|
569
|
+
import { MultiProgressBars } from "multi-progress-bars";
|
|
570
|
+
|
|
571
|
+
// packages/utils/src/lib/reports/generate-stdout-summary.ts
|
|
572
|
+
import cliui from "@isaacs/cliui";
|
|
573
|
+
import chalk3 from "chalk";
|
|
574
|
+
import CliTable3 from "cli-table3";
|
|
575
|
+
|
|
576
|
+
// packages/utils/src/lib/transform.ts
|
|
577
|
+
import { platform } from "node:os";
|
|
578
|
+
function toUnixPath(path, options) {
|
|
579
|
+
const unixPath = path.replace(/\\/g, "/");
|
|
580
|
+
if (options?.toRelative) {
|
|
581
|
+
return unixPath.replace(`${process.cwd().replace(/\\/g, "/")}/`, "");
|
|
582
|
+
}
|
|
583
|
+
return unixPath;
|
|
584
|
+
}
|
|
585
|
+
function toUnixNewlines(text) {
|
|
586
|
+
return platform() === "win32" ? text.replace(/\r\n/g, "\n") : text;
|
|
587
|
+
}
|
|
588
|
+
function capitalize(text) {
|
|
589
|
+
return `${text.charAt(0).toLocaleUpperCase()}${text.slice(
|
|
590
|
+
1
|
|
591
|
+
)}`;
|
|
592
|
+
}
|
|
593
|
+
function toNumberPrecision(value, decimalPlaces) {
|
|
594
|
+
return Number(
|
|
595
|
+
`${Math.round(
|
|
596
|
+
Number.parseFloat(`${value}e${decimalPlaces}`)
|
|
597
|
+
)}e-${decimalPlaces}`
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
function toOrdinal(value) {
|
|
601
|
+
if (value % 10 === 1 && value % 100 !== 11) {
|
|
602
|
+
return `${value}st`;
|
|
603
|
+
}
|
|
604
|
+
if (value % 10 === 2 && value % 100 !== 12) {
|
|
605
|
+
return `${value}nd`;
|
|
606
|
+
}
|
|
607
|
+
if (value % 10 === 3 && value % 100 !== 13) {
|
|
608
|
+
return `${value}rd`;
|
|
609
|
+
}
|
|
610
|
+
return `${value}th`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// packages/plugin-coverage/package.json
|
|
614
|
+
var name = "@code-pushup/coverage-plugin";
|
|
615
|
+
var version = "0.13.0";
|
|
616
|
+
|
|
617
|
+
// packages/plugin-coverage/src/lib/config.ts
|
|
618
|
+
import { z as z13 } from "zod";
|
|
619
|
+
var coverageTypeSchema = z13.enum(["function", "branch", "line"]);
|
|
620
|
+
var coverageReportSchema = z13.object({
|
|
621
|
+
resultsPath: z13.string().includes("lcov"),
|
|
622
|
+
pathToProject: z13.string({
|
|
623
|
+
description: "Path from workspace root to project root. Necessary for LCOV reports."
|
|
624
|
+
}).optional()
|
|
625
|
+
});
|
|
626
|
+
var coveragePluginConfigSchema = z13.object({
|
|
627
|
+
coverageToolCommand: z13.object({
|
|
628
|
+
command: z13.string({ description: "Command to run coverage tool." }).min(1),
|
|
629
|
+
args: z13.array(z13.string(), {
|
|
630
|
+
description: "Arguments to be passed to the coverage tool."
|
|
631
|
+
}).optional()
|
|
632
|
+
}).optional(),
|
|
633
|
+
coverageTypes: z13.array(coverageTypeSchema, {
|
|
634
|
+
description: "Coverage types measured. Defaults to all available types."
|
|
635
|
+
}).min(1).default(["function", "branch", "line"]),
|
|
636
|
+
reports: z13.array(coverageReportSchema, {
|
|
637
|
+
description: "Path to all code coverage report files. Only LCOV format is supported for now."
|
|
638
|
+
}).min(1),
|
|
639
|
+
perfectScoreThreshold: z13.number({
|
|
640
|
+
description: "Score will be 1 (perfect) for this coverage and above. Score range is 0 - 1."
|
|
641
|
+
}).gt(0).max(1).optional()
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// packages/plugin-coverage/src/lib/runner/lcov/runner.ts
|
|
645
|
+
import { join as join2 } from "node:path";
|
|
646
|
+
|
|
647
|
+
// packages/plugin-coverage/src/lib/runner/lcov/parse-lcov.ts
|
|
648
|
+
import parseLcovExport from "parse-lcov";
|
|
649
|
+
var godKnows = parseLcovExport;
|
|
650
|
+
var parseLcov = "default" in godKnows ? godKnows.default : godKnows;
|
|
651
|
+
|
|
652
|
+
// packages/plugin-coverage/src/lib/runner/lcov/utils.ts
|
|
653
|
+
function calculateCoverage(hit, found) {
|
|
654
|
+
return found > 0 ? hit / found : 1;
|
|
655
|
+
}
|
|
656
|
+
function mergeConsecutiveNumbers(numberArr) {
|
|
657
|
+
return [...numberArr].sort().reduce((acc, currValue) => {
|
|
658
|
+
const prevValue = acc.at(-1);
|
|
659
|
+
if (prevValue != null && (prevValue.start === currValue - 1 || prevValue.end === currValue - 1)) {
|
|
660
|
+
return [...acc.slice(0, -1), { start: prevValue.start, end: currValue }];
|
|
661
|
+
}
|
|
662
|
+
return [...acc, { start: currValue }];
|
|
663
|
+
}, []);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// packages/plugin-coverage/src/lib/runner/lcov/transform.ts
|
|
667
|
+
function lcovReportToFunctionStat(record) {
|
|
668
|
+
return {
|
|
669
|
+
totalFound: record.functions.found,
|
|
670
|
+
totalHit: record.functions.hit,
|
|
671
|
+
issues: record.functions.hit < record.functions.found ? record.functions.details.filter((detail) => !detail.hit).map(
|
|
672
|
+
(detail) => ({
|
|
673
|
+
message: `Function ${detail.name} is not called in any test case.`,
|
|
674
|
+
severity: "error",
|
|
675
|
+
source: {
|
|
676
|
+
file: toUnixPath(record.file),
|
|
677
|
+
position: { startLine: detail.line }
|
|
678
|
+
}
|
|
679
|
+
})
|
|
680
|
+
) : []
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
function lcovReportToLineStat(record) {
|
|
684
|
+
const missingCoverage = record.lines.hit < record.lines.found;
|
|
685
|
+
const lines = missingCoverage ? record.lines.details.filter((detail) => !detail.hit).map((detail) => detail.line) : [];
|
|
686
|
+
const linePositions = mergeConsecutiveNumbers(lines);
|
|
687
|
+
return {
|
|
688
|
+
totalFound: record.lines.found,
|
|
689
|
+
totalHit: record.lines.hit,
|
|
690
|
+
issues: missingCoverage ? linePositions.map((linePosition) => {
|
|
691
|
+
const lineReference = linePosition.end == null ? `Line ${linePosition.start} is` : `Lines ${linePosition.start}-${linePosition.end} are`;
|
|
692
|
+
return {
|
|
693
|
+
message: `${lineReference} not covered in any test case.`,
|
|
694
|
+
severity: "warning",
|
|
695
|
+
source: {
|
|
696
|
+
file: toUnixPath(record.file),
|
|
697
|
+
position: {
|
|
698
|
+
startLine: linePosition.start,
|
|
699
|
+
endLine: linePosition.end
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
}) : []
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
function lcovReportToBranchStat(record) {
|
|
707
|
+
return {
|
|
708
|
+
totalFound: record.branches.found,
|
|
709
|
+
totalHit: record.branches.hit,
|
|
710
|
+
issues: record.branches.hit < record.branches.found ? record.branches.details.filter((detail) => !detail.taken).map(
|
|
711
|
+
(detail) => ({
|
|
712
|
+
message: `${toOrdinal(
|
|
713
|
+
detail.branch + 1
|
|
714
|
+
)} branch is not taken in any test case.`,
|
|
715
|
+
severity: "error",
|
|
716
|
+
source: {
|
|
717
|
+
file: toUnixPath(record.file),
|
|
718
|
+
position: { startLine: detail.line }
|
|
719
|
+
}
|
|
720
|
+
})
|
|
721
|
+
) : []
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
var recordToStatFunctionMapper = {
|
|
725
|
+
branch: lcovReportToBranchStat,
|
|
726
|
+
line: lcovReportToLineStat,
|
|
727
|
+
function: lcovReportToFunctionStat
|
|
728
|
+
};
|
|
729
|
+
function lcovCoverageToAuditOutput(stat2, coverageType) {
|
|
730
|
+
const coverage = calculateCoverage(stat2.totalHit, stat2.totalFound);
|
|
731
|
+
const MAX_DECIMAL_PLACES = 4;
|
|
732
|
+
const roundedIntValue = toNumberPrecision(coverage * 100, 0);
|
|
733
|
+
return {
|
|
734
|
+
slug: `${coverageType}-coverage`,
|
|
735
|
+
score: toNumberPrecision(coverage, MAX_DECIMAL_PLACES),
|
|
736
|
+
value: roundedIntValue,
|
|
737
|
+
displayValue: `${roundedIntValue} %`,
|
|
738
|
+
...stat2.issues.length > 0 && { details: { issues: stat2.issues } }
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// packages/plugin-coverage/src/lib/runner/lcov/runner.ts
|
|
743
|
+
async function lcovResultsToAuditOutputs(reports, coverageTypes) {
|
|
744
|
+
const parsedReports = await Promise.all(
|
|
745
|
+
reports.map(async (report) => {
|
|
746
|
+
const reportContent = await readTextFile(report.resultsPath);
|
|
747
|
+
const parsedRecords = parseLcov(toUnixNewlines(reportContent));
|
|
748
|
+
return parsedRecords.map((record) => ({
|
|
749
|
+
...record,
|
|
750
|
+
file: report.pathToProject == null ? record.file : join2(report.pathToProject, record.file)
|
|
751
|
+
}));
|
|
752
|
+
})
|
|
753
|
+
);
|
|
754
|
+
if (parsedReports.length !== reports.length) {
|
|
755
|
+
throw new Error("Some provided LCOV reports were not valid.");
|
|
756
|
+
}
|
|
757
|
+
const flatReports = parsedReports.flat();
|
|
758
|
+
if (flatReports.length === 0) {
|
|
759
|
+
throw new Error("All provided reports are empty.");
|
|
760
|
+
}
|
|
761
|
+
const totalCoverageStats = getTotalCoverageFromLcovReports(
|
|
762
|
+
flatReports,
|
|
763
|
+
coverageTypes
|
|
764
|
+
);
|
|
765
|
+
return coverageTypes.map((coverageType) => {
|
|
766
|
+
const stats = totalCoverageStats[coverageType];
|
|
767
|
+
if (!stats) {
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
return lcovCoverageToAuditOutput(stats, coverageType);
|
|
771
|
+
}).filter(exists);
|
|
772
|
+
}
|
|
773
|
+
function getTotalCoverageFromLcovReports(records, coverageTypes) {
|
|
774
|
+
return records.reduce(
|
|
775
|
+
(acc, report) => Object.fromEntries([
|
|
776
|
+
...Object.entries(acc),
|
|
777
|
+
...Object.entries(
|
|
778
|
+
getCoverageStatsFromLcovRecord(report, coverageTypes)
|
|
779
|
+
).map(([type, stats]) => [
|
|
780
|
+
type,
|
|
781
|
+
{
|
|
782
|
+
totalFound: (acc[type]?.totalFound ?? 0) + stats.totalFound,
|
|
783
|
+
totalHit: (acc[type]?.totalHit ?? 0) + stats.totalHit,
|
|
784
|
+
issues: [...acc[type]?.issues ?? [], ...stats.issues]
|
|
785
|
+
}
|
|
786
|
+
])
|
|
787
|
+
]),
|
|
788
|
+
{}
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
function getCoverageStatsFromLcovRecord(record, coverageTypes) {
|
|
792
|
+
return Object.fromEntries(
|
|
793
|
+
coverageTypes.map((coverageType) => [
|
|
794
|
+
coverageType,
|
|
795
|
+
recordToStatFunctionMapper[coverageType](record)
|
|
796
|
+
])
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// packages/plugin-coverage/src/lib/utils.ts
|
|
801
|
+
function applyMaxScoreAboveThreshold(outputs, threshold) {
|
|
802
|
+
return outputs.map(
|
|
803
|
+
(output) => output.score >= threshold ? { ...output, score: 1 } : output
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// packages/plugin-coverage/src/lib/coverage-plugin.ts
|
|
808
|
+
var RUNNER_OUTPUT_PATH = join3(
|
|
809
|
+
pluginWorkDir("coverage"),
|
|
810
|
+
"runner-output.json"
|
|
811
|
+
);
|
|
812
|
+
function coveragePlugin(config) {
|
|
813
|
+
const { reports, perfectScoreThreshold, coverageTypes, coverageToolCommand } = coveragePluginConfigSchema.parse(config);
|
|
814
|
+
const audits = coverageTypes.map(
|
|
815
|
+
(type) => ({
|
|
816
|
+
slug: `${type}-coverage`,
|
|
817
|
+
title: `${capitalize(type)} coverage`,
|
|
818
|
+
description: `${capitalize(type)} coverage percentage on the project.`
|
|
819
|
+
})
|
|
820
|
+
);
|
|
821
|
+
const getAuditOutputs = async () => perfectScoreThreshold ? applyMaxScoreAboveThreshold(
|
|
822
|
+
await lcovResultsToAuditOutputs(reports, coverageTypes),
|
|
823
|
+
perfectScoreThreshold
|
|
824
|
+
) : await lcovResultsToAuditOutputs(reports, coverageTypes);
|
|
825
|
+
const runner = coverageToolCommand == null ? getAuditOutputs : {
|
|
826
|
+
command: coverageToolCommand.command,
|
|
827
|
+
args: coverageToolCommand.args,
|
|
828
|
+
outputFile: RUNNER_OUTPUT_PATH,
|
|
829
|
+
outputTransform: getAuditOutputs
|
|
830
|
+
};
|
|
831
|
+
return {
|
|
832
|
+
slug: "coverage",
|
|
833
|
+
title: "Code coverage",
|
|
834
|
+
icon: "folder-coverage-open",
|
|
835
|
+
description: "Official Code PushUp code coverage plugin.",
|
|
836
|
+
docsUrl: "https://www.npmjs.com/package/@code-pushup/coverage-plugin/",
|
|
837
|
+
packageName: name,
|
|
838
|
+
version,
|
|
839
|
+
audits,
|
|
840
|
+
runner
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// packages/plugin-coverage/src/index.ts
|
|
845
|
+
var src_default = coveragePlugin;
|
|
846
|
+
export {
|
|
847
|
+
src_default as default
|
|
848
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@code-pushup/coverage-plugin",
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"dependencies": {
|
|
5
|
+
"@code-pushup/models": "*",
|
|
6
|
+
"@code-pushup/utils": "*",
|
|
7
|
+
"parse-lcov": "^1.0.4",
|
|
8
|
+
"zod": "^3.22.4"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"homepage": "https://github.com/code-pushup/cli#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/code-pushup/cli/issues"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/code-pushup/cli.git",
|
|
18
|
+
"directory": "packages/plugin-coverage"
|
|
19
|
+
},
|
|
20
|
+
"contributors": [
|
|
21
|
+
{
|
|
22
|
+
"name": "Igor Katsuba",
|
|
23
|
+
"email": "igor@katsuba.dev",
|
|
24
|
+
"url": "https://katsuba.dev"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "Kateลina Pilรกtovรก",
|
|
28
|
+
"email": "katerina.pilatova@flowup.cz",
|
|
29
|
+
"url": "https://github.com/Tlacenka"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "Matฤj Chalk",
|
|
33
|
+
"email": "matej.chalk@flowup.cz",
|
|
34
|
+
"url": "https://github.com/matejchalk"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "Michael Hladky",
|
|
38
|
+
"email": "michael.hladky@push-based.io",
|
|
39
|
+
"url": "https://push-based.io"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "Michael Seredenko",
|
|
43
|
+
"email": "misha.seredenko@push-based.io",
|
|
44
|
+
"url": "https://github.com/MishaSeredenkoPushBased"
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"type": "module",
|
|
48
|
+
"main": "./index.js",
|
|
49
|
+
"types": "./src/index.d.ts"
|
|
50
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const coverageTypeSchema: z.ZodEnum<["function", "branch", "line"]>;
|
|
3
|
+
export type CoverageType = z.infer<typeof coverageTypeSchema>;
|
|
4
|
+
export declare const coverageReportSchema: z.ZodObject<{
|
|
5
|
+
resultsPath: z.ZodString;
|
|
6
|
+
pathToProject: z.ZodOptional<z.ZodString>;
|
|
7
|
+
}, "strip", z.ZodTypeAny, {
|
|
8
|
+
resultsPath: string;
|
|
9
|
+
pathToProject?: string | undefined;
|
|
10
|
+
}, {
|
|
11
|
+
resultsPath: string;
|
|
12
|
+
pathToProject?: string | undefined;
|
|
13
|
+
}>;
|
|
14
|
+
export type CoverageReport = z.infer<typeof coverageReportSchema>;
|
|
15
|
+
export declare const coveragePluginConfigSchema: z.ZodObject<{
|
|
16
|
+
coverageToolCommand: z.ZodOptional<z.ZodObject<{
|
|
17
|
+
command: z.ZodString;
|
|
18
|
+
args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
19
|
+
}, "strip", z.ZodTypeAny, {
|
|
20
|
+
command: string;
|
|
21
|
+
args?: string[] | undefined;
|
|
22
|
+
}, {
|
|
23
|
+
command: string;
|
|
24
|
+
args?: string[] | undefined;
|
|
25
|
+
}>>;
|
|
26
|
+
coverageTypes: z.ZodDefault<z.ZodArray<z.ZodEnum<["function", "branch", "line"]>, "many">>;
|
|
27
|
+
reports: z.ZodArray<z.ZodObject<{
|
|
28
|
+
resultsPath: z.ZodString;
|
|
29
|
+
pathToProject: z.ZodOptional<z.ZodString>;
|
|
30
|
+
}, "strip", z.ZodTypeAny, {
|
|
31
|
+
resultsPath: string;
|
|
32
|
+
pathToProject?: string | undefined;
|
|
33
|
+
}, {
|
|
34
|
+
resultsPath: string;
|
|
35
|
+
pathToProject?: string | undefined;
|
|
36
|
+
}>, "many">;
|
|
37
|
+
perfectScoreThreshold: z.ZodOptional<z.ZodNumber>;
|
|
38
|
+
}, "strip", z.ZodTypeAny, {
|
|
39
|
+
coverageTypes: ("function" | "branch" | "line")[];
|
|
40
|
+
reports: {
|
|
41
|
+
resultsPath: string;
|
|
42
|
+
pathToProject?: string | undefined;
|
|
43
|
+
}[];
|
|
44
|
+
coverageToolCommand?: {
|
|
45
|
+
command: string;
|
|
46
|
+
args?: string[] | undefined;
|
|
47
|
+
} | undefined;
|
|
48
|
+
perfectScoreThreshold?: number | undefined;
|
|
49
|
+
}, {
|
|
50
|
+
reports: {
|
|
51
|
+
resultsPath: string;
|
|
52
|
+
pathToProject?: string | undefined;
|
|
53
|
+
}[];
|
|
54
|
+
coverageToolCommand?: {
|
|
55
|
+
command: string;
|
|
56
|
+
args?: string[] | undefined;
|
|
57
|
+
} | undefined;
|
|
58
|
+
coverageTypes?: ("function" | "branch" | "line")[] | undefined;
|
|
59
|
+
perfectScoreThreshold?: number | undefined;
|
|
60
|
+
}>;
|
|
61
|
+
export type CoveragePluginConfig = z.input<typeof coveragePluginConfigSchema>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PluginConfig } from '@code-pushup/models';
|
|
2
|
+
import { CoveragePluginConfig } from './config';
|
|
3
|
+
export declare const RUNNER_OUTPUT_PATH: string;
|
|
4
|
+
/**
|
|
5
|
+
* Instantiates Code PushUp code coverage plugin for core config.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import coveragePlugin from '@code-pushup/coverage-plugin'
|
|
9
|
+
*
|
|
10
|
+
* export default {
|
|
11
|
+
* // ... core config ...
|
|
12
|
+
* plugins: [
|
|
13
|
+
* // ... other plugins ...
|
|
14
|
+
* await coveragePlugin({
|
|
15
|
+
* reports: [{ resultsPath: 'coverage/cli/lcov.info', pathToProject: 'packages/cli' }]
|
|
16
|
+
* })
|
|
17
|
+
* ]
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* @returns Plugin configuration.
|
|
21
|
+
*/
|
|
22
|
+
export declare function coveragePlugin(config: CoveragePluginConfig): PluginConfig;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { AuditOutputs } from '@code-pushup/models';
|
|
2
|
+
import { CoverageReport, CoverageType } from '../../config';
|
|
3
|
+
/**
|
|
4
|
+
*
|
|
5
|
+
* @param reports report files
|
|
6
|
+
* @param coverageTypes types of coverage to be considered
|
|
7
|
+
* @returns Audit outputs with complete coverage data.
|
|
8
|
+
*/
|
|
9
|
+
export declare function lcovResultsToAuditOutputs(reports: CoverageReport[], coverageTypes: CoverageType[]): Promise<AuditOutputs>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { LCOVRecord } from 'parse-lcov';
|
|
2
|
+
import { AuditOutput } from '@code-pushup/models';
|
|
3
|
+
import { CoverageType } from '../../config';
|
|
4
|
+
import { LCOVStat } from './types';
|
|
5
|
+
export declare function lcovReportToFunctionStat(record: LCOVRecord): LCOVStat;
|
|
6
|
+
export declare function lcovReportToLineStat(record: LCOVRecord): LCOVStat;
|
|
7
|
+
export declare function lcovReportToBranchStat(record: LCOVRecord): LCOVStat;
|
|
8
|
+
export declare const recordToStatFunctionMapper: {
|
|
9
|
+
branch: typeof lcovReportToBranchStat;
|
|
10
|
+
line: typeof lcovReportToLineStat;
|
|
11
|
+
function: typeof lcovReportToFunctionStat;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
*
|
|
15
|
+
* @param stat code coverage result for a given type
|
|
16
|
+
* @param coverageType code coverage type
|
|
17
|
+
* @returns Result of complete code ccoverage data coverted to AuditOutput
|
|
18
|
+
*/
|
|
19
|
+
export declare function lcovCoverageToAuditOutput(stat: LCOVStat, coverageType: CoverageType): AuditOutput;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Issue } from '@code-pushup/models';
|
|
2
|
+
import { CoverageType } from '../../config';
|
|
3
|
+
export type LCOVStat = {
|
|
4
|
+
totalFound: number;
|
|
5
|
+
totalHit: number;
|
|
6
|
+
issues: Issue[];
|
|
7
|
+
};
|
|
8
|
+
export type LCOVStats = Partial<Record<CoverageType, LCOVStat>>;
|
|
9
|
+
export type NumberRange = {
|
|
10
|
+
start: number;
|
|
11
|
+
end?: number;
|
|
12
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { NumberRange } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* This function calculates coverage as ratio of tested entities vs total
|
|
4
|
+
* @param hit how many entities were executed in at least one test
|
|
5
|
+
* @param found how many entities were found overall
|
|
6
|
+
* @returns coverage between 0 and 1
|
|
7
|
+
*/
|
|
8
|
+
export declare function calculateCoverage(hit: number, found: number): number;
|
|
9
|
+
export declare function mergeConsecutiveNumbers(numberArr: number[]): NumberRange[];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AuditOutputs } from '@code-pushup/models';
|
|
2
|
+
/**
|
|
3
|
+
* Since more code coverage does not necessarily mean better score, this optional override allows for defining custom coverage goals.
|
|
4
|
+
* @param outputs original results
|
|
5
|
+
* @param threshold threshold above which the score is to be 1
|
|
6
|
+
* @returns Outputs with overriden score (not value) to 1 if it reached a defined threshold.
|
|
7
|
+
*/
|
|
8
|
+
export declare function applyMaxScoreAboveThreshold(outputs: AuditOutputs, threshold: number): AuditOutputs;
|