Create code-coverage plugin

In order to use this plugin, you must set the
`backstage.io/code-coverage` annotation on your entity.

```yaml
backstage.io/code-coverage: enabled
```

There's a feature to only include files that are in SCM in the coverage
report, this is helpful to not count generated files for example. To
enable this set the `backstage.io/code-coverage` annotation to
`scm-only`.

```yaml
backstage.io/code-coverage: scm-only
```

The backend plugin provides API endpoints for submitting code-coverage
reports. Currently jacoco and cobertura are supported. These reports
are normalized to a json format that is stored in the database.

```json
// curl -X POST -H "Content-Type:text/xml" -d @cobertura.xml "localhost:7000/api/code-coverage/Component/default/entity-name?coverageType=cobertura"
{
    "links": [
        {
            "href": "http://localhost:7000/api/code-coverage/Component/default/entity-name",
            "rel": "coverage"
        }
    ]
}
```

It also provides some additional API endpoints:
* Viewing the latest report
* Viewing a more condensed history of code coverage values
* Retrieving file contents from source-control, used by the UI

Provides a graph of code coverage change over time, as well as a file
view where you can see the highlighted lines.

Co-authored-by: nissayeva <natashaaay@gmail.com>
Signed-off-by: alde <r.dybeck@gmail.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
alde
2021-02-24 16:48:38 -05:00
committed by Fredrik Adelöw
parent ce05bf61e1
commit e7d2fb93f0
67 changed files with 16733 additions and 11 deletions
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+19
View File
@@ -0,0 +1,19 @@
# code-coverage
This is the frontend part of the code-coverage plugin. It takes care of processing various coverage formats and standardizing them into a single json format, used by the frontend.
## Configuring your entity
In order to use this plugin, you must set the `backstage.io/code-coverage` annotation.
```yaml
backstage.io/code-coverage: enabled
```
There's a feature to only include files that are in VCS in the coverage report, this is helpful to not count generated files for example. To enable this set the `backstage.io/code-coverage` annotation to `scm-only`.
```yaml
backstage.io/code-coverage: scm-only
```
Note: This requires the [`backstage.io/source-location` annotation](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiosource-location) to be set.
+26
View File
@@ -0,0 +1,26 @@
/*
* Copyright 2021 Spotify AB
*
* 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 React from 'react';
import { createDevApp } from '@backstage/dev-utils';
import { codeCoveragePlugin, CodeCoveragePage } from '../src/plugin';
createDevApp()
.registerPlugin(codeCoveragePlugin)
.addPage({
element: <CodeCoveragePage />,
title: 'Root Page',
})
.render();
+57
View File
@@ -0,0 +1,57 @@
{
"name": "@backstage/plugin-code-coverage",
"version": "0.1.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.7.2",
"@backstage/config": "^0.1.3",
"@backstage/core": "^0.6.2",
"@backstage/core-api": "^0.2.12",
"@backstage/dev-utils": "^0.1.11",
"@backstage/plugin-catalog-react": "^0.1.0",
"@backstage/theme": "^0.2.3",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/highlightjs": "^10.1.0",
"highlight.js": "^10.6.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"recharts": "^1.8.5"
},
"devDependencies": {
"@backstage/cli": "^0.6.1",
"@backstage/test-utils": "^0.1.7",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/recharts": "^1.8.15",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
},
"files": [
"dist"
]
}
+102
View File
@@ -0,0 +1,102 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { createApiRef } from '@backstage/core';
import { Config } from '@backstage/config';
import { EntityName } from '@backstage/catalog-model';
export class FetchError extends Error {
get name(): string {
return this.constructor.name;
}
static async forResponse(resp: Response): Promise<FetchError> {
return new FetchError(
`Request failed with status code ${
resp.status
}.\nReason: ${await resp.text()}`,
);
}
}
export type CodeCoverageApi = {
url: string;
getCoverageForEntity: (entity: EntityName) => Promise<any>;
getFileContentFromEntity: (
entity: EntityName,
filePath: string,
) => Promise<any>;
getCoverageHistoryForEntity: (
entity: EntityName,
limit?: number,
) => Promise<any>;
};
export const codeCoverageApiRef = createApiRef<CodeCoverageApi>({
id: 'plugin.code-coverage.service',
description: 'Used by the code coverage plugin to make requests',
});
export class CodeCoverageRestApi implements CodeCoverageApi {
static fromConfig(config: Config) {
return new CodeCoverageRestApi(config.getString('backend.baseUrl'));
}
constructor(public url: string) {}
private async fetch<T = any | string>(
input: string,
init?: RequestInit,
): Promise<T | string> {
const resp = await fetch(`${this.url}${input}`, init);
if (!resp.ok) throw await FetchError.forResponse(resp);
if (resp.headers.get('content-type')?.includes('application/json')) {
return await resp.json();
}
return await resp.text();
}
async getCoverageForEntity({
kind,
namespace,
name,
}: EntityName): Promise<any> {
return await this.fetch<any>(
`/api/code-coverage/${kind}/${namespace}/${name}`,
);
}
async getFileContentFromEntity(
{ kind, namespace, name }: EntityName,
filePath: string,
): Promise<any> {
return await this.fetch<any>(
`/api/code-coverage/${kind}/${namespace}/${name}/file-content?path=${filePath}`,
);
}
async getCoverageHistoryForEntity(
{ kind, namespace, name }: EntityName,
limit?: number,
): Promise<any> {
const hasValidLimit = limit && limit > 0;
return await this.fetch<any>(
`/api/code-coverage/${kind}/${namespace}/${name}/history${
hasValidLimit ? `?limit=${limit}` : ''
}`,
);
}
}
@@ -0,0 +1,31 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React from 'react';
import { Content, ContentHeader, Page } from '@backstage/core';
import { CoverageHistoryChart } from '../CoverageHistoryChart';
import { FileExplorer } from '../FileExplorer';
export const CodeCoveragePage = () => {
return (
<Page themeId="tool">
<Content>
<ContentHeader title="Code coverage" />
<CoverageHistoryChart />
<FileExplorer />
</Content>
</Page>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { CodeCoveragePage } from './CodeCoveragePage';
@@ -0,0 +1,169 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React from 'react';
import {
Box,
Card,
CardContent,
MenuItem,
Select,
Typography,
} from '@material-ui/core';
import {
LineChart,
XAxis,
YAxis,
Tooltip,
Legend,
Line,
CartesianGrid,
ResponsiveContainer,
} from 'recharts';
import { useAsync } from 'react-use';
import { useApi } from '@backstage/core-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { codeCoverageApiRef } from '../../api';
import { Progress } from '@backstage/core';
import { Alert } from '@material-ui/lab';
import TrendingDownIcon from '@material-ui/icons/TrendingDown';
import TrendingUpIcon from '@material-ui/icons/TrendingUp';
import TrendingFlatIcon from '@material-ui/icons/TrendingFlat';
type Coverage = 'line' | 'branch';
const getTrendIcon = (trend: number) => {
switch (true) {
case trend > 0:
return <TrendingUpIcon style={{ color: 'green' }} />;
case trend < 0:
return <TrendingDownIcon style={{ color: 'red' }} />;
case trend === 0:
default:
return <TrendingFlatIcon />;
}
};
export const CoverageHistoryChart = () => {
const { entity } = useEntity();
const codeCoverageApi = useApi(codeCoverageApiRef);
const {
loading: loadingHistory,
error: errorHistory,
value: valueHistory,
} = useAsync(
async () =>
await codeCoverageApi.getCoverageHistoryForEntity({
kind: entity.kind,
namespace: entity.metadata.namespace || 'default',
name: entity.metadata.name,
}),
);
if (loadingHistory) {
return <Progress />;
} else if (errorHistory) {
return <Alert severity="error">{errorHistory.message}</Alert>;
}
if (!valueHistory.history.length) {
return (
<Box>
<Card>
<CardContent>
<Box mb={2} display="flex" justifyContent="space-between">
<Typography variant="h5">History</Typography>
</Box>
No coverage history found
</CardContent>
</Card>
</Box>
);
}
const oldestCoverage = valueHistory.history[0];
const [latestCoverage] = valueHistory.history.slice(-1);
const getTrendForCoverage = (type: Coverage) => {
if (!oldestCoverage[type].percentage) {
return 0;
}
return (
((latestCoverage[type].percentage - oldestCoverage[type].percentage) /
oldestCoverage[type].percentage) *
100
);
};
const lineTrend = getTrendForCoverage('line');
const branchTrend = getTrendForCoverage('branch');
return (
<Box>
<Card>
<CardContent>
<Box mb={2} display="flex" justifyContent="space-between">
<Typography variant="h5">History</Typography>
<Select>
<MenuItem>7</MenuItem>
</Select>
</Box>
<Box px={6} display="flex">
<Box display="flex" mr={4}>
{getTrendIcon(lineTrend)}
<Typography>
Current line: {latestCoverage.line.percentage}%<br />(
{Math.floor(lineTrend)}% change over{' '}
{valueHistory.history.length} builds)
</Typography>
</Box>
<Box display="flex">
{getTrendIcon(branchTrend)}
<Typography>
Current branch: {latestCoverage.branch.percentage}%<br />(
{Math.floor(branchTrend)}% change over{' '}
{valueHistory.history.length} builds)
</Typography>
</Box>
</Box>
<ResponsiveContainer width="100%" height={300}>
<LineChart
data={valueHistory.history}
margin={{ right: 48, top: 32 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="timestamp" />
<YAxis dataKey="line.percentage" />
<YAxis dataKey="branch.percentage" />
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="branch.percentage"
stroke="#8884d8"
/>
<Line
type="monotone"
dataKey="line.percentage"
stroke="#82ca9d"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</Box>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { CoverageHistoryChart } from './CoverageHistoryChart';
@@ -0,0 +1,103 @@
/*
* Copyright 2021 Spotify AB
*
* 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 React, { FC } from 'react';
// styles
import { makeStyles } from '@material-ui/core';
import { red, green } from '@material-ui/core/colors';
const useStyles = makeStyles(theme => ({
lineNumberCell: {
color: `${theme.palette.grey[500]}`,
fontSize: '90%',
borderRight: `1px solid ${theme.palette.grey[500]}`,
paddingRight: '8px',
textAlign: 'right',
},
hitCountCell: {
width: '50px',
borderRight: `1px solid ${theme.palette.grey[500]}`,
textAlign: 'center',
color: 'white', // need to enforce this color since it needs to stand out against colored background
paddingLeft: `${theme.spacing(1)}`,
paddingRight: `${theme.spacing(1)}`,
},
countRoundedRectangle: {
borderRadius: '45px',
fontSize: '90%',
padding: '1px 3px 1px 3px',
width: '50px',
},
hitCountRoundedRectangle: {
backgroundColor: `${green[500]}`,
},
notHitCountRoundedRectangle: {
backgroundColor: `${red[500]}`,
},
codeLine: {
paddingLeft: `${theme.spacing(1)}`,
whiteSpace: 'pre',
fontSize: '90%',
},
hitCodeLine: {
backgroundColor: `${green[100]}`,
},
notHitCodeLine: {
backgroundColor: `${red[100]}`,
},
}));
type CodeRowProps = {
lineNumber: number;
lineContent: string;
lineHits?: number | null;
};
const CodeRow: FC<CodeRowProps> = ({
lineNumber,
lineContent,
lineHits = null,
}: CodeRowProps) => {
const classes = useStyles();
const hitCountRoundedRectangleClass = [classes.countRoundedRectangle];
const lineContentClass = [classes.codeLine];
let hitRoundedRectangle = null;
if (lineHits !== null) {
if (lineHits > 0) {
hitCountRoundedRectangleClass.push(classes.hitCountRoundedRectangle);
lineContentClass.push(classes.hitCodeLine);
} else {
hitCountRoundedRectangleClass.push(classes.notHitCountRoundedRectangle);
lineContentClass.push(classes.notHitCodeLine);
}
hitRoundedRectangle = (
<div className={hitCountRoundedRectangleClass.join(' ')}>{lineHits}</div>
);
}
return (
<tr>
<td className={classes.lineNumberCell}>{lineNumber}</td>
<td className={classes.hitCountCell}>{hitRoundedRectangle}</td>
<td
className={lineContentClass.join(' ')}
dangerouslySetInnerHTML={{ __html: lineContent }}
/>
</tr>
);
};
export default CodeRow;
@@ -0,0 +1,137 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React, { FC, useEffect } from 'react';
import { useApi } from '@backstage/core-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { useAsync } from 'react-use';
import { codeCoverageApiRef } from '../../api';
import { Progress } from '@backstage/core';
import { Alert } from '@material-ui/lab';
import { makeStyles, Paper } from '@material-ui/core';
import { highlightLines } from './Highlighter';
import { FileCoverage } from './FileExplorer';
import CoverageRow from './CoverageRow';
type Props = {
filename: string;
coverage: FileCoverage;
};
const getContent = async (value: any) => {
let blob = value;
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
blob = new Blob([value], { type: 'application/octet-stream' });
}
return value;
};
const useStyles = makeStyles(theme => ({
paper: {
margin: 'auto',
top: '2em',
width: '80%',
backgroundColor: theme.palette.background.paper,
border: '2px solid #000',
boxShadow: theme.shadows[5],
padding: theme.spacing(2, 4, 3),
overflow: 'scroll',
},
coverageFileViewTable: {
borderSpacing: '0px',
width: '80%',
marginTop: theme.spacing(2),
},
}));
type FormattedLinesProps = {
highlightedLines: string[];
lineHits: Record<number, number>;
};
const FormattedLines: FC<FormattedLinesProps> = ({
highlightedLines,
lineHits,
}: FormattedLinesProps) => {
return (
<>
{highlightedLines.map((lineContent, idx) => {
const line = idx + 1;
return (
<CoverageRow
key={line}
lineNumber={line}
lineContent={lineContent}
lineHits={lineHits[line]}
/>
);
})}
</>
);
};
export const FileContent = ({ filename, coverage }: Props) => {
const { entity } = useEntity();
const codeCoverageApi = useApi(codeCoverageApiRef);
const { loading, error, value } = useAsync(
async () =>
await codeCoverageApi.getFileContentFromEntity(
{
kind: entity.kind,
namespace: entity.metadata.namespace || 'default',
name: entity.metadata.name,
},
filename,
),
);
useEffect(() => {
if (value) getContent(value);
}, [value]);
const classes = useStyles();
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
}
const [language] = filename.split('.').slice(-1);
const highlightedLines = highlightLines(language, value.split('\n'));
// List of formatted nodes containing highlighted code
// lineHits array where lineHits[i] is number of hits for line i + 1
const lineHits = Object.entries(coverage.lineHits).reduce(
(acc: Record<string, number>, next: [string, number]) => {
acc[next[0]] = next[1];
return acc;
},
{},
);
return (
<Paper variant="outlined" className={classes.paper}>
<table className={classes.coverageFileViewTable}>
<tbody>
<FormattedLines
highlightedLines={highlightedLines}
lineHits={lineHits}
/>
</tbody>
</table>
</Paper>
);
};
@@ -0,0 +1,280 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { useApi } from '@backstage/core-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { Progress, Table, TableColumn } from '@backstage/core';
import {
Box,
Card,
CardContent,
Modal,
Tooltip,
Typography,
} from '@material-ui/core';
import React, { Fragment, useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import { codeCoverageApiRef } from '../../api';
import { Alert } from '@material-ui/lab';
import DescriptionIcon from '@material-ui/icons/Description';
import { FileContent } from './FileContent';
export type FileCoverage = {
filename: string;
branchHits: { [branch: number]: number };
lineHits: { [line: number]: number };
};
type FileStructureObject = Record<string, any>;
type CoverageTableRow = {
filename?: string;
files: CoverageTableRow[];
coverage: number;
missing: number;
tracked: number;
path: string;
tableData?: { id: number };
};
const buildFileStructure = (row: CoverageTableRow) => {
const dataGroupedByPath: FileStructureObject = row.files.reduce(
(acc: FileStructureObject, cur: CoverageTableRow) => {
let path = cur.filename;
if (row.path) {
path = path?.split(`${row.path}/`)[1];
}
const pathArray = path?.split('/');
if (!pathArray) {
return acc;
}
if (!acc.hasOwnProperty(pathArray[0])) {
acc[pathArray[0]] = [];
}
acc[pathArray[0]].push(cur);
return acc;
},
{},
);
row.files = Object.keys(dataGroupedByPath).map(pathGroup => {
return buildFileStructure({
path: pathGroup,
files: dataGroupedByPath.hasOwnProperty('files')
? dataGroupedByPath.files
: dataGroupedByPath[pathGroup],
coverage:
dataGroupedByPath[pathGroup].reduce(
(acc: number, cur: CoverageTableRow) => acc + cur.coverage,
0,
) / dataGroupedByPath[pathGroup].length,
missing: dataGroupedByPath[pathGroup].reduce(
(acc: number, cur: CoverageTableRow) => acc + cur.missing,
0,
),
tracked: dataGroupedByPath[pathGroup].reduce(
(acc: number, cur: CoverageTableRow) => acc + cur.tracked,
0,
),
});
});
return row;
};
const formatInitialData = (value: any) => {
return buildFileStructure({
path: '',
coverage: value.aggregate.line.percentage,
missing: value.aggregate.line.missed,
tracked: value.aggregate.line.available,
files: value.files.map((fc: FileCoverage) => {
return {
path: '',
filename: fc.filename,
coverage: Math.floor(
(Object.values(fc.lineHits).filter((hits: number) => hits > 0)
.length /
Object.values(fc.lineHits).length) *
100,
),
missing: Object.values(fc.lineHits).filter(hits => !hits).length,
tracked: Object.values(fc.lineHits).length,
};
}),
});
};
export const FileExplorer = () => {
const { entity } = useEntity();
const [curData, setCurData] = useState<CoverageTableRow | undefined>();
const [tableData, setTableData] = useState<CoverageTableRow[] | undefined>();
const [curPath, setCurPath] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [curFile, setCurFile] = useState('');
const codeCoverageApi = useApi(codeCoverageApiRef);
const { loading, error, value } = useAsync(
async () =>
await codeCoverageApi.getCoverageForEntity({
kind: entity.kind,
namespace: entity.metadata.namespace || 'default',
name: entity.metadata.name,
}),
);
useEffect(() => {
if (!value) return;
const data = formatInitialData(value);
setCurData(data);
if (data.files) setTableData(data.files);
}, [value]);
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
}
const moveDownIntoPath = (path: string) => {
const nextPathData = tableData!.find(
(d: CoverageTableRow) => d.path === path,
);
if (nextPathData && nextPathData.files) {
setTableData(nextPathData.files);
}
};
const moveUpIntoPath = (path: string) => {
const pathArray = path.split('/').filter(p => p.length);
let data = curData?.files;
pathArray.forEach(p => {
data = data?.find(d => d.path === p)?.files;
});
setCurPath(path);
setTableData(data);
};
const columns: TableColumn<CoverageTableRow>[] = [
{
title: 'Path',
type: 'string',
field: 'path',
render: (row: CoverageTableRow) => {
if (row.files?.length) {
return (
<div
role="button"
tabIndex={row.tableData!.id}
style={{ color: 'lightblue', cursor: 'pointer' }}
onKeyDown={() => {
setCurPath(`${curPath}/${row.path}`);
moveDownIntoPath(row.path);
}}
onClick={() => {
setCurPath(`${curPath}/${row.path}`);
moveDownIntoPath(row.path);
}}
>
{row.path}
</div>
);
}
return (
<Box display="flex" alignItems="center">
{row.path}
<Tooltip title="View file content">
<DescriptionIcon
fontSize="small"
style={{ color: 'lightblue', cursor: 'pointer' }}
onClick={() => {
setCurFile(`${curPath.slice(1)}/${row.path}`);
setModalOpen(true);
}}
/>
</Tooltip>
</Box>
);
},
},
{
title: 'Coverage',
type: 'numeric',
field: 'coverage',
render: (row: CoverageTableRow) => `${row.coverage}%`,
},
{
title: 'Missing lines',
type: 'numeric',
field: 'missing',
},
{
title: 'Tracked lines',
type: 'numeric',
field: 'tracked',
},
];
const pathArray = curPath.split('/');
const lastPathElementIndex = pathArray.length - 1;
const fileCoverage = value.files.find((f: FileCoverage) =>
f.filename.endsWith(curFile),
);
return (
<Box mt={8}>
<Card>
<CardContent>
<Box mb={2} display="flex" justifyContent="space-between">
<Typography variant="h5">Explore Files</Typography>
</Box>
<Box mb={2} display="flex">
{pathArray.map((pathElement, idx) => (
<Fragment key={pathElement || 'root'}>
<div
role="button"
tabIndex={idx}
style={{
color: `${idx !== lastPathElementIndex && 'lightblue'}`,
cursor: `${idx !== lastPathElementIndex && 'pointer'}`,
}}
onKeyDown={() => moveUpIntoPath(pathElement)}
onClick={() => moveUpIntoPath(pathElement)}
>
{pathElement || 'root'}
</div>
<div>{'\u00A0/\u00A0'}</div>
</Fragment>
))}
</Box>
<Table
emptyContent={<>No files found</>}
data={tableData || []}
columns={columns}
/>
<Modal
open={modalOpen}
onClick={event => event.stopPropagation()}
onClose={() => setModalOpen(false)}
style={{ overflow: 'scroll' }}
>
<FileContent filename={curFile} coverage={fileCoverage} />
</Modal>
</CardContent>
</Card>
</Box>
);
};
@@ -0,0 +1,54 @@
/*
* Copyright 2021 Spotify AB
*
* 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 'highlight.js/styles/atom-one-dark.css';
import highlight from 'highlight.js';
/*
* Given a file extension, repo name, and array of code lines, return a Promise resolving
* to an array of formatted lines with html/css formatting.
*
* @param {String} fileExtension The extension of the source file
* @param {String} repo The name of the code repository
* @param {Array<String>} lines The source code lines
*
* @returns {Promise<Array<String>>} Promise of formatted lines
*
* @see http://highlightjs.readthedocs.io/en/latest/api.html#highlight-name-value-ignore-illegals-continuation
*/
export const highlightLines = (fileExtension: string, lines: Array<string>) => {
const formattedLines: Array<string> = [];
let state: CompiledMode | Language | undefined;
let fileformat = fileExtension;
if (fileExtension === 'm') {
fileformat = 'objectivec';
}
if (fileExtension === 'tsx') {
fileformat = 'typescript';
}
if (fileExtension === 'jsx') {
fileformat = 'javascript';
}
if (fileExtension === 'kt') {
fileformat = 'kotlin';
}
lines.forEach(line => {
const result = highlight.highlight(fileformat, line, true, state);
state = result.top;
formattedLines.push(result.value);
});
return formattedLines;
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { FileExplorer } from './FileExplorer';
@@ -0,0 +1,34 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React from 'react';
import { Entity } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
import { MissingAnnotationEmptyState } from '@backstage/core';
import { CodeCoveragePage } from './CodeCoveragePage';
export const isCodeCoverageAvailable = (entity: Entity) =>
Boolean(entity.metadata.annotations?.['backstage.io/code-coverage']);
export const Router = () => {
const { entity } = useEntity();
if (!isCodeCoverageAvailable(entity)) {
return (
<MissingAnnotationEmptyState annotation="backstage.io/code-coverage" />
);
}
return <CodeCoveragePage />;
};
+28
View File
@@ -0,0 +1,28 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { createDevApp } from '@backstage/dev-utils';
import { codeCoveragePlugin } from '../plugin';
import { codeCoverageApiRef, CodeCoverageRestApi } from '../api';
createDevApp()
.registerPlugin(codeCoveragePlugin)
.registerApi({
api: codeCoverageApiRef,
deps: {},
factory: () => new CodeCoverageRestApi('http://localhost:3000'),
})
.render();
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright 2021 Spotify AB
*
* 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.
*/
export { codeCoveragePlugin, EntityCodeCoverageContent } from './plugin';
export {
Router,
isCodeCoverageAvailable,
isCodeCoverageAvailable as isPluginApplicableToEntity,
} from './components/Router';
// export { createBackendRouter } from './backend/router';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { codeCoveragePlugin } from './plugin';
describe('code-coverage', () => {
it('should export plugin', () => {
expect(codeCoveragePlugin).toBeDefined();
});
});
+45
View File
@@ -0,0 +1,45 @@
/*
* Copyright 2021 Spotify AB
*
* 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 {
configApiRef,
createApiFactory,
createPlugin,
createRoutableExtension,
} from '@backstage/core';
import { codeCoverageApiRef, CodeCoverageRestApi } from './api';
import { rootRouteRef } from './routes';
export const codeCoveragePlugin = createPlugin({
id: 'code-coverage',
routes: {
root: rootRouteRef,
},
apis: [
createApiFactory({
api: codeCoverageApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) => CodeCoverageRestApi.fromConfig(configApi),
}),
],
});
export const EntityCodeCoverageContent = codeCoveragePlugin.provide(
createRoutableExtension({
component: () => import('./components/Router').then(m => m.Router),
mountPoint: rootRouteRef,
}),
);
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { createRouteRef } from '@backstage/core';
export const rootRouteRef = createRouteRef({
title: 'code-coverage',
});
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* 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 '@testing-library/jest-dom';
import 'cross-fetch/polyfill';