Skip to content
This repository was archived by the owner on Apr 11, 2019. It is now read-only.

[WIP] Add a directory coverage viewer. #222

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 73 additions & 30 deletions src/containers/fileViewer.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import React, { Component } from 'react';

import { fileRevisionCoverageSummary, fileRevisionWithActiveData } from '../utils/coverage';
import FileOutlineIcon from 'mdi-react/FileOutlineIcon';
import FolderOutlineIcon from 'mdi-react/FolderOutlineIcon';
import settings from '../settings';
import { sourceCoverageSummary, sourceCoverageFromActiveData, pathCoverageFromBackend } from '../utils/coverage';
import { rawFile } from '../utils/hg';
import { TestsSideViewer, CoveragePercentageViewer } from '../components/fileViewer';
import { HORIZONTAL_ELLIPSIS, HEAVY_CHECKMARK } from '../utils/symbol';
import hash from '../utils/hash';

const { low, medium, high } = settings.COVERAGE_THRESHOLDS;

// FileViewer loads a raw file for a given revision from Mozilla's hg web.
// It uses test coverage information from Active Data to show coverage
// for runnable lines.
Expand All @@ -28,7 +33,8 @@ export default class FileViewerContainer extends Component {
// Reset the state and fetch new data
const newState = {
appErr: undefined,
coverage: undefined,
pathCoverage: undefined,
sourceCoverage: undefined,
parsedFile: undefined,
};
// eslint-disable-next-line react/no-did-update-set-state
Expand All @@ -45,52 +51,89 @@ export default class FileViewerContainer extends Component {
}
}

