Merge pull request #19469 from ganhammar/feat/add-support-for-lcov

Adds support for sending LCOV coverage reports
This commit is contained in:
Johan Haals
2023-08-22 10:21:23 +02:00
committed by GitHub
15 changed files with 501 additions and 9 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-code-coverage-backend': patch
---
Added support for LCOV coverage reports
+18
View File
@@ -125,6 +125,24 @@ Example:
}
```
### Adding a LCOV report
POST a LCOV INFO file to `/report`
Example:
```json
// curl -X POST -H "Content-Type:text/plain" -d @coverage.info "localhost:7007/api/code-coverage/report?entity=component:default/entity-name&coverageType=lcov"
{
"links": [
{
"href": "http://localhost:7007/api/code-coverage/report?entity=component:default/entity-name",
"rel": "coverage"
}
]
}
```
### Reading json coverage
GET `/report`
@@ -224,11 +224,11 @@ describe('CodeCoverageUtils', () => {
err = error;
}
expect(err?.message).toEqual(
'Content-Type header "application/json" not supported, expected "text/xml" possibly followed by a charset',
'Content-Type header "application/json" not supported, expected "text/xml" or "text/plain" possibly followed by a charset',
);
});
it('parses the body', () => {
it('parses the xml body', () => {
const data: Readable = utils.validateRequestBody({
headers: {
'content-type': 'text/xml',
@@ -240,6 +240,19 @@ describe('CodeCoverageUtils', () => {
expect(data.read()).toContain('<?xml');
});
it('parses the plain body', () => {
const data: Readable = utils.validateRequestBody({
headers: {
'content-type': 'text/plain',
},
body: Readable.from(
'TN:\nSF:/src/index.js\nFNF:0\nFNH:0\nLF:1\nLH:1\nBRF:0\nBRH:0\nend_of_record',
),
} as Request);
expect(data.read()).toMatch(/^TN:\nSF:\/src\/index.js/);
});
});
describe('processCoveragePayload', () => {
@@ -163,9 +163,10 @@ export class CoverageUtils {
const contentType = req.headers['content-type'];
if (!contentType) {
throw new InputError('Content-Type header missing');
} else if (!contentType.match(/^text\/xml($|;)/)) {
// text/xml or text/plain is allowed
} else if (!contentType.match(/^text\/xml|plain($|;)/)) {
throw new InputError(
`Content-Type header "${contentType}" not supported, expected "text/xml" possibly followed by a charset`,
`Content-Type header "${contentType}" not supported, expected "text/xml" or "text/plain" possibly followed by a charset`,
);
}
const body = req.body;
@@ -0,0 +1,103 @@
[
{
"filename": "src/index.js",
"lineHits": {
"1": 1,
"2": 1,
"3": 1,
"4": 5,
"5": 5,
"6": 1,
"7": 5,
"8": 0,
"9": 5,
"10": 3,
"11": 5,
"12": 1,
"13": 5,
"14": 5
},
"branchHits": {
"3": {
"covered": 1,
"missed": 0,
"available": 1
},
"5": {
"covered": 1,
"missed": 0,
"available": 1
},
"7": {
"covered": 0,
"missed": 1,
"available": 1
},
"9": {
"covered": 1,
"missed": 0,
"available": 1
},
"11": {
"covered": 1,
"missed": 0,
"available": 1
}
}
},
{
"filename": "src/math.js",
"lineHits": {
"1": 1,
"2": 1,
"3": 1,
"4": 1,
"5": 1,
"6": 0,
"7": 0,
"8": 1,
"9": 1,
"10": 3,
"11": 0,
"12": 3,
"13": 1,
"14": 3,
"15": 1,
"16": 1,
"17": 1,
"18": 1
},
"branchHits": {
"2": {
"covered": 1,
"missed": 0,
"available": 1
},
"9": {
"covered": 1,
"missed": 0,
"available": 1
},
"10": {
"covered": 0,
"missed": 1,
"available": 1
},
"12": {
"covered": 2,
"missed": 0,
"available": 2
},
"14": {
"covered": 2,
"missed": 0,
"available": 2
},
"17": {
"covered": 1,
"missed": 0,
"available": 1
}
}
}
]
@@ -0,0 +1,47 @@
[
{
"filename": "src/Calc/Main.cs",
"lineHits": {
"5": 4,
"6": 4,
"7": 1,
"8": 0,
"9": 2,
"10": 1,
"11": 4
},
"branchHits": {
"5": {
"covered": 5,
"missed": 1,
"available": 6
}
}
},
{
"filename": "src/Calc/Math.cs",
"lineHits": {
"5": 1,
"6": 0,
"7": 2,
"8": 2,
"9": 0,
"11": 3,
"12": 1,
"14": 1,
"15": 1
},
"branchHits": {
"8": {
"covered": 1,
"missed": 1,
"available": 2
},
"11": {
"covered": 2,
"missed": 0,
"available": 2
}
}
}
]
@@ -0,0 +1,2 @@
src/index.js
src/math.js
@@ -0,0 +1,2 @@
src/Calc/Main.cs
src/Calc/Math.cs
@@ -0,0 +1,71 @@
TN:
SF:src/index.js
FN:3,__vite_ssr_exports__.default
FNF:1
FNH:1
FNDA:5,__vite_ssr_exports__.default
DA:1,1
DA:2,1
DA:3,1
DA:4,5
DA:5,5
DA:6,1
DA:7,5
DA:8,0
DA:9,5
DA:10,3
DA:11,5
DA:12,1
DA:13,5
DA:14,5
LF:14
LH:13
BRDA:3,0,0,5
BRDA:5,1,0,1
BRDA:7,2,0,0
BRDA:9,3,0,3
BRDA:11,4,0,1
BRF:5
BRH:4
end_of_record
TN:
SF:src/math.js
FN:2,addition
FN:5,subtraction
FN:9,division
FNF:3
FNH:2
FNDA:1,addition
FNDA:0,subtraction
FNDA:3,division
DA:1,1
DA:2,1
DA:3,1
DA:4,1
DA:5,1
DA:6,0
DA:7,0
DA:8,1
DA:9,1
DA:10,3
DA:11,0
DA:12,3
DA:13,1
DA:14,3
DA:15,1
DA:16,1
DA:17,1
DA:18,1
LF:18
LH:15
BRDA:2,0,0,1
BRDA:9,1,0,3
BRDA:10,2,0,0
BRDA:12,3,0,2
BRDA:12,4,0,1
BRDA:14,5,0,2
BRDA:14,6,0,1
BRDA:17,7,0,1
BRF:8
BRH:7
end_of_record
@@ -0,0 +1,50 @@
SF:/Some/Path/src/Calc/Main.cs
FN:4,System.Int32 Calc.Main::Calc(System.Int32,System.Int32,System.String)
FNDA:4,System.Int32 Calc.Main::Calc(System.Int32,System.Int32,System.String)
DA:5,4
DA:6,4
DA:7,1
DA:8,0
DA:9,2
DA:10,1
DA:11,4
BRDA:5,15,0,3
BRDA:5,28,0,3
BRDA:5,15,1,1
BRDA:5,28,1,0
BRDA:5,41,1,2
BRDA:5,41,0,1
LF:7
LH:6
BRF:6
BRH:5
FNF:1
FNH:1
end_of_record
SF:/Some/Path/src/Calc/Math.cs
FN:4,System.Int32 Calc.Math::Addition(System.Int32,System.Int32)
FNDA:1,System.Int32 Calc.Math::Addition(System.Int32,System.Int32)
DA:5,1
FN:5,System.Int32 Calc.Math::Subtraction(System.Int32,System.Int32)
FNDA:0,System.Int32 Calc.Math::Subtraction(System.Int32,System.Int32)
DA:6,0
FN:6,System.Int32 Calc.Math::Division(System.Int32,System.Int32)
FNDA:2,System.Int32 Calc.Math::Division(System.Int32,System.Int32)
DA:7,2
DA:8,2
DA:9,0
DA:11,3
DA:12,1
DA:14,1
DA:15,1
BRDA:8,7,0,0
BRDA:8,7,1,2
BRDA:11,22,0,1
BRDA:11,22,1,1
LF:9
LH:7
BRF:4
BRH:3
FNF:3
FNH:2
end_of_record
@@ -17,3 +17,4 @@
export * from './Converter';
export { Cobertura } from './cobertura';
export { Jacoco } from './jacoco';
export { Lcov } from './lcov';
@@ -0,0 +1,53 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import path from 'path';
import { Lcov } from './lcov';
import { getVoidLogger } from '@backstage/backend-common';
describe('convert lcov', () => {
const converter = new Lcov(getVoidLogger());
[1, 2].forEach(idx => {
const lcov = fs
.readFileSync(
path.resolve(`${__dirname}/../__fixtures__/lcov-testdata-${idx}.info`),
)
.toString();
const expected = JSON.parse(
fs
.readFileSync(
path.resolve(
`${__dirname}/../__fixtures__/lcov-jsoncoverage-files-${idx}.json`,
),
)
.toString(),
);
const scmFiles = fs
.readFileSync(
path.resolve(
`${__dirname}/../__fixtures__/lcov-sourcefiles-${idx}.txt`,
),
)
.toString()
.split('\n');
it(`should convert the lcov report with index ${idx} to json`, async () => {
const files = converter.convert(lcov, scmFiles);
expect(files).toEqual(expected);
});
});
});
@@ -0,0 +1,126 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FileEntry } from '../types';
import { Logger } from 'winston';
import { Converter } from './Converter';
export class Lcov implements Converter {
constructor(readonly logger: Logger) {
this.logger = logger;
}
/**
* convert lcov into shared json coverage format
*
* @param raw - raw lcov report
* @param scmFiles - list of files that are committed to SCM
*/
convert(raw: string, scmFiles: string[]): FileEntry[] {
const lines = raw.split(/\r?\n/);
const jscov: Array<FileEntry> = [];
let currentFile: FileEntry | null = null;
lines.forEach(line => {
const [section, value] = line.split(':');
switch (section) {
// If the line starts with SF, it's a new file
case 'SF':
currentFile = this.processNewFile(value, scmFiles);
break;
// If the line starts with DA, it's a line hit
case 'DA':
this.processLineHit(currentFile, value);
break;
// If the line starts with BRDA, it's a branch line
case 'BRDA':
this.processBranchHit(currentFile, value);
break;
// If the line starts with end_of_record, it's the end of current file
case 'end_of_record':
if (currentFile) {
jscov.push(currentFile);
currentFile = null;
}
break;
default:
break;
}
});
return jscov;
}
/**
* Parses a new file entry
*
* @param file - file name from coverage report
* @param scmFiles - list of files that are committed to SCM
*/
private processNewFile(file: string, scmFiles: string[]): FileEntry | null {
const filename = scmFiles.map(f => f.trimEnd()).find(f => file.endsWith(f));
this.logger.debug(`matched ${file} to ${filename}`);
if (filename) {
return {
filename,
lineHits: {},
branchHits: {},
};
}
return null;
}
/**
* Parses line coverage information
*
* @param currentFile - current file entry
* @param value - line coverage information
*/
private processLineHit(currentFile: FileEntry | null, value: string): void {
if (!currentFile) return;
const [lineNumber, hits] = value.split(',');
currentFile.lineHits[Number(lineNumber)] = Number(hits);
}
/**
* Parses branch coverage information
*
* @param currentFile - current file entry
* @param value - branch coverage information
*/
private processBranchHit(currentFile: FileEntry | null, value: string): void {
if (!currentFile) return;
const [lineNumber, , , hits] = value.split(',');
const lineNumberNum = Number(lineNumber);
const isHit = Number(hits) > 0;
const branch = currentFile.branchHits[lineNumberNum] || {
covered: 0,
available: 0,
missed: 0,
};
branch.available++;
branch.covered += isHit ? 1 : 0;
branch.missed += isHit ? 0 : 1;
currentFile.branchHits[lineNumberNum] = branch;
}
}
@@ -30,9 +30,7 @@ import { Config } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { CodeCoverageDatabase } from './CodeCoverageDatabase';
import { aggregateCoverage, CoverageUtils } from './CoverageUtils';
import { Cobertura } from './converter/cobertura';
import { Jacoco } from './converter/jacoco';
import { Converter } from './converter';
import { Converter, Jacoco, Cobertura, Lcov } from './converter';
import { getEntitySourceLocation } from '@backstage/catalog-model';
/**
@@ -182,6 +180,8 @@ export const makeRouter = async (
converter = new Jacoco(logger);
} else if (coverageType === 'cobertura') {
converter = new Cobertura(logger);
} else if (coverageType === 'lcov') {
converter = new Lcov(logger);
} else {
throw new InputError(`Unsupported coverage type '${coverageType}`);
}
@@ -16,8 +16,8 @@
import {
createServiceBuilder,
HostDiscovery,
loadBackendConfig,
SingleHostDiscovery,
UrlReaders,
useHotMemoize,
} from '@backstage/backend-common';
@@ -71,7 +71,7 @@ export async function startStandaloneServer(
const router = await createRouter({
database: { getClient: async () => db },
config,
discovery: SingleHostDiscovery.fromConfig(config),
discovery: HostDiscovery.fromConfig(config),
urlReader: UrlReaders.default({ logger, config }),
logger,
catalogApi,