Merge pull request #19469 from ganhammar/feat/add-support-for-lcov
Adds support for sending LCOV coverage reports
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-code-coverage-backend': patch
|
||||
---
|
||||
|
||||
Added support for LCOV coverage reports
|
||||
@@ -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;
|
||||
|
||||
+103
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user