diff --git a/plugins/code-coverage-backend/src/service/CoverageUtils.test.ts b/plugins/code-coverage-backend/src/service/CoverageUtils.test.ts index 44b1411cc4..23c7c6cba0 100644 --- a/plugins/code-coverage-backend/src/service/CoverageUtils.test.ts +++ b/plugins/code-coverage-backend/src/service/CoverageUtils.test.ts @@ -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(' { + 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', () => { diff --git a/plugins/code-coverage-backend/src/service/CoverageUtils.ts b/plugins/code-coverage-backend/src/service/CoverageUtils.ts index a4d3405868..bfe79e869f 100644 --- a/plugins/code-coverage-backend/src/service/CoverageUtils.ts +++ b/plugins/code-coverage-backend/src/service/CoverageUtils.ts @@ -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; diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/lcov-jsoncoverage-files-1.json b/plugins/code-coverage-backend/src/service/__fixtures__/lcov-jsoncoverage-files-1.json new file mode 100644 index 0000000000..1d0ed702df --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/lcov-jsoncoverage-files-1.json @@ -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 + } + } + } +] diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/lcov-jsoncoverage-files-2.json b/plugins/code-coverage-backend/src/service/__fixtures__/lcov-jsoncoverage-files-2.json new file mode 100644 index 0000000000..7de4e7dcf7 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/lcov-jsoncoverage-files-2.json @@ -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 + } + } + } +] diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/lcov-sourcefiles-1.txt b/plugins/code-coverage-backend/src/service/__fixtures__/lcov-sourcefiles-1.txt new file mode 100644 index 0000000000..4f4bf2c118 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/lcov-sourcefiles-1.txt @@ -0,0 +1,2 @@ +src/index.js +src/math.js diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/lcov-sourcefiles-2.txt b/plugins/code-coverage-backend/src/service/__fixtures__/lcov-sourcefiles-2.txt new file mode 100644 index 0000000000..3aa3c36039 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/lcov-sourcefiles-2.txt @@ -0,0 +1,2 @@ +src/Calc/Main.cs +src/Calc/Math.cs diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/lcov-testdata-1.info b/plugins/code-coverage-backend/src/service/__fixtures__/lcov-testdata-1.info new file mode 100644 index 0000000000..0bf69f6bc7 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/lcov-testdata-1.info @@ -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 diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/lcov-testdata-2.info b/plugins/code-coverage-backend/src/service/__fixtures__/lcov-testdata-2.info new file mode 100644 index 0000000000..688bffb521 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/lcov-testdata-2.info @@ -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 diff --git a/plugins/code-coverage-backend/src/service/converter/index.ts b/plugins/code-coverage-backend/src/service/converter/index.ts index a85f320486..7e6d95ead5 100644 --- a/plugins/code-coverage-backend/src/service/converter/index.ts +++ b/plugins/code-coverage-backend/src/service/converter/index.ts @@ -17,3 +17,4 @@ export * from './Converter'; export { Cobertura } from './cobertura'; export { Jacoco } from './jacoco'; +export { Lcov } from './lcov'; diff --git a/plugins/code-coverage-backend/src/service/converter/lcov.test.ts b/plugins/code-coverage-backend/src/service/converter/lcov.test.ts new file mode 100644 index 0000000000..e31261ba77 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/converter/lcov.test.ts @@ -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); + }); + }); +}); diff --git a/plugins/code-coverage-backend/src/service/converter/lcov.ts b/plugins/code-coverage-backend/src/service/converter/lcov.ts new file mode 100644 index 0000000000..319377764a --- /dev/null +++ b/plugins/code-coverage-backend/src/service/converter/lcov.ts @@ -0,0 +1,79 @@ +/* + * 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(raw: string, scmFiles: string[]): FileEntry[] { + const lines = raw.split(/\r?\n/); + const jscov: Array = []; + let currentFile: FileEntry | null = null; + + lines.forEach(line => { + // If the line starts with SF, it's a new file + if (/^SF:/.test(line)) { + const coverageFilename = line.replace(/^SF:/, ''); + const filename = scmFiles + .map(f => f.trimEnd()) + .find(f => coverageFilename.endsWith(f)); + + this.logger.debug(`matched ${coverageFilename} to ${currentFile}`); + + if (filename) { + currentFile = { + filename, + lineHits: {}, + branchHits: {}, + }; + } + + // If the line starts with DA, it's a line hit + } else if (currentFile && /^DA:/.test(line)) { + const [, lineNumber, hits] = line.split(/[,:]+/); + currentFile.lineHits[Number(lineNumber)] = Number(hits); + + // If the line starts with BRDA, it's a branch + } else if (currentFile && /^BRDA:/.test(line)) { + const [, lineNumber, , , hits] = line.split(/[,:]+/); + const isHit = Number(hits) > 0; + + if (!currentFile.branchHits[Number(lineNumber)]) { + currentFile.branchHits[Number(lineNumber)] = { + covered: isHit ? 1 : 0, + available: 1, + missed: isHit ? 0 : 1, + }; + } else { + currentFile.branchHits[Number(lineNumber)].available += 1; + currentFile.branchHits[Number(lineNumber)].covered += isHit ? 1 : 0; + currentFile.branchHits[Number(lineNumber)].missed += isHit ? 0 : 1; + } + + // If the line starts with end_of_record, it's the end of current file + } else if (currentFile && /^end_of_record/.test(line)) { + jscov.push(currentFile); + currentFile = null; + } + }); + + return jscov; + } +} diff --git a/plugins/code-coverage-backend/src/service/router.ts b/plugins/code-coverage-backend/src/service/router.ts index 4d4fb779b5..c4f98bd2fb 100644 --- a/plugins/code-coverage-backend/src/service/router.ts +++ b/plugins/code-coverage-backend/src/service/router.ts @@ -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}`); } diff --git a/plugins/code-coverage-backend/src/service/standaloneServer.ts b/plugins/code-coverage-backend/src/service/standaloneServer.ts index 41493660fb..23eaf981c1 100644 --- a/plugins/code-coverage-backend/src/service/standaloneServer.ts +++ b/plugins/code-coverage-backend/src/service/standaloneServer.ts @@ -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,