fetchData(repoPath = 'mozilla-central') {
async fetchData(repoPath = 'mozilla-central') {
const { revision, path } = this.props;
if (!revision || !path) {
this.setState({ appErr: "Undefined URL query ('revision', 'path' fields are required)" });
if (!revision) {
this.setState({ appErr: "Undefined URL query (field 'revision' is required)" });
return;
}
// Get source code from hg
const fileSource = async () => {
this.setState({ parsedFile: (await rawFile(revision, path, repoPath)) });
// Get overall path coverage from backend
const pathCoverage = async () => {
const data = await pathCoverageFromBackend(revision, path, repoPath);
this.setState({ pathCoverage: data });
};
// Get coverage from ActiveData
const coverageData = async () => {
const { data } = await fileRevisionWithActiveData(revision, path, repoPath);
this.setState({ coverage: fileRevisionCoverageSummary(data) });
// Get detailed source coverage from ActiveData
const fileCoverage = async () => {
const { data } = await sourceCoverageFromActiveData(revision, path, repoPath);
this.setState({ sourceCoverage: sourceCoverageSummary(data) });
};
// Get raw source code from hg
const fileSource = async () => {
const parsedFile = await rawFile(revision, path, repoPath);
this.setState({ parsedFile });
};
// Fetch source code and coverage in parallel
try {
Promise.all([fileSource(), coverageData()])
.catch((e) => {
if ((e instanceof RangeError) && (e.message === 'Revision number too short')) {
this.setState({ appErr: 'Revision number is too short. Unable to fetch tests.' });
} else {
this.setState({ appErr: `${e.name}: ${e.message}` });
}
throw e;
});
await Promise.all([pathCoverage(), fileCoverage(), fileSource()]);
} catch (error) {
this.setState({ appErr: `${error.name}: ${error.message}` });
console.error(error);
if ((error instanceof RangeError) && (error.message === 'Revision number too short')) {
this.setState({ appErr: 'Revision number is too short. Unable to fetch data.' });
} else {
this.setState({ appErr: `${error.name}: ${error.message}` });
}
throw error;
}
}

render() {
const { revision, path } = this.props;
const {
parsedFile, coverage, selectedLine, appErr,
pathCoverage, sourceCoverage, parsedFile, selectedLine, appErr,
} = this.state;

return (
<div>
<div className="file-view">
<FileViewerMeta {...this.props} {...this.state} />
{ !appErr && (parsedFile) &&
{ pathCoverage && pathCoverage.type === 'directory' &&
<table className="changeset-viewer">
<tbody>
<tr>
<th>File</th>
<th>Coverage summary</th>
</tr>
{pathCoverage.children.map((file) => {
const fileName = file.path.replace(new RegExp(`^${path}`, 'g'), '');
const coveragePercent = Math.round(100 * file.coverage);
let summaryClassName = high.className;
if (coveragePercent < medium.threshold) {
summaryClassName =
(coveragePercent < low.threshold ? low.className : medium.className);
}
const href =
`/#/file?revision=${revision}&path=${path}${fileName}`;
return (
<tr className="changeset" key={fileName}>
<td className="changeset-author">
<a href={href}>
{file.type === 'directory' ? <FolderOutlineIcon /> : <FileOutlineIcon />}
<span className="changeset-eIcon-align">{fileName}</span>
</a>
</td>
<td className={`changeset-summary ${summaryClassName}`}>{coveragePercent}%</td>
</tr>
);
})}
</tbody>
</table> }
{ pathCoverage && pathCoverage.type === 'file' &&
<div style={{ textAlign: 'center' }}>Coverage: {Math.round(pathCoverage.coverage * 100)}%</div> }
{ parsedFile &&
<FileViewer {...this.state} onLineClick={this.setSelectedLine} /> }
</div>
<TestsSideViewer
coverage={coverage}
coverage={sourceCoverage}
lineNumber={selectedLine}
/>
</div>
Expand All @@ -100,7 +143,7 @@ export default class FileViewerContainer extends Component {

// This component renders each line of the file with its line number
const FileViewer = ({
parsedFile, coverage, selectedLine, onLineClick,
parsedFile, sourceCoverage, selectedLine, onLineClick,
}) => (
<table className="file-view-table">
<tbody>
Expand All @@ -111,7 +154,7 @@ const FileViewer = ({
key={uniqueId}
lineNumber={lineNumber + 1}
text={text}
coverage={coverage}
coverage={sourceCoverage}
selectedLine={selectedLine}
onLineClick={onLineClick}
/>
Expand Down Expand Up @@ -156,7 +199,7 @@ const Line = ({

// This component contains metadata of the file
const FileViewerMeta = ({
revision, path, appErr, parsedFile, coverage,
revision, path, appErr, parsedFile, sourceCoverage,
}) => {
const showStatus = (label, data) => (
<li className="file-meta-li">
Expand All @@ -168,11 +211,11 @@ const FileViewerMeta = ({
<div>
<div className="file-meta-center">
<div className="file-meta-title">File Coverage</div>
{ (coverage) && <CoveragePercentageViewer coverage={coverage} /> }
{ (sourceCoverage) && <CoveragePercentageViewer coverage={sourceCoverage} /> }
<div className="file-meta-status">
<ul className="file-meta-ul">
{ showStatus('Source code', parsedFile) }
{ showStatus('Coverage', coverage) }
{ showStatus('Coverage', sourceCoverage) }
</ul>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions src/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const settings = {
ENABLED: process.env.ENABLE_CACHE === 'true' || process.env.NODE_ENV === 'production',
},
CCOV_BACKEND: 'https://coverage.moz.tools',
CCOV_STAGING_BACKEND: 'https://coverage.staging.moz.tools',
CODECOV_GECKO_DEV: 'https://codecov.io/gh/mozilla/gecko-dev',
COVERAGE_THRESHOLDS: {
low: {
Expand Down
25 changes: 22 additions & 3 deletions src/utils/coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { jsonFetch, jsonPost, plainFetch } from './fetch';
import { queryCacheWithFallback, saveInCache } from './localCache';

const {
ACTIVE_DATA, CCOV_BACKEND, CODECOV_GECKO_DEV, GH_GECKO_DEV,
ACTIVE_DATA, CCOV_BACKEND, CCOV_STAGING_BACKEND, CODECOV_GECKO_DEV, GH_GECKO_DEV,
} = settings;

const { INTERNAL_ERROR, PENDING } = settings.STRINGS;
Expand All @@ -16,6 +16,9 @@ export const ccovBackendUrl = node => (`${CCOV_BACKEND}/coverage/changeset/${nod
const queryChangesetCoverage = node =>
plainFetch(`${CCOV_BACKEND}/coverage/changeset/${node}`);

export const queryPathCoverage = (path, revision) =>
jsonFetch(`${CCOV_STAGING_BACKEND}/v2/path?path=${path}${revision ? `&changeset=${revision}` : ''}`);

const queryActiveData = body =>
jsonPost(`${ACTIVE_DATA}/query`, body);

Expand Down Expand Up @@ -44,7 +47,7 @@ const coverageStatistics = (coverage) => {
};

// get the coverage summary for a particular revision and file
export const fileRevisionCoverageSummary = (coverage) => {
export const sourceCoverageSummary = (coverage) => {
const s = {
coveredLines: [],
uncoveredLines: [],
Expand Down Expand Up @@ -227,7 +230,7 @@ export const getPendingCoverage = async (changesetsCoverage) => {
};
};

export const fileRevisionWithActiveData = async (revision, path, repoPath) => {
export const sourceCoverageFromActiveData = async (revision, path, repoPath) => {
try {
if (revision.length < settings.MIN_REVISION_LENGTH) {
throw new RangeError('Revision number too short');
Expand All @@ -252,3 +255,19 @@ export const fileRevisionWithActiveData = async (revision, path, repoPath) => {
throw new Error(`Failed to fetch data for revision: ${revision}, path: ${path}\n${e}`);
}
};

export const pathCoverageFromBackend = async (revision, path, repoPath) => {
try {
if (revision.length < settings.MIN_REVISION_LENGTH) {
throw new RangeError('Revision number too short');
}
const data = await queryPathCoverage(path /* , revision */);
if (data.status && data.staus !== 200) {
throw new Error(`HTTP response ${data.status}`);
}
return data;
} catch (error) {
// FIXME: If you start using this method, please replace the `console.error()` with a `throw`.
console.error(new Error(`Failed to fetch data for revision: ${revision}, path: ${path}\n${error}`));
}
};