Commit 51a66e6b authored by Fabio Huser's avatar Fabio Huser Committed by Kamil Trzciński

Add inline file coverage view for merge request diffs

parent 9870a7e3
...@@ -50,6 +50,11 @@ export default { ...@@ -50,6 +50,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
endpointCoverage: {
type: String,
required: false,
default: '',
},
projectPath: { projectPath: {
type: String, type: String,
required: true, required: true,
...@@ -169,6 +174,7 @@ export default { ...@@ -169,6 +174,7 @@ export default {
endpoint: this.endpoint, endpoint: this.endpoint,
endpointMetadata: this.endpointMetadata, endpointMetadata: this.endpointMetadata,
endpointBatch: this.endpointBatch, endpointBatch: this.endpointBatch,
endpointCoverage: this.endpointCoverage,
projectPath: this.projectPath, projectPath: this.projectPath,
dismissEndpoint: this.dismissEndpoint, dismissEndpoint: this.dismissEndpoint,
showSuggestPopover: this.showSuggestPopover, showSuggestPopover: this.showSuggestPopover,
...@@ -218,6 +224,7 @@ export default { ...@@ -218,6 +224,7 @@ export default {
'fetchDiffFiles', 'fetchDiffFiles',
'fetchDiffFilesMeta', 'fetchDiffFilesMeta',
'fetchDiffFilesBatch', 'fetchDiffFilesBatch',
'fetchCoverageFiles',
'startRenderDiffsQueue', 'startRenderDiffsQueue',
'assignDiscussionsToDiff', 'assignDiscussionsToDiff',
'setHighlightedRow', 'setHighlightedRow',
...@@ -292,6 +299,10 @@ export default { ...@@ -292,6 +299,10 @@ export default {
}); });
} }
if (this.endpointCoverage) {
this.fetchCoverageFiles();
}
if (!this.isNotesFetched) { if (!this.isNotesFetched) {
eventHub.$emit('fetchNotesData'); eventHub.$emit('fetchNotesData');
} }
......
...@@ -54,7 +54,7 @@ export default { ...@@ -54,7 +54,7 @@ export default {
colspan: { colspan: {
type: Number, type: Number,
required: false, required: false,
default: 3, default: 4,
}, },
}, },
computed: { computed: {
......
...@@ -51,7 +51,7 @@ export default { ...@@ -51,7 +51,7 @@ export default {
<template> <template>
<tr v-if="shouldRender" :class="className" class="notes_holder"> <tr v-if="shouldRender" :class="className" class="notes_holder">
<td class="notes-content" colspan="3"> <td class="notes-content" colspan="4">
<div class="content"> <div class="content">
<diff-discussions <diff-discussions
v-if="line.discussions.length" v-if="line.discussions.length"
......
<script> <script>
import { mapActions, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
import DiffTableCell from './diff_table_cell.vue'; import DiffTableCell from './diff_table_cell.vue';
import { import {
MATCH_LINE_TYPE, MATCH_LINE_TYPE,
...@@ -15,11 +16,18 @@ export default { ...@@ -15,11 +16,18 @@ export default {
components: { components: {
DiffTableCell, DiffTableCell,
}, },
directives: {
GlTooltip: GlTooltipDirective,
},
props: { props: {
fileHash: { fileHash: {
type: String, type: String,
required: true, required: true,
}, },
filePath: {
type: String,
required: true,
},
contextLinesPath: { contextLinesPath: {
type: String, type: String,
required: true, required: true,
...@@ -40,6 +48,7 @@ export default { ...@@ -40,6 +48,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapGetters('diffs', ['fileLineCoverage']),
...mapState({ ...mapState({
isHighlighted(state) { isHighlighted(state) {
return this.line.line_code !== null && this.line.line_code === state.diffs.highlightedRow; return this.line.line_code !== null && this.line.line_code === state.diffs.highlightedRow;
...@@ -62,6 +71,9 @@ export default { ...@@ -62,6 +71,9 @@ export default {
isMatchLine() { isMatchLine() {
return this.line.type === MATCH_LINE_TYPE; return this.line.type === MATCH_LINE_TYPE;
}, },
coverageState() {
return this.fileLineCoverage(this.filePath, this.line.new_line);
},
}, },
created() { created() {
this.newLineType = NEW_LINE_TYPE; this.newLineType = NEW_LINE_TYPE;
...@@ -113,6 +125,12 @@ export default { ...@@ -113,6 +125,12 @@ export default {
:is-highlighted="isHighlighted" :is-highlighted="isHighlighted"
class="diff-line-num new_line qa-new-diff-line" class="diff-line-num new_line qa-new-diff-line"
/> />
<td
v-gl-tooltip.hover
:title="coverageState.text"
:class="[line.type, coverageState.class, { hll: isHighlighted }]"
class="line-coverage"
></td>
<td <td
:class="[ :class="[
line.type, line.type,
...@@ -120,7 +138,7 @@ export default { ...@@ -120,7 +138,7 @@ export default {
hll: isHighlighted, hll: isHighlighted,
}, },
]" ]"
class="line_content" class="line_content with-coverage"
v-html="line.rich_text" v-html="line.rich_text"
></td> ></td>
</tr> </tr>
......
...@@ -48,6 +48,7 @@ export default { ...@@ -48,6 +48,7 @@ export default {
<colgroup> <colgroup>
<col style="width: 50px;" /> <col style="width: 50px;" />
<col style="width: 50px;" /> <col style="width: 50px;" />
<col style="width: 8px;" />
<col /> <col />
</colgroup> </colgroup>
<tbody> <tbody>
...@@ -63,6 +64,7 @@ export default { ...@@ -63,6 +64,7 @@ export default {
<inline-diff-table-row <inline-diff-table-row
:key="`${line.line_code || index}`" :key="`${line.line_code || index}`"
:file-hash="diffFile.file_hash" :file-hash="diffFile.file_hash"
:file-path="diffFile.file_path"
:context-lines-path="diffFile.context_lines_path" :context-lines-path="diffFile.context_lines_path"
:line="line" :line="line"
:is-bottom="index + 1 === diffLinesLength" :is-bottom="index + 1 === diffLinesLength"
......
...@@ -122,7 +122,7 @@ export default { ...@@ -122,7 +122,7 @@ export default {
<template> <template>
<tr v-if="shouldRender" :class="className" class="notes_holder"> <tr v-if="shouldRender" :class="className" class="notes_holder">
<td class="notes-content parallel old" colspan="2"> <td class="notes-content parallel old" colspan="3">
<div v-if="shouldRenderDiscussionsOnLeft" class="content"> <div v-if="shouldRenderDiscussionsOnLeft" class="content">
<diff-discussions <diff-discussions
:discussions="line.left.discussions" :discussions="line.left.discussions"
...@@ -147,7 +147,7 @@ export default { ...@@ -147,7 +147,7 @@ export default {
</template> </template>
</diff-discussion-reply> </diff-discussion-reply>
</td> </td>
<td class="notes-content parallel new" colspan="2"> <td class="notes-content parallel new" colspan="3">
<div v-if="shouldRenderDiscussionsOnRight" class="content"> <div v-if="shouldRenderDiscussionsOnRight" class="content">
<diff-discussions <diff-discussions
:discussions="line.right.discussions" :discussions="line.right.discussions"
......
...@@ -49,7 +49,7 @@ export default { ...@@ -49,7 +49,7 @@ export default {
:line="line.left" :line="line.left"
:is-top="isTop" :is-top="isTop"
:is-bottom="isBottom" :is-bottom="isBottom"
:colspan="4" :colspan="6"
/> />
</template> </template>
</tr> </tr>
......
<script> <script>
import { mapActions, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import $ from 'jquery'; import $ from 'jquery';
import { GlTooltipDirective } from '@gitlab/ui';
import DiffTableCell from './diff_table_cell.vue'; import DiffTableCell from './diff_table_cell.vue';
import { import {
MATCH_LINE_TYPE, MATCH_LINE_TYPE,
...@@ -18,11 +19,18 @@ export default { ...@@ -18,11 +19,18 @@ export default {
components: { components: {
DiffTableCell, DiffTableCell,
}, },
directives: {
GlTooltip: GlTooltipDirective,
},
props: { props: {
fileHash: { fileHash: {
type: String, type: String,
required: true, required: true,
}, },
filePath: {
type: String,
required: true,
},
contextLinesPath: { contextLinesPath: {
type: String, type: String,
required: true, required: true,
...@@ -44,6 +52,7 @@ export default { ...@@ -44,6 +52,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapGetters('diffs', ['fileLineCoverage']),
...mapState({ ...mapState({
isHighlighted(state) { isHighlighted(state) {
const lineCode = const lineCode =
...@@ -82,6 +91,9 @@ export default { ...@@ -82,6 +91,9 @@ export default {
isMatchLineRight() { isMatchLineRight() {
return this.line.right && this.line.right.type === MATCH_LINE_TYPE; return this.line.right && this.line.right.type === MATCH_LINE_TYPE;
}, },
coverageState() {
return this.fileLineCoverage(this.filePath, this.line.right.new_line);
},
}, },
created() { created() {
this.newLineType = NEW_LINE_TYPE; this.newLineType = NEW_LINE_TYPE;
...@@ -99,7 +111,7 @@ export default { ...@@ -99,7 +111,7 @@ export default {
const allCellsInHoveringRow = Array.from(e.currentTarget.children); const allCellsInHoveringRow = Array.from(e.currentTarget.children);
const hoverIndex = allCellsInHoveringRow.indexOf(hoveringCell); const hoverIndex = allCellsInHoveringRow.indexOf(hoveringCell);
if (hoverIndex >= 2) { if (hoverIndex >= 3) {
this.isRightHover = isHover; this.isRightHover = isHover;
} else { } else {
this.isLeftHover = isHover; this.isLeftHover = isHover;
...@@ -143,17 +155,19 @@ export default { ...@@ -143,17 +155,19 @@ export default {
line-position="left" line-position="left"
class="diff-line-num old_line" class="diff-line-num old_line"
/> />
<td :class="parallelViewLeftLineType" class="line-coverage left-side"></td>
<td <td
:id="line.left.line_code" :id="line.left.line_code"
:class="parallelViewLeftLineType" :class="parallelViewLeftLineType"
class="line_content parallel left-side" class="line_content with-coverage parallel left-side"
@mousedown="handleParallelLineMouseDown" @mousedown="handleParallelLineMouseDown"
v-html="line.left.rich_text" v-html="line.left.rich_text"
></td> ></td>
</template> </template>
<template v-else> <template v-else>
<td class="diff-line-num old_line empty-cell"></td> <td class="diff-line-num old_line empty-cell"></td>
<td class="line_content parallel left-side empty-cell"></td> <td class="line-coverage left-side empty-cell"></td>
<td class="line_content with-coverage parallel left-side empty-cell"></td>
</template> </template>
<template v-if="line.right && !isMatchLineRight"> <template v-if="line.right && !isMatchLineRight">
<diff-table-cell <diff-table-cell
...@@ -169,6 +183,12 @@ export default { ...@@ -169,6 +183,12 @@ export default {
line-position="right" line-position="right"
class="diff-line-num new_line" class="diff-line-num new_line"
/> />
<td
v-gl-tooltip.hover
:title="coverageState.text"
:class="[line.right.type, coverageState.class, { hll: isHighlighted }]"
class="line-coverage right-side"
></td>
<td <td
:id="line.right.line_code" :id="line.right.line_code"
:class="[ :class="[
...@@ -177,14 +197,15 @@ export default { ...@@ -177,14 +197,15 @@ export default {
hll: isHighlighted, hll: isHighlighted,
}, },
]" ]"
class="line_content parallel right-side" class="line_content with-coverage parallel right-side"
@mousedown="handleParallelLineMouseDown" @mousedown="handleParallelLineMouseDown"
v-html="line.right.rich_text" v-html="line.right.rich_text"
></td> ></td>
</template> </template>
<template v-else> <template v-else>
<td class="diff-line-num old_line empty-cell"></td> <td class="diff-line-num old_line empty-cell"></td>
<td class="line_content parallel right-side empty-cell"></td> <td class="line-coverage right-side empty-cell"></td>
<td class="line_content with-coverage parallel right-side empty-cell"></td>
</template> </template>
</tr> </tr>
</template> </template>
...@@ -47,8 +47,10 @@ export default { ...@@ -47,8 +47,10 @@ export default {
> >
<colgroup> <colgroup>
<col style="width: 50px;" /> <col style="width: 50px;" />
<col style="width: 8px;" />
<col /> <col />
<col style="width: 50px;" /> <col style="width: 50px;" />
<col style="width: 8px;" />
<col /> <col />
</colgroup> </colgroup>
<tbody> <tbody>
...@@ -64,6 +66,7 @@ export default { ...@@ -64,6 +66,7 @@ export default {
<parallel-diff-table-row <parallel-diff-table-row
:key="line.line_code" :key="line.line_code"
:file-hash="diffFile.file_hash" :file-hash="diffFile.file_hash"
:file-path="diffFile.file_path"
:context-lines-path="diffFile.context_lines_path" :context-lines-path="diffFile.context_lines_path"
:line="line" :line="line"
:is-bottom="index + 1 === diffLinesLength" :is-bottom="index + 1 === diffLinesLength"
......
...@@ -69,6 +69,7 @@ export default function initDiffsApp(store) { ...@@ -69,6 +69,7 @@ export default function initDiffsApp(store) {
endpoint: dataset.endpoint, endpoint: dataset.endpoint,
endpointMetadata: dataset.endpointMetadata || '', endpointMetadata: dataset.endpointMetadata || '',
endpointBatch: dataset.endpointBatch || '', endpointBatch: dataset.endpointBatch || '',
endpointCoverage: dataset.endpointCoverage || '',
projectPath: dataset.projectPath, projectPath: dataset.projectPath,
helpPagePath: dataset.helpPagePath, helpPagePath: dataset.helpPagePath,
currentUser: JSON.parse(dataset.currentUserData) || {}, currentUser: JSON.parse(dataset.currentUserData) || {},
...@@ -104,6 +105,7 @@ export default function initDiffsApp(store) { ...@@ -104,6 +105,7 @@ export default function initDiffsApp(store) {
endpoint: this.endpoint, endpoint: this.endpoint,
endpointMetadata: this.endpointMetadata, endpointMetadata: this.endpointMetadata,
endpointBatch: this.endpointBatch, endpointBatch: this.endpointBatch,
endpointCoverage: this.endpointCoverage,
currentUser: this.currentUser, currentUser: this.currentUser,
projectPath: this.projectPath, projectPath: this.projectPath,
helpPagePath: this.helpPagePath, helpPagePath: this.helpPagePath,
......
import Vue from 'vue'; import Vue from 'vue';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import Poll from '~/lib/utils/poll';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { s__ } from '~/locale'; import { __, s__ } from '~/locale';
import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils'; import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility'; import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
import TreeWorker from '../workers/tree_worker'; import TreeWorker from '../workers/tree_worker';
...@@ -43,6 +45,7 @@ export const setBaseConfig = ({ commit }, options) => { ...@@ -43,6 +45,7 @@ export const setBaseConfig = ({ commit }, options) => {
endpoint, endpoint,
endpointMetadata, endpointMetadata,
endpointBatch, endpointBatch,
endpointCoverage,
projectPath, projectPath,
dismissEndpoint, dismissEndpoint,
showSuggestPopover, showSuggestPopover,
...@@ -52,6 +55,7 @@ export const setBaseConfig = ({ commit }, options) => { ...@@ -52,6 +55,7 @@ export const setBaseConfig = ({ commit }, options) => {
endpoint, endpoint,
endpointMetadata, endpointMetadata,
endpointBatch, endpointBatch,
endpointCoverage,
projectPath, projectPath,
dismissEndpoint, dismissEndpoint,
showSuggestPopover, showSuggestPopover,
...@@ -170,6 +174,26 @@ export const fetchDiffFilesMeta = ({ commit, state }) => { ...@@ -170,6 +174,26 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
.catch(() => worker.terminate()); .catch(() => worker.terminate());
}; };
export const fetchCoverageFiles = ({ commit, state }) => {
const coveragePoll = new Poll({
resource: {
getCoverageReports: endpoint => axios.get(endpoint),
},
data: state.endpointCoverage,
method: 'getCoverageReports',
successCallback: ({ status, data }) => {
if (status === httpStatusCodes.OK) {
commit(types.SET_COVERAGE_DATA, data);
coveragePoll.stop();
}
},
errorCallback: () => createFlash(__('Something went wrong on our end. Please try again!')),
});
coveragePoll.makeRequest();
};
export const setHighlightedRow = ({ commit }, lineCode) => { export const setHighlightedRow = ({ commit }, lineCode) => {
const fileHash = lineCode.split('_')[0]; const fileHash = lineCode.split('_')[0];
commit(types.SET_HIGHLIGHTED_ROW, lineCode); commit(types.SET_HIGHLIGHTED_ROW, lineCode);
......
import { __, n__ } from '~/locale';
import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants'; import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants';
export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW_TYPE; export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW_TYPE;
...@@ -98,6 +99,29 @@ export const allBlobs = (state, getters) => ...@@ -98,6 +99,29 @@ export const allBlobs = (state, getters) =>
export const getCommentFormForDiffFile = state => fileHash => export const getCommentFormForDiffFile = state => fileHash =>
state.commentForms.find(form => form.fileHash === fileHash); state.commentForms.find(form => form.fileHash === fileHash);
/**
* Returns the test coverage hits for a specific line of a given file
* @param {string} file
* @param {number} line
* @returns {number}
*/
export const fileLineCoverage = state => (file, line) => {
if (!state.coverageFiles.files) return {};
const fileCoverage = state.coverageFiles.files[file];
if (!fileCoverage) return {};
const lineCoverage = fileCoverage[String(line)];
if (lineCoverage === 0) {
return { text: __('No test coverage'), class: 'no-coverage' };
} else if (lineCoverage >= 0) {
return {
text: n__('Test coverage: %d hit', 'Test coverage: %d hits', lineCoverage),
class: 'coverage',
};
}
return {};
};
/** /**
* Returns index of a currently selected diff in diffFiles * Returns index of a currently selected diff in diffFiles
* @returns {number} * @returns {number}
......
...@@ -17,6 +17,7 @@ export default () => ({ ...@@ -17,6 +17,7 @@ export default () => ({
commit: null, commit: null,
startVersion: null, startVersion: null,
diffFiles: [], diffFiles: [],
coverageFiles: {},
mergeRequestDiffs: [], mergeRequestDiffs: [],
mergeRequestDiff: null, mergeRequestDiff: null,
diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType, diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType,
......
...@@ -5,6 +5,7 @@ export const SET_RETRIEVING_BATCHES = 'SET_RETRIEVING_BATCHES'; ...@@ -5,6 +5,7 @@ export const SET_RETRIEVING_BATCHES = 'SET_RETRIEVING_BATCHES';
export const SET_DIFF_DATA = 'SET_DIFF_DATA'; export const SET_DIFF_DATA = 'SET_DIFF_DATA';
export const SET_DIFF_DATA_BATCH = 'SET_DIFF_DATA_BATCH'; export const SET_DIFF_DATA_BATCH = 'SET_DIFF_DATA_BATCH';
export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE'; export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE';
export const SET_COVERAGE_DATA = 'SET_COVERAGE_DATA';
export const SET_MERGE_REQUEST_DIFFS = 'SET_MERGE_REQUEST_DIFFS'; export const SET_MERGE_REQUEST_DIFFS = 'SET_MERGE_REQUEST_DIFFS';
export const TOGGLE_LINE_HAS_FORM = 'TOGGLE_LINE_HAS_FORM'; export const TOGGLE_LINE_HAS_FORM = 'TOGGLE_LINE_HAS_FORM';
export const ADD_CONTEXT_LINES = 'ADD_CONTEXT_LINES'; export const ADD_CONTEXT_LINES = 'ADD_CONTEXT_LINES';
......
...@@ -16,6 +16,7 @@ export default { ...@@ -16,6 +16,7 @@ export default {
endpoint, endpoint,
endpointMetadata, endpointMetadata,
endpointBatch, endpointBatch,
endpointCoverage,
projectPath, projectPath,
dismissEndpoint, dismissEndpoint,
showSuggestPopover, showSuggestPopover,
...@@ -25,6 +26,7 @@ export default { ...@@ -25,6 +26,7 @@ export default {
endpoint, endpoint,
endpointMetadata, endpointMetadata,
endpointBatch, endpointBatch,
endpointCoverage,
projectPath, projectPath,
dismissEndpoint, dismissEndpoint,
showSuggestPopover, showSuggestPopover,
...@@ -69,6 +71,10 @@ export default { ...@@ -69,6 +71,10 @@ export default {
}); });
}, },
[types.SET_COVERAGE_DATA](state, coverageFiles) {
Object.assign(state, { coverageFiles });
},
[types.RENDER_FILE](state, file) { [types.RENDER_FILE](state, file) {
Object.assign(file, { Object.assign(file, {
renderIt: true, renderIt: true,
......
...@@ -29,3 +29,15 @@ ...@@ -29,3 +29,15 @@
color: $link; color: $link;
} }
} }
@mixin line-coverage-border-color($coverage, $no-coverage) {
transition: border-left 0.1s ease-out;
&.coverage {
border-left: 3px solid $coverage;
}
&.no-coverage {
border-left: 3px solid $no-coverage;
}
}
...@@ -24,6 +24,8 @@ $dark-pre-hll-bg: #373b41; ...@@ -24,6 +24,8 @@ $dark-pre-hll-bg: #373b41;
$dark-hll-bg: #373b41; $dark-hll-bg: #373b41;
$dark-over-bg: #9f9ab5; $dark-over-bg: #9f9ab5;
$dark-expanded-bg: #3e3e3e; $dark-expanded-bg: #3e3e3e;
$dark-coverage: #b5bd68;
$dark-no-coverage: #de935f;
$dark-c: #969896; $dark-c: #969896;
$dark-err: #c66; $dark-err: #c66;
$dark-k: #b294bb; $dark-k: #b294bb;
...@@ -124,12 +126,18 @@ $dark-il: #de935f; ...@@ -124,12 +126,18 @@ $dark-il: #de935f;
} }
td.diff-line-num.hll:not(.empty-cell), td.diff-line-num.hll:not(.empty-cell),
td.line-coverage.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) { td.line_content.hll:not(.empty-cell) {
background-color: $dark-diff-not-empty-bg; background-color: $dark-diff-not-empty-bg;
border-color: darken($dark-diff-not-empty-bg, 15%); border-color: darken($dark-diff-not-empty-bg, 15%);
} }
.line-coverage {
@include line-coverage-border-color($dark-coverage, $dark-no-coverage);
}
.diff-line-num.new, .diff-line-num.new,
.line-coverage.new,
.line_content.new { .line_content.new {
@include diff-background($dark-new-bg, $dark-new-idiff, $dark-border); @include diff-background($dark-new-bg, $dark-new-idiff, $dark-border);
...@@ -140,6 +148,7 @@ $dark-il: #de935f; ...@@ -140,6 +148,7 @@ $dark-il: #de935f;
} }
.diff-line-num.old, .diff-line-num.old,
.line-coverage.old,
.line_content.old { .line_content.old {
@include diff-background($dark-old-bg, $dark-old-idiff, $dark-border); @include diff-background($dark-old-bg, $dark-old-idiff, $dark-border);
...@@ -168,6 +177,7 @@ $dark-il: #de935f; ...@@ -168,6 +177,7 @@ $dark-il: #de935f;
&:not(.diff-expanded) + .diff-expanded, &:not(.diff-expanded) + .diff-expanded,
&.diff-expanded + .line_holder:not(.diff-expanded) { &.diff-expanded + .line_holder:not(.diff-expanded) {
> .diff-line-num, > .diff-line-num,
> .line-coverage,
> .line_content { > .line_content {
border-top: 1px solid $black; border-top: 1px solid $black;
} }
...@@ -175,6 +185,7 @@ $dark-il: #de935f; ...@@ -175,6 +185,7 @@ $dark-il: #de935f;
&.diff-expanded { &.diff-expanded {
> .diff-line-num, > .diff-line-num,
> .line-coverage,
> .line_content { > .line_content {
background: $dark-expanded-bg; background: $dark-expanded-bg;
border-color: $dark-expanded-bg; border-color: $dark-expanded-bg;
......
...@@ -17,6 +17,8 @@ $monokai-diff-border: #808080; ...@@ -17,6 +17,8 @@ $monokai-diff-border: #808080;
$monokai-highlight-bg: #ffe792; $monokai-highlight-bg: #ffe792;
$monokai-over-bg: #9f9ab5; $monokai-over-bg: #9f9ab5;
$monokai-expanded-bg: #3e3e3e; $monokai-expanded-bg: #3e3e3e;
$monokai-coverage: #a6e22e;
$monokai-no-coverage: #fd971f;
$monokai-new-bg: rgba(166, 226, 46, 0.1); $monokai-new-bg: rgba(166, 226, 46, 0.1);
$monokai-new-idiff: rgba(166, 226, 46, 0.15); $monokai-new-idiff: rgba(166, 226, 46, 0.15);
...@@ -124,12 +126,18 @@ $monokai-gi: #a6e22e; ...@@ -124,12 +126,18 @@ $monokai-gi: #a6e22e;
} }
td.diff-line-num.hll:not(.empty-cell), td.diff-line-num.hll:not(.empty-cell),
td.line-coverage.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) { td.line_content.hll:not(.empty-cell) {
background-color: $monokai-line-empty-bg; background-color: $monokai-line-empty-bg;
border-color: $monokai-line-empty-border; border-color: $monokai-line-empty-border;
} }
.line-coverage {
@include line-coverage-border-color($monokai-coverage, $monokai-no-coverage);
}
.diff-line-num.new, .diff-line-num.new,
.line-coverage.new,
.line_content.new { .line_content.new {
@include diff-background($monokai-new-bg, $monokai-new-idiff, $monokai-diff-border); @include diff-background($monokai-new-bg, $monokai-new-idiff, $monokai-diff-border);
...@@ -140,6 +148,7 @@ $monokai-gi: #a6e22e; ...@@ -140,6 +148,7 @@ $monokai-gi: #a6e22e;
} }
.diff-line-num.old, .diff-line-num.old,
.line-coverage.old,
.line_content.old { .line_content.old {
@include diff-background($monokai-old-bg, $monokai-old-idiff, $monokai-diff-border); @include diff-background($monokai-old-bg, $monokai-old-idiff, $monokai-diff-border);
...@@ -168,6 +177,7 @@ $monokai-gi: #a6e22e; ...@@ -168,6 +177,7 @@ $monokai-gi: #a6e22e;
&:not(.diff-expanded) + .diff-expanded, &:not(.diff-expanded) + .diff-expanded,
&.diff-expanded + .line_holder:not(.diff-expanded) { &.diff-expanded + .line_holder:not(.diff-expanded) {
> .diff-line-num, > .diff-line-num,
> .line-coverage,
> .line_content { > .line_content {
border-top: 1px solid $black; border-top: 1px solid $black;
} }
...@@ -175,6 +185,7 @@ $monokai-gi: #a6e22e; ...@@ -175,6 +185,7 @@ $monokai-gi: #a6e22e;
&.diff-expanded { &.diff-expanded {
> .diff-line-num, > .diff-line-num,
> .line-coverage,
> .line_content { > .line_content {
background: $monokai-expanded-bg; background: $monokai-expanded-bg;
border-color: $monokai-expanded-bg; border-color: $monokai-expanded-bg;
......
...@@ -51,6 +51,15 @@ ...@@ -51,6 +51,15 @@
@include match-line; @include match-line;
} }
.line-coverage {
@include line-coverage-border-color($green-500, $orange-500);
&.old,
&.new {
background-color: $white-normal;
}
}
.diff-line-num { .diff-line-num {
&.old { &.old {
a { a {
...@@ -83,6 +92,7 @@ ...@@ -83,6 +92,7 @@
&:not(.diff-expanded) + .diff-expanded, &:not(.diff-expanded) + .diff-expanded,
&.diff-expanded + .line_holder:not(.diff-expanded) { &.diff-expanded + .line_holder:not(.diff-expanded) {
> .diff-line-num, > .diff-line-num,
> .line-coverage,
> .line_content { > .line_content {
border-top: 1px solid $none-expanded-border; border-top: 1px solid $none-expanded-border;
} }
...@@ -90,6 +100,7 @@ ...@@ -90,6 +100,7 @@
&.diff-expanded { &.diff-expanded {
> .diff-line-num, > .diff-line-num,
> .line-coverage,
> .line_content { > .line_content {
background: $none-expanded-bg; background: $none-expanded-bg;
border-color: $none-expanded-bg; border-color: $none-expanded-bg;
......
...@@ -21,6 +21,8 @@ $solarized-dark-highlight: #094554; ...@@ -21,6 +21,8 @@ $solarized-dark-highlight: #094554;
$solarized-dark-hll-bg: #174652; $solarized-dark-hll-bg: #174652;
$solarized-dark-over-bg: #9f9ab5; $solarized-dark-over-bg: #9f9ab5;
$solarized-dark-expanded-bg: #010d10; $solarized-dark-expanded-bg: #010d10;
$solarized-dark-coverage: #859900;
$solarized-dark-no-coverage: #cb4b16;
$solarized-dark-c: #586e75; $solarized-dark-c: #586e75;
$solarized-dark-err: #93a1a1; $solarized-dark-err: #93a1a1;
$solarized-dark-g: #93a1a1; $solarized-dark-g: #93a1a1;
...@@ -128,12 +130,18 @@ $solarized-dark-il: #2aa198; ...@@ -128,12 +130,18 @@ $solarized-dark-il: #2aa198;
} }
td.diff-line-num.hll:not(.empty-cell), td.diff-line-num.hll:not(.empty-cell),
td.line-coverage.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) { td.line_content.hll:not(.empty-cell) {
background-color: $solarized-dark-hll-bg; background-color: $solarized-dark-hll-bg;
border-color: darken($solarized-dark-hll-bg, 15%); border-color: darken($solarized-dark-hll-bg, 15%);
} }
.line-coverage {
@include line-coverage-border-color($solarized-dark-coverage, $solarized-dark-no-coverage);
}
.diff-line-num.new, .diff-line-num.new,
.line-coverage.new,
.line_content.new { .line_content.new {
@include diff-background($solarized-dark-new-bg, $solarized-dark-new-idiff, $solarized-dark-border); @include diff-background($solarized-dark-new-bg, $solarized-dark-new-idiff, $solarized-dark-border);
...@@ -144,6 +152,7 @@ $solarized-dark-il: #2aa198; ...@@ -144,6 +152,7 @@ $solarized-dark-il: #2aa198;
} }
.diff-line-num.old, .diff-line-num.old,
.line-coverage.old,
.line_content.old { .line_content.old {
@include diff-background($solarized-dark-old-bg, $solarized-dark-old-idiff, $solarized-dark-border); @include diff-background($solarized-dark-old-bg, $solarized-dark-old-idiff, $solarized-dark-border);
...@@ -172,6 +181,7 @@ $solarized-dark-il: #2aa198; ...@@ -172,6 +181,7 @@ $solarized-dark-il: #2aa198;
&:not(.diff-expanded) + .diff-expanded, &:not(.diff-expanded) + .diff-expanded,
&.diff-expanded + .line_holder:not(.diff-expanded) { &.diff-expanded + .line_holder:not(.diff-expanded) {
> .diff-line-num, > .diff-line-num,
> .line-coverage,
> .line_content { > .line_content {
border-top: 1px solid $black; border-top: 1px solid $black;
} }
...@@ -179,6 +189,7 @@ $solarized-dark-il: #2aa198; ...@@ -179,6 +189,7 @@ $solarized-dark-il: #2aa198;
&.diff-expanded { &.diff-expanded {
> .diff-line-num, > .diff-line-num,
> .line-coverage,
> .line_content { > .line_content {
background: $solarized-dark-expanded-bg; background: $solarized-dark-expanded-bg;
border-color: $solarized-dark-expanded-bg; border-color: $solarized-dark-expanded-bg;
......
...@@ -23,6 +23,8 @@ $solarized-light-hll-bg: #ddd8c5; ...@@ -23,6 +23,8 @@ $solarized-light-hll-bg: #ddd8c5;
$solarized-light-over-bg: #ded7fc; $solarized-light-over-bg: #ded7fc;
$solarized-light-expanded-border: #d2cdbd; $solarized-light-expanded-border: #d2cdbd;
$solarized-light-expanded-bg: #ece6d4; $solarized-light-expanded-bg: #ece6d4;
$solarized-light-coverage: #859900;
$solarized-light-no-coverage: #cb4b16;
$solarized-light-c: #93a1a1; $solarized-light-c: #93a1a1;
$solarized-light-err: #586e75; $solarized-light-err: #586e75;
$solarized-light-g: #586e75; $solarized-light-g: #586e75;
...@@ -135,12 +137,18 @@ $solarized-light-il: #2aa198; ...@@ -135,12 +137,18 @@ $solarized-light-il: #2aa198;
} }
td.diff-line-num.hll:not(.empty-cell), td.diff-line-num.hll:not(.empty-cell),
td.line-coverage.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) { td.line_content.hll:not(.empty-cell) {
background-color: $solarized-light-hll-bg; background-color: $solarized-light-hll-bg;
border-color: darken($solarized-light-hll-bg, 15%); border-color: darken($solarized-light-hll-bg, 15%);
} }
.line-coverage {
@include line-coverage-border-color($solarized-light-coverage, $solarized-light-no-coverage);
}
.diff-line-num.new, .diff-line-num.new,
.line-coverage.new,
.line_content.new { .line_content.new {
@include diff-background($solarized-light-new-bg, @include diff-background($solarized-light-new-bg,
$solarized-light-new-idiff, $solarized-light-border); $solarized-light-new-idiff, $solarized-light-border);
...@@ -152,6 +160,7 @@ $solarized-light-il: #2aa198; ...@@ -152,6 +160,7 @@ $solarized-light-il: #2aa198;
} }
.diff-line-num.old, .diff-line-num.old,
.line-coverage.old,
.line_content.old { .line_content.old {
@include diff-background($solarized-light-old-bg, $solarized-light-old-idiff, $solarized-light-border); @include diff-background($solarized-light-old-bg, $solarized-light-old-idiff, $solarized-light-border);
...@@ -180,6 +189,7 @@ $solarized-light-il: #2aa198; ...@@ -180,6 +189,7 @@ $solarized-light-il: #2aa198;
&:not(.diff-expanded) + .diff-expanded, &:not(.diff-expanded) + .diff-expanded,
&.diff-expanded + .line_holder:not(.diff-expanded) { &.diff-expanded + .line_holder:not(.diff-expanded) {
> .diff-line-num, > .diff-line-num,
> .line-coverage,
> .line_content { > .line_content {
border-top: 1px solid $solarized-light-expanded-border; border-top: 1px solid $solarized-light-expanded-border;
} }
...@@ -187,6 +197,7 @@ $solarized-light-il: #2aa198; ...@@ -187,6 +197,7 @@ $solarized-light-il: #2aa198;
&.diff-expanded { &.diff-expanded {
> .diff-line-num, > .diff-line-num,
> .line-coverage,
> .line_content { > .line_content {
background: $solarized-light-expanded-bg; background: $solarized-light-expanded-bg;
border-color: $solarized-light-expanded-bg; border-color: $solarized-light-expanded-bg;
......
...@@ -151,6 +151,7 @@ pre.code, ...@@ -151,6 +151,7 @@ pre.code,
&:not(.diff-expanded) + .diff-expanded, &:not(.diff-expanded) + .diff-expanded,
&.diff-expanded + .line_holder:not(.diff-expanded) { &.diff-expanded + .line_holder:not(.diff-expanded) {
> .diff-line-num, > .diff-line-num,
> .line-coverage,
> .line_content { > .line_content {
border-top: 1px solid $white-expanded-border; border-top: 1px solid $white-expanded-border;
} }
...@@ -158,6 +159,7 @@ pre.code, ...@@ -158,6 +159,7 @@ pre.code,
&.diff-expanded { &.diff-expanded {
> .diff-line-num, > .diff-line-num,
> .line-coverage,
> .line_content { > .line_content {
background: $white-expanded-bg; background: $white-expanded-bg;
border-color: $white-expanded-bg; border-color: $white-expanded-bg;
...@@ -197,6 +199,22 @@ pre.code, ...@@ -197,6 +199,22 @@ pre.code,
background-color: $line-select-yellow; background-color: $line-select-yellow;
} }
} }
.line-coverage {
@include line-coverage-border-color($green-500, $orange-500);
&.old {
background-color: $line-removed;
}
&.new {
background-color: $line-added;
}
&.hll:not(.empty-cell) {
background-color: $line-select-yellow;
}
}
} }
// highlight line via anchor // highlight line via anchor
......
...@@ -514,6 +514,10 @@ table.code { ...@@ -514,6 +514,10 @@ table.code {
position: absolute; position: absolute;
left: 0.5em; left: 0.5em;
} }
&.with-coverage::before {
left: 0;
}
} }
&.new { &.new {
...@@ -522,6 +526,10 @@ table.code { ...@@ -522,6 +526,10 @@ table.code {
position: absolute; position: absolute;
left: 0.5em; left: 0.5em;
} }
&.with-coverage::before {
left: 0;
}
} }
} }
} }
......
...@@ -14,7 +14,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -14,7 +14,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
skip_before_action :merge_request, only: [:index, :bulk_update] skip_before_action :merge_request, only: [:index, :bulk_update]
before_action :whitelist_query_limiting, only: [:assign_related_issues, :update] before_action :whitelist_query_limiting, only: [:assign_related_issues, :update]
before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort] before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort]
before_action :authorize_read_actual_head_pipeline!, only: [:test_reports, :exposed_artifacts] before_action :authorize_read_actual_head_pipeline!, only: [:test_reports, :exposed_artifacts, :coverage_reports]
before_action :set_issuables_index, only: [:index] before_action :set_issuables_index, only: [:index]
before_action :authenticate_user!, only: [:assign_related_issues] before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase] before_action :check_user_can_push_to_source_branch!, only: [:rebase]
...@@ -63,6 +63,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -63,6 +63,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar') @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar')
@current_user_data = UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json @current_user_data = UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json
@show_whitespace_default = current_user.nil? || current_user.show_whitespace_in_diffs @show_whitespace_default = current_user.nil? || current_user.show_whitespace_in_diffs
@coverage_path = coverage_reports_project_merge_request_path(@project, @merge_request, format: :json) if @merge_request.has_coverage_reports?
set_pipeline_variables set_pipeline_variables
...@@ -131,6 +132,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -131,6 +132,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
reports_response(@merge_request.compare_test_reports) reports_response(@merge_request.compare_test_reports)
end end
def coverage_reports
if @merge_request.has_coverage_reports?
reports_response(@merge_request.find_coverage_reports)
else
head :no_content
end
end
def exposed_artifacts def exposed_artifacts
if @merge_request.has_exposed_artifacts? if @merge_request.has_exposed_artifacts?
reports_response(@merge_request.find_exposed_artifacts) reports_response(@merge_request.find_exposed_artifacts)
......
...@@ -916,6 +916,14 @@ module Ci ...@@ -916,6 +916,14 @@ module Ci
end end
end end
def collect_coverage_reports!(coverage_report)
each_report(Ci::JobArtifact::COVERAGE_REPORT_FILE_TYPES) do |file_type, blob|
Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, coverage_report)
end
coverage_report
end
def report_artifacts def report_artifacts
job_artifacts.with_reports job_artifacts.with_reports
end end
......
...@@ -11,6 +11,7 @@ module Ci ...@@ -11,6 +11,7 @@ module Ci
NotSupportedAdapterError = Class.new(StandardError) NotSupportedAdapterError = Class.new(StandardError)
TEST_REPORT_FILE_TYPES = %w[junit].freeze TEST_REPORT_FILE_TYPES = %w[junit].freeze
COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze
NON_ERASABLE_FILE_TYPES = %w[trace].freeze NON_ERASABLE_FILE_TYPES = %w[trace].freeze
DEFAULT_FILE_NAMES = { DEFAULT_FILE_NAMES = {
archive: nil, archive: nil,
...@@ -29,7 +30,8 @@ module Ci ...@@ -29,7 +30,8 @@ module Ci
performance: 'performance.json', performance: 'performance.json',
metrics: 'metrics.txt', metrics: 'metrics.txt',
lsif: 'lsif.json', lsif: 'lsif.json',
dotenv: '.env' dotenv: '.env',
cobertura: 'cobertura-coverage.xml'
}.freeze }.freeze
INTERNAL_TYPES = { INTERNAL_TYPES = {
...@@ -45,6 +47,7 @@ module Ci ...@@ -45,6 +47,7 @@ module Ci
network_referee: :gzip, network_referee: :gzip,
lsif: :gzip, lsif: :gzip,
dotenv: :gzip, dotenv: :gzip,
cobertura: :gzip,
# All these file formats use `raw` as we need to store them uncompressed # All these file formats use `raw` as we need to store them uncompressed
# for Frontend to fetch the files and do analysis # for Frontend to fetch the files and do analysis
...@@ -92,6 +95,10 @@ module Ci ...@@ -92,6 +95,10 @@ module Ci
with_file_types(TEST_REPORT_FILE_TYPES) with_file_types(TEST_REPORT_FILE_TYPES)
end end
scope :coverage_reports, -> do
with_file_types(COVERAGE_REPORT_FILE_TYPES)
end
scope :erasable, -> do scope :erasable, -> do
types = self.file_types.reject { |file_type| NON_ERASABLE_FILE_TYPES.include?(file_type) }.values types = self.file_types.reject { |file_type| NON_ERASABLE_FILE_TYPES.include?(file_type) }.values
...@@ -121,7 +128,8 @@ module Ci ...@@ -121,7 +128,8 @@ module Ci
metrics_referee: 13, ## runner referees metrics_referee: 13, ## runner referees
network_referee: 14, ## runner referees network_referee: 14, ## runner referees
lsif: 15, # LSIF data for code navigation lsif: 15, # LSIF data for code navigation
dotenv: 16 dotenv: 16,
cobertura: 17
} }
enum file_format: { enum file_format: {
......
...@@ -820,6 +820,14 @@ module Ci ...@@ -820,6 +820,14 @@ module Ci
end end
end end
def coverage_reports
Gitlab::Ci::Reports::CoverageReports.new.tap do |coverage_reports|
builds.latest.with_reports(Ci::JobArtifact.coverage_reports).each do |build|
build.collect_coverage_reports!(coverage_reports)
end
end
end
def has_exposed_artifacts? def has_exposed_artifacts?
complete? && builds.latest.with_exposed_artifacts.exists? complete? && builds.latest.with_exposed_artifacts.exists?
end end
......
...@@ -567,6 +567,10 @@ class MergeRequest < ApplicationRecord ...@@ -567,6 +567,10 @@ class MergeRequest < ApplicationRecord
diffs.modified_paths diffs.modified_paths
end end
def new_paths
diffs.diff_files.map(&:new_path)
end
def diff_base_commit def diff_base_commit
if merge_request_diff.persisted? if merge_request_diff.persisted?
merge_request_diff.base_commit merge_request_diff.base_commit
...@@ -1295,6 +1299,24 @@ class MergeRequest < ApplicationRecord ...@@ -1295,6 +1299,24 @@ class MergeRequest < ApplicationRecord
compare_reports(Ci::CompareTestReportsService) compare_reports(Ci::CompareTestReportsService)
end end
def has_coverage_reports?
return false unless Feature.enabled?(:coverage_report_view, project)
actual_head_pipeline&.has_reports?(Ci::JobArtifact.coverage_reports)
end
# TODO: this method and compare_test_reports use the same
# result type, which is handled by the controller's #reports_response.
# we should minimize mistakes by isolating the common parts.
# issue: https://gitlab.com/gitlab-org/gitlab/issues/34224
def find_coverage_reports
unless has_coverage_reports?
return { status: :error, status_reason: 'This merge request does not have coverage reports' }
end
compare_reports(Ci::GenerateCoverageReportsService)
end
def has_exposed_artifacts? def has_exposed_artifacts?
return false unless Feature.enabled?(:ci_expose_arbitrary_artifacts_in_mr, default_enabled: true) return false unless Feature.enabled?(:ci_expose_arbitrary_artifacts_in_mr, default_enabled: true)
...@@ -1318,7 +1340,7 @@ class MergeRequest < ApplicationRecord ...@@ -1318,7 +1340,7 @@ class MergeRequest < ApplicationRecord
# issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224
def compare_reports(service_class, current_user = nil) def compare_reports(service_class, current_user = nil)
with_reactive_cache(service_class.name, current_user&.id) do |data| with_reactive_cache(service_class.name, current_user&.id) do |data|
unless service_class.new(project, current_user) unless service_class.new(project, current_user, id: id)
.latest?(base_pipeline, actual_head_pipeline, data) .latest?(base_pipeline, actual_head_pipeline, data)
raise InvalidateReactiveCache raise InvalidateReactiveCache
end end
...@@ -1335,7 +1357,7 @@ class MergeRequest < ApplicationRecord ...@@ -1335,7 +1357,7 @@ class MergeRequest < ApplicationRecord
raise NameError, service_class unless service_class < Ci::CompareReportsBaseService raise NameError, service_class unless service_class < Ci::CompareReportsBaseService
current_user = User.find_by(id: current_user_id) current_user = User.find_by(id: current_user_id)
service_class.new(project, current_user).execute(base_pipeline, actual_head_pipeline) service_class.new(project, current_user, id: id).execute(base_pipeline, actual_head_pipeline)
end end
def all_commits def all_commits
......
# frozen_string_literal: true
module Ci
# TODO: a couple of points with this approach:
# + reuses existing architecture and reactive caching
# - it's not a report comparison and some comparing features must be turned off.
# see CompareReportsBaseService for more notes.
# issue: https://gitlab.com/gitlab-org/gitlab/issues/34224
class GenerateCoverageReportsService < CompareReportsBaseService
def execute(base_pipeline, head_pipeline)
merge_request = MergeRequest.find_by_id(params[:id])
{
status: :parsed,
key: key(base_pipeline, head_pipeline),
data: head_pipeline.coverage_reports.pick(merge_request.new_paths)
}
rescue => e
Gitlab::ErrorTracking.track_exception(e, project_id: project.id)
{
status: :error,
key: key(base_pipeline, head_pipeline),
status_reason: _('An error occurred while fetching coverage reports.')
}
end
def latest?(base_pipeline, head_pipeline, data)
data&.fetch(:key, nil) == key(base_pipeline, head_pipeline)
end
end
end
...@@ -78,6 +78,7 @@ ...@@ -78,6 +78,7 @@
endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters), endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
endpoint_metadata: diffs_metadata_project_json_merge_request_path(@project, @merge_request, 'json', request.query_parameters), endpoint_metadata: diffs_metadata_project_json_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
endpoint_batch: diffs_batch_project_json_merge_request_path(@project, @merge_request, 'json', request.query_parameters), endpoint_batch: diffs_batch_project_json_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
endpoint_coverage: @coverage_path,
help_page_path: suggest_changes_help_path, help_page_path: suggest_changes_help_path,
current_user_data: @current_user_data, current_user_data: @current_user_data,
project_path: project_path(@merge_request.project), project_path: project_path(@merge_request.project),
......
---
title: Add Cobertura XML coverage visualization to merge request diff view
merge_request: 21791
author: Fabio Huser
type: added
...@@ -14,6 +14,7 @@ resources :merge_requests, concerns: :awardable, except: [:new, :create, :show], ...@@ -14,6 +14,7 @@ resources :merge_requests, concerns: :awardable, except: [:new, :create, :show],
post :rebase post :rebase
get :test_reports get :test_reports
get :exposed_artifacts get :exposed_artifacts
get :coverage_reports
scope constraints: ->(req) { req.format == :json }, as: :json do scope constraints: ->(req) { req.format == :json }, as: :json do
get :commits get :commits
......
...@@ -107,7 +107,7 @@ The following table lists available parameters for jobs: ...@@ -107,7 +107,7 @@ The following table lists available parameters for jobs:
| [`when`](#when) | When to run job. Also available: `when:manual` and `when:delayed`. | | [`when`](#when) | When to run job. Also available: `when:manual` and `when:delayed`. |
| [`environment`](#environment) | Name of an environment to which the job deploys. Also available: `environment:name`, `environment:url`, `environment:on_stop`, `environment:auto_stop_in` and `environment:action`. | | [`environment`](#environment) | Name of an environment to which the job deploys. Also available: `environment:name`, `environment:url`, `environment:on_stop`, `environment:auto_stop_in` and `environment:action`. |
| [`cache`](#cache) | List of files that should be cached between subsequent runs. Also available: `cache:paths`, `cache:key`, `cache:untracked`, and `cache:policy`. | | [`cache`](#cache) | List of files that should be cached between subsequent runs. Also available: `cache:paths`, `cache:key`, `cache:untracked`, and `cache:policy`. |
| [`artifacts`](#artifacts) | List of files and directories to attach to a job on success. Also available: `artifacts:paths`, `artifacts:expose_as`, `artifacts:name`, `artifacts:untracked`, `artifacts:when`, `artifacts:expire_in`, `artifacts:reports`, and `artifacts:reports:junit`.<br><br>In GitLab [Enterprise Edition](https://about.gitlab.com/pricing/), these are available: `artifacts:reports:codequality`, `artifacts:reports:sast`, `artifacts:reports:dependency_scanning`, `artifacts:reports:container_scanning`, `artifacts:reports:dast`, `artifacts:reports:license_management`, `artifacts:reports:performance` and `artifacts:reports:metrics`. | | [`artifacts`](#artifacts) | List of files and directories to attach to a job on success. Also available: `artifacts:paths`, `artifacts:expose_as`, `artifacts:name`, `artifacts:untracked`, `artifacts:when`, `artifacts:expire_in`, `artifacts:reports`, `artifacts:reports:junit`, and `artifacts:reports:cobertura`.<br><br>In GitLab [Enterprise Edition](https://about.gitlab.com/pricing/), these are available: `artifacts:reports:codequality`, `artifacts:reports:sast`, `artifacts:reports:dependency_scanning`, `artifacts:reports:container_scanning`, `artifacts:reports:dast`, `artifacts:reports:license_management`, `artifacts:reports:performance` and `artifacts:reports:metrics`. |
| [`dependencies`](#dependencies) | Restrict which artifacts are passed to a specific job by providing a list of jobs to fetch artifacts from. | | [`dependencies`](#dependencies) | Restrict which artifacts are passed to a specific job by providing a list of jobs to fetch artifacts from. |
| [`coverage`](#coverage) | Code coverage settings for a given job. | | [`coverage`](#coverage) | Code coverage settings for a given job. |
| [`retry`](#retry) | When and how many times a job can be auto-retried in case of a failure. | | [`retry`](#retry) | When and how many times a job can be auto-retried in case of a failure. |
...@@ -2283,6 +2283,18 @@ There are a couple of limitations on top of the [original dotenv rules](https:// ...@@ -2283,6 +2283,18 @@ There are a couple of limitations on top of the [original dotenv rules](https://
- It doesn't support empty lines and comments (`#`) in dotenv file. - It doesn't support empty lines and comments (`#`) in dotenv file.
- It doesn't support quote escape, spaces in a quote, a new line expansion in a quote, in dotenv file. - It doesn't support quote escape, spaces in a quote, a new line expansion in a quote, in dotenv file.
##### `artifacts:reports:cobertura`
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/3708) in GitLab 12.9.
> Requires [GitLab Runner](https://docs.gitlab.com/runner/) 11.5 and above.
The `cobertura` report collects [Cobertura coverage XML files](../../user/project/merge_requests/test_coverage_visualization.md).
The collected Cobertura coverage reports will be uploaded to GitLab as an artifact
and will be automatically shown in merge requests.
Cobertura was originally developed for Java, but there are many
third party ports for other languages like JavaScript, Python, Ruby, etc.
##### `artifacts:reports:codequality` **(STARTER)** ##### `artifacts:reports:codequality` **(STARTER)**
> Introduced in GitLab 11.5. Requires GitLab Runner 11.5 and above. > Introduced in GitLab 11.5. Requires GitLab Runner 11.5 and above.
......
...@@ -102,6 +102,7 @@ or link to useful information directly in the merge request page: ...@@ -102,6 +102,7 @@ or link to useful information directly in the merge request page:
| [Multi-Project pipelines](../../../ci/multi_project_pipelines.md) **(PREMIUM)** | When you set up GitLab CI/CD across multiple projects, you can visualize the entire pipeline, including all cross-project interdependencies. | | [Multi-Project pipelines](../../../ci/multi_project_pipelines.md) **(PREMIUM)** | When you set up GitLab CI/CD across multiple projects, you can visualize the entire pipeline, including all cross-project interdependencies. |
| [Pipelines for merge requests](../../../ci/merge_request_pipelines/index.md) | Customize a specific pipeline structure for merge requests in order to speed the cycle up by running only important jobs. | | [Pipelines for merge requests](../../../ci/merge_request_pipelines/index.md) | Customize a specific pipeline structure for merge requests in order to speed the cycle up by running only important jobs. |
| [Pipeline Graphs](../../../ci/pipelines/index.md#visualizing-pipelines) | View the status of pipelines within the merge request, including the deployment process. | | [Pipeline Graphs](../../../ci/pipelines/index.md#visualizing-pipelines) | View the status of pipelines within the merge request, including the deployment process. |
| [Test Coverage visualization](test_coverage_visualization.md) | See test coverage results for merge requests, within the file diff. |
### Security Reports **(ULTIMATE)** ### Security Reports **(ULTIMATE)**
......
---
type: reference, howto
---
# Test Coverage Visualization
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/3708) in GitLab 12.9.
With the help of [GitLab CI/CD](../../../ci/README.md), you can collect the test
coverage information of your favorite testing or coverage-analysis tool, and visualize
this information inside the file diff view of your merge requests (MRs). This will allow you
to see which lines are covered by tests, and which lines still require coverage, before the
MR is merged.
![Test Coverage Visualization Diff View](img/test_coverage_visualization_v12_9.png)
## How test coverage visualization works
Collecting the coverage information is done via GitLab CI/CD's
[artifacts reports feature](../../../ci/yaml/README.md#artifactsreports).
You can specify one or more coverage reports to collect, including wildcard paths.
GitLab will then take the coverage information in all the files and combine it
together.
For the coverage analysis to work, you have to provide a properly formated
[Cobertura XML](https://cobertura.github.io/cobertura/) report to
[`artifacts:reports:cobertura`](../../../ci/yaml/README.md#artifactsreportscobertura).
This format was originally developed for Java, but most coverage analysis frameworks
for other languages have plugins to add support for it, like:
- [simplecov-cobertura](https://rubygems.org/gems/simplecov-cobertura) (Ruby)
- [gocover-cobertura](https://github.com/t-yuki/gocover-cobertura) (Golang)
Other coverage analysis frameworks support the format out of the box, for example:
- [Istanbul](https://istanbul.js.org/docs/advanced/alternative-reporters/#cobertura) (JavaScript)
- [Coverage.py](https://coverage.readthedocs.io/en/coverage-5.0/cmd.html#xml-reporting) (Python)
Once configured, if you create a merge request that triggers a pipeline which collects
coverage reports, the coverage will be shown in the diff view. This includes reports
from any job in any stage in the pipeline. The coverage will be displayed for each line:
- `covered` (green): lines which have been checked at least once by tests
- `no test coverage` (orange): lines which are loaded but never executed
- no coverage information: lines which are non-instrumented or not loaded
Hovering over the coverage bar will provide further information, such as the number
of times the line was checked by tests.
## Example test coverage configuration
The following [`gitlab-ci.yml`](../../../ci/yaml/README.md) example uses [Mocha](https://mochajs.org/)
JavaScript testing and [NYC](https://github.com/istanbuljs/nyc) coverage-tooling to
generate the coverage artifact:
```yaml
test:
script:
- npm install
- npx nyc --reporter cobertura mocha
artifacts:
reports:
cobertura: coverage/cobertura-coverage.xml
```
## Enabling the feature
This feature comes with the `:coverage_report_view` feature flag disabled by
default. This feature is disabled due to some performance issues with very large
data sets. When [the performance issue](https://gitlab.com/gitlab-org/gitlab/issues/37725)
is resolved, the feature will be enabled by default.
To enable this feature, ask a GitLab administrator with Rails console access to
run the following command:
```ruby
Feature.enable(:coverage_report_view)
```
...@@ -16,7 +16,7 @@ export default { ...@@ -16,7 +16,7 @@ export default {
<template> <template>
<tr class="notes_holder js-temp-notes-holder"> <tr class="notes_holder js-temp-notes-holder">
<td class="notes-content" colspan="3"> <td class="notes-content" colspan="4">
<div class="content"><draft-note :draft="draft" /></div> <div class="content"><draft-note :draft="draft" /></div>
</td> </td>
</tr> </tr>
......
...@@ -34,11 +34,11 @@ export default { ...@@ -34,11 +34,11 @@ export default {
<template> <template>
<tr :class="className" class="notes_holder"> <tr :class="className" class="notes_holder">
<td class="notes_line old"></td> <td class="notes_line old"></td>
<td class="notes-content parallel old"> <td class="notes-content parallel old" colspan="2">
<div v-if="leftDraft.isDraft" class="content"><draft-note :draft="leftDraft" /></div> <div v-if="leftDraft.isDraft" class="content"><draft-note :draft="leftDraft" /></div>
</td> </td>
<td class="notes_line new"></td> <td class="notes_line new"></td>
<td class="notes-content parallel new"> <td class="notes-content parallel new" colspan="2">
<div v-if="rightDraft.isDraft" class="content"><draft-note :draft="rightDraft" /></div> <div v-if="rightDraft.isDraft" class="content"><draft-note :draft="rightDraft" /></div>
</td> </td>
</tr> </tr>
......
...@@ -14,7 +14,7 @@ module Gitlab ...@@ -14,7 +14,7 @@ module Gitlab
ALLOWED_KEYS = ALLOWED_KEYS =
%i[junit codequality sast dependency_scanning container_scanning %i[junit codequality sast dependency_scanning container_scanning
dast performance license_management license_scanning metrics lsif dast performance license_management license_scanning metrics lsif
dotenv].freeze dotenv cobertura].freeze
attributes ALLOWED_KEYS attributes ALLOWED_KEYS
...@@ -35,6 +35,7 @@ module Gitlab ...@@ -35,6 +35,7 @@ module Gitlab
validates :metrics, array_of_strings_or_string: true validates :metrics, array_of_strings_or_string: true
validates :lsif, array_of_strings_or_string: true validates :lsif, array_of_strings_or_string: true
validates :dotenv, array_of_strings_or_string: true validates :dotenv, array_of_strings_or_string: true
validates :cobertura, array_of_strings_or_string: true
end end
end end
......
...@@ -9,7 +9,8 @@ module Gitlab ...@@ -9,7 +9,8 @@ module Gitlab
def self.parsers def self.parsers
{ {
junit: ::Gitlab::Ci::Parsers::Test::Junit junit: ::Gitlab::Ci::Parsers::Test::Junit,
cobertura: ::Gitlab::Ci::Parsers::Coverage::Cobertura
} }
end end
......
# frozen_string_literal: true
module Gitlab
module Ci
module Parsers
module Coverage
class Cobertura
CoberturaParserError = Class.new(Gitlab::Ci::Parsers::ParserError)
def parse!(xml_data, coverage_report)
root = Hash.from_xml(xml_data)
parse_all(root, coverage_report)
rescue Nokogiri::XML::SyntaxError
raise CoberturaParserError, "XML parsing failed"
rescue
raise CoberturaParserError, "Cobertura parsing failed"
end
private
def parse_all(root, coverage_report)
return unless root.present?
root.each do |key, value|
parse_node(key, value, coverage_report)
end
end
def parse_node(key, value, coverage_report)
if key == 'class'
Array.wrap(value).each do |item|
parse_class(item, coverage_report)
end
elsif value.is_a?(Hash)
parse_all(value, coverage_report)
elsif value.is_a?(Array)
value.each do |item|
parse_all(item, coverage_report)
end
end
end
def parse_class(file, coverage_report)
return unless file["filename"].present? && file["lines"].present?
parsed_lines = parse_lines(file["lines"])
coverage_report.add_file(file["filename"], Hash[parsed_lines])
end
def parse_lines(lines)
line_array = Array.wrap(lines["line"])
line_array.map do |line|
# Using `Integer()` here to raise exception on invalid values
[Integer(line["number"]), Integer(line["hits"])]
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
module Reports
class CoverageReports
attr_reader :files
def initialize
@files = {}
end
def pick(keys)
coverage_files = files.select do |key|
keys.include?(key)
end
{ files: coverage_files }
end
def add_file(name, line_coverage)
if files[name].present?
line_coverage.each { |line, hits| combine_lines(name, line, hits) }
else
files[name] = line_coverage
end
end
private
def combine_lines(name, line, hits)
if files[name][line].present?
files[name][line] += hits
else
files[name][line] = hits
end
end
end
end
end
end
...@@ -1810,6 +1810,9 @@ msgstr "" ...@@ -1810,6 +1810,9 @@ msgstr ""
msgid "An error occurred while enabling Service Desk." msgid "An error occurred while enabling Service Desk."
msgstr "" msgstr ""
msgid "An error occurred while fetching coverage reports."
msgstr ""
msgid "An error occurred while fetching environments." msgid "An error occurred while fetching environments."
msgstr "" msgstr ""
...@@ -13325,6 +13328,9 @@ msgstr "" ...@@ -13325,6 +13328,9 @@ msgstr ""
msgid "No template" msgid "No template"
msgstr "" msgstr ""
msgid "No test coverage"
msgstr ""
msgid "No thanks, don't show this again" msgid "No thanks, don't show this again"
msgstr "" msgstr ""
...@@ -19469,6 +19475,11 @@ msgstr "" ...@@ -19469,6 +19475,11 @@ msgstr ""
msgid "Test coverage parsing" msgid "Test coverage parsing"
msgstr "" msgstr ""
msgid "Test coverage: %d hit"
msgid_plural "Test coverage: %d hits"
msgstr[0] ""
msgstr[1] ""
msgid "Test failed." msgid "Test failed."
msgstr "" msgstr ""
......
...@@ -984,6 +984,136 @@ describe Projects::MergeRequestsController do ...@@ -984,6 +984,136 @@ describe Projects::MergeRequestsController do
end end
end end
describe 'GET coverage_reports' do
let(:merge_request) do
create(:merge_request,
:with_merge_request_pipeline,
target_project: project,
source_project: project)
end
let(:pipeline) do
create(:ci_pipeline,
:success,
project: merge_request.source_project,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha)
end
before do
allow_any_instance_of(MergeRequest)
.to receive(:find_coverage_reports)
.and_return(report)
allow_any_instance_of(MergeRequest)
.to receive(:actual_head_pipeline)
.and_return(pipeline)
end
subject do
get :coverage_reports, params: {
namespace_id: project.namespace.to_param,
project_id: project,
id: merge_request.iid
},
format: :json
end
describe 'permissions on a public project with private CI/CD' do
let(:project) { create :project, :repository, :public, :builds_private }
let(:report) { { status: :parsed, data: [] } }
context 'while signed out' do
before do
sign_out(user)
end
it 'responds with a 404' do
subject
expect(response).to have_gitlab_http_status(:not_found)
expect(response.body).to be_blank
end
end
context 'while signed in as an unrelated user' do
before do
sign_in(create(:user))
end
it 'responds with a 404' do
subject
expect(response).to have_gitlab_http_status(:not_found)
expect(response.body).to be_blank
end
end
end
context 'when pipeline has jobs with coverage reports' do
before do
allow_any_instance_of(MergeRequest)
.to receive(:has_coverage_reports?)
.and_return(true)
end
context 'when processing coverage reports is in progress' do
let(:report) { { status: :parsing } }
it 'sends polling interval' do
expect(Gitlab::PollingInterval).to receive(:set_header)
subject
end
it 'returns 204 HTTP status' do
subject
expect(response).to have_gitlab_http_status(:no_content)
end
end
context 'when processing coverage reports is completed' do
let(:report) { { status: :parsed, data: pipeline.coverage_reports } }
it 'returns coverage reports' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq({ 'files' => {} })
end
end
context 'when user created corrupted coverage reports' do
let(:report) { { status: :error, status_reason: 'Failed to parse coverage reports' } }
it 'does not send polling interval' do
expect(Gitlab::PollingInterval).not_to receive(:set_header)
subject
end
it 'returns 400 HTTP status' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response).to eq({ 'status_reason' => 'Failed to parse coverage reports' })
end
end
end
context 'when pipeline does not have jobs with coverage reports' do
let(:report) { double }
it 'returns no content' do
subject
expect(response).to have_gitlab_http_status(:no_content)
expect(response.body).to be_empty
end
end
end
describe 'GET test_reports' do describe 'GET test_reports' do
let(:merge_request) do let(:merge_request) do
create(:merge_request, create(:merge_request,
......
...@@ -311,6 +311,12 @@ FactoryBot.define do ...@@ -311,6 +311,12 @@ FactoryBot.define do
end end
end end
trait :coverage_reports do
after(:build) do |build|
build.job_artifacts << create(:ci_job_artifact, :cobertura, job: build)
end
end
trait :expired do trait :expired do
artifacts_expire_at { 1.minute.ago } artifacts_expire_at { 1.minute.ago }
end end
......
...@@ -129,6 +129,36 @@ FactoryBot.define do ...@@ -129,6 +129,36 @@ FactoryBot.define do
end end
end end
trait :cobertura do
file_type { :cobertura }
file_format { :gzip }
after(:build) do |artifact, evaluator|
artifact.file = fixture_file_upload(
Rails.root.join('spec/fixtures/cobertura/coverage.xml.gz'), 'application/x-gzip')
end
end
trait :coverage_gocov_xml do
file_type { :cobertura }
file_format { :gzip }
after(:build) do |artifact, evaluator|
artifact.file = fixture_file_upload(
Rails.root.join('spec/fixtures/cobertura/coverage_gocov_xml.xml.gz'), 'application/x-gzip')
end
end
trait :coverage_with_corrupted_data do
file_type { :cobertura }
file_format { :gzip }
after(:build) do |artifact, evaluator|
artifact.file = fixture_file_upload(
Rails.root.join('spec/fixtures/cobertura/coverage_with_corrupted_data.xml.gz'), 'application/x-gzip')
end
end
trait :codequality do trait :codequality do
file_type { :codequality } file_type { :codequality }
file_format { :raw } file_format { :raw }
......
...@@ -67,6 +67,14 @@ FactoryBot.define do ...@@ -67,6 +67,14 @@ FactoryBot.define do
end end
end end
trait :with_coverage_reports do
status { :success }
after(:build) do |pipeline, evaluator|
pipeline.builds << build(:ci_build, :coverage_reports, pipeline: pipeline, project: pipeline.project)
end
end
trait :with_exposed_artifacts do trait :with_exposed_artifacts do
status { :success } status { :success }
......
...@@ -121,6 +121,18 @@ FactoryBot.define do ...@@ -121,6 +121,18 @@ FactoryBot.define do
end end
end end
trait :with_coverage_reports do
after(:build) do |merge_request|
merge_request.head_pipeline = build(
:ci_pipeline,
:success,
:with_coverage_reports,
project: merge_request.source_project,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha)
end
end
trait :with_exposed_artifacts do trait :with_exposed_artifacts do
after(:build) do |merge_request| after(:build) do |merge_request|
merge_request.head_pipeline = build( merge_request.head_pipeline = build(
......
...@@ -190,7 +190,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do ...@@ -190,7 +190,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do
def find_line(line_code) def find_line(line_code)
line = find("[id='#{line_code}']") line = find("[id='#{line_code}']")
line = line.find(:xpath, 'preceding-sibling::*[1][self::td]') if line.tag_name == 'td' line = line.find(:xpath, 'preceding-sibling::*[1][self::td]/preceding-sibling::*[1][self::td]') if line.tag_name == 'td'
line line
end end
end end
<?xml version='1.0'?>
<!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">
<!-- cobertura example file - generated by simplecov-cobertura - subset of gitlab-org/gitlab - manually modified -->
<!-- Generated by simplecov-cobertura version 1.3.1 (https://github.com/dashingrocket/simplecov-cobertura) -->
<coverage line-rate="0.5" branch-rate="0" lines-covered="73865" lines-valid="147397" branches-covered="0" branches-valid="0" complexity="0" version="0" timestamp="1577128350">
<sources>
<source>/tmp/projects/gitlab-ce/gitlab</source>
</sources>
<packages>
<package name="Controllers" line-rate="0.43" branch-rate="0" complexity="0">
<classes>
<class name="abuse_reports_controller" filename="app/controllers/abuse_reports_controller.rb" line-rate="0.3" branch-rate="0" complexity="0">
<methods/>
<lines>
<line number="3" branch="false" hits="1"/>
<line number="4" branch="false" hits="1"/>
<line number="6" branch="false" hits="1"/>
<line number="7" branch="false" hits="0"/>
<line number="8" branch="false" hits="0"/>
<line number="9" branch="false" hits="0"/>
<line number="12" branch="false" hits="1"/>
<line number="13" branch="false" hits="0"/>
<line number="14" branch="false" hits="0"/>
<line number="16" branch="false" hits="0"/>
<line number="17" branch="false" hits="0"/>
<line number="19" branch="false" hits="0"/>
<line number="20" branch="false" hits="0"/>
<line number="22" branch="false" hits="0"/>
<line number="26" branch="false" hits="1"/>
<line number="28" branch="false" hits="1"/>
<line number="29" branch="false" hits="0"/>
<line number="36" branch="false" hits="1"/>
<line number="37" branch="false" hits="0"/>
<line number="39" branch="false" hits="0"/>
<line number="40" branch="false" hits="0"/>
<line number="41" branch="false" hits="0"/>
<line number="42" branch="false" hits="0"/>
</lines>
</class>
</classes>
</package>
</packages>
</coverage>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">
<!-- cobertura example file - generated by gocov-xml - subset of gitlab-org/gitaly -->
<coverage line-rate="0.7966102" branch-rate="0" lines-covered="47" lines-valid="59" branches-covered="0" branches-valid="0" complexity="0" version="" timestamp="1577127162320">
<packages>
<package name="gitlab.com/gitlab-org/gitaly/auth" line-rate="0.7966102" branch-rate="0" complexity="0" line-count="59" line-hits="47">
<classes>
<class name="-" filename="auth/rpccredentials.go" line-rate="0.2" branch-rate="0" complexity="0" line-count="5" line-hits="1">
<methods>
<method name="RPCCredentials" signature="" line-rate="1" branch-rate="0" complexity="0" line-count="0" line-hits="0">
<lines>
<line number="17" hits="1"></line>
</lines>
</method>
<method name="RPCCredentialsV2" signature="" line-rate="0" branch-rate="0" complexity="0" line-count="0" line-hits="0">
<lines>
<line number="34" hits="0"></line>
</lines>
</method>
<method name="hmacToken" signature="" line-rate="0" branch-rate="0" complexity="0" line-count="0" line-hits="0">
<lines>
<line number="52" hits="0"></line>
<line number="53" hits="0"></line>
<line number="55" hits="0"></line>
</lines>
</method>
</methods>
<lines>
<line number="17" hits="1"></line>
<line number="34" hits="0"></line>
<line number="52" hits="0"></line>
<line number="53" hits="0"></line>
<line number="55" hits="0"></line>
</lines>
</class>
<class name="rpcCredentials" filename="auth/rpccredentials.go" line-rate="0.5" branch-rate="0" complexity="0" line-count="2" line-hits="1">
<methods>
<method name="RequireTransportSecurity" signature="" line-rate="0" branch-rate="0" complexity="0" line-count="0" line-hits="0">
<lines>
<line number="24" hits="0"></line>
</lines>
</method>
<method name="GetRequestMetadata" signature="" line-rate="1" branch-rate="0" complexity="0" line-count="0" line-hits="0">
<lines>
<line number="27" hits="1"></line>
</lines>
</method>
</methods>
<lines>
<line number="24" hits="0"></line>
<line number="27" hits="1"></line>
</lines>
</class>
<class name="rpcCredentialsV2" filename="auth/rpccredentials.go" line-rate="0" branch-rate="0" complexity="0" line-count="3" line-hits="0">
<methods>
<method name="RequireTransportSecurity" signature="" line-rate="0" branch-rate="0" complexity="0" line-count="0" line-hits="0">
<lines>
<line number="41" hits="0"></line>
</lines>
</method>
<method name="GetRequestMetadata" signature="" line-rate="0" branch-rate="0" complexity="0" line-count="0" line-hits="0">
<lines>
<line number="44" hits="0"></line>
</lines>
</method>
<method name="hmacToken" signature="" line-rate="0" branch-rate="0" complexity="0" line-count="0" line-hits="0">
<lines>
<line number="48" hits="0"></line>
</lines>
</method>
</methods>
<lines>
<line number="41" hits="0"></line>
<line number="44" hits="0"></line>
<line number="48" hits="0"></line>
</lines>
</class>
<class name="-" filename="auth/token.go" line-rate="0.9183673" branch-rate="0" complexity="0" line-count="49" line-hits="45">
<methods>
<method name="init" signature="" line-rate="1" branch-rate="0" complexity="0" line-count="0" line-hits="0">
<lines>
<line number="38" hits="1"></line>
</lines>
</method>
<method name="CheckToken" signature="" line-rate="0.9285714" branch-rate="0" complexity="0" line-count="0" line-hits="0">
<lines>
<line number="52" hits="1"></line>
<line number="53" hits="0"></line>
<line number="56" hits="1"></line>
<line number="57" hits="1"></line>
<line number="58" hits="1"></line>
<line number="61" hits="1"></line>
<line number="63" hits="1"></line>
<line number="64" hits="1"></line>
<line number="65" hits="1"></line>
<line number="68" hits="1"></line>
<line number="69" hits="1"></line>
<line number="72" hits="1"></line>
<line number="73" hits="1"></line>
<line number="77" hits="1"></line>
</lines>
</method>
<method name="tokensEqual" signature="" line-rate="1" branch-rate="0" complexity="0" line-count="0" line-hits="0">
<lines>
<line number="81" hits="1"></line>
</lines>
</method>
<method name="ExtractAuthInfo" signature="" line-rate="0.90909094" branch-rate="0" complexity="0" line-count="0" line-hits="0">
<lines>
<line number="86" hits="1"></line>
<line number="88" hits="1"></line>
<line number="89" hits="1"></line>
<line number="92" hits="1"></line>
<line number="96" hits="1"></line>
<line number="97" hits="1"></line>
<line number="100" hits="1"></line>
<line number="101" hits="1"></line>
<line number="102" hits="1"></line>
<line number="103" hits="0"></line>
<line number="106" hits="1"></line>
</lines>
</method>
<method name="countV2Error" signature="" line-rate="1" branch-rate="0" complexity="0" line-count="0" line-hits="0">
<lines>
<line number="109" hits="1"></line>
</lines>
</method>
<method name="v2HmacInfoValid" signature="" line-rate="0.8888889" branch-rate="0" complexity="0" line-count="0" line-hits="0">
<lines>
<line number="112" hits="1"></line>
<line number="113" hits="1"></line>
<line number="114" hits="1"></line>
<line number="115" hits="1"></line>
<line number="118" hits="1"></line>
<line number="119" hits="1"></line>
<line number="120" hits="0"></line>
<line number="121" hits="0"></line>
<line number="124" hits="1"></line>
<line number="125" hits="1"></line>
<line number="126" hits="1"></line>
<line number="128" hits="1"></line>
<line number="129" hits="1"></line>
<line number="130" hits="1"></line>
<line number="133" hits="1"></line>
<line number="134" hits="1"></line>
<line number="135" hits="1"></line>
<line number="138" hits="1"></line>
</lines>
</method>
<method name="hmacSign" signature="" line-rate="1" branch-rate="0" complexity="0" line-count="0" line-hits="0">
<lines>
<line number="142" hits="1"></line>
<line number="143" hits="1"></line>
<line number="145" hits="1"></line>
</lines>
</method>
</methods>
<lines>
<line number="38" hits="1"></line>
<line number="52" hits="1"></line>
<line number="53" hits="0"></line>
<line number="56" hits="1"></line>
<line number="57" hits="1"></line>
<line number="58" hits="1"></line>
<line number="61" hits="1"></line>
<line number="63" hits="1"></line>
<line number="64" hits="1"></line>
<line number="65" hits="1"></line>
<line number="68" hits="1"></line>
<line number="69" hits="1"></line>
<line number="72" hits="1"></line>
<line number="73" hits="1"></line>
<line number="77" hits="1"></line>
<line number="81" hits="1"></line>
<line number="86" hits="1"></line>
<line number="88" hits="1"></line>
<line number="89" hits="1"></line>
<line number="92" hits="1"></line>
<line number="96" hits="1"></line>
<line number="97" hits="1"></line>
<line number="100" hits="1"></line>
<line number="101" hits="1"></line>
<line number="102" hits="1"></line>
<line number="103" hits="0"></line>
<line number="106" hits="1"></line>
<line number="109" hits="1"></line>
<line number="112" hits="1"></line>
<line number="113" hits="1"></line>
<line number="114" hits="1"></line>
<line number="115" hits="1"></line>
<line number="118" hits="1"></line>
<line number="119" hits="1"></line>
<line number="120" hits="0"></line>
<line number="121" hits="0"></line>
<line number="124" hits="1"></line>
<line number="125" hits="1"></line>
<line number="126" hits="1"></line>
<line number="128" hits="1"></line>
<line number="129" hits="1"></line>
<line number="130" hits="1"></line>
<line number="133" hits="1"></line>
<line number="134" hits="1"></line>
<line number="135" hits="1"></line>
<line number="138" hits="1"></line>
<line number="142" hits="1"></line>
<line number="143" hits="1"></line>
<line number="145" hits="1"></line>
</lines>
</class>
</classes>
</package>
</packages>
<sources>
<source>/tmp/projects/gitlab-ce/gitaly/src/gitlab.com/gitlab-org/gitaly</source>
</sources>
</coverage>
<?xml version="1.0" ?>
<!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">
<!-- cobertura example file - generated by NYC - manually modified -->
<coverage lines-valid="22" lines-covered="16" line-rate="0.7273000000000001" branches-valid="4" branches-covered="2" branch-rate="0.5" timestamp="1576756029756" complexity="0" version="0.1">
<sources>
<source>/tmp/projects/coverage-test</source>
</sources>
<packages>
<package name="coverage-test" line-rate="0.6842" branch-rate="0.5">
<classes>
<class name="index.js" filename="index.js" line-rate="0.6842" branch-rate="0.5">
<methods>
<method name="(anonymous_3)" hits="0" signature="()V">
<lines>
<line number="21" hits="0"/>
</lines>
</method>
</methods>
<lines>
<line number="21" hits="1" branch="false"/>
<line number="22" hits="0" branch="false"/>
<line number="25" hits="1" branch="true" condition-coverage="50% (1/2)"/>
<line number="26" hits="0" branch="false"/>
<line number="27" hits="0" branch="false"/>
<line number="28" hits="0" branch="false"/>
<line number="29" hits="0" branch="false"/>
</lines>
</class>
</classes>
</package>
<package name="coverage-test.lib.math" line-rate="1" branch-rate="1">
<classes>
<class name="add.js" filename="lib/math/add.js" line-rate="1" branch-rate="1">
<methods>
<method name="(anonymous_0)" hits="1" signature="()V">
<lines>
<line number="1" hits="1"/>
</lines>
</method>
</methods>
<lines>
<line null="test" hits="1" branch="false"/>
<line number="2" hits="1" branch="false"/>
<line number="3" hits="1" branch="false"/>
</lines>
</class>
</classes>
</package>
</packages>
</coverage>
...@@ -41,6 +41,7 @@ describe('diffs/components/app', () => { ...@@ -41,6 +41,7 @@ describe('diffs/components/app', () => {
endpoint: TEST_ENDPOINT, endpoint: TEST_ENDPOINT,
endpointMetadata: `${TEST_HOST}/diff/endpointMetadata`, endpointMetadata: `${TEST_HOST}/diff/endpointMetadata`,
endpointBatch: `${TEST_HOST}/diff/endpointBatch`, endpointBatch: `${TEST_HOST}/diff/endpointBatch`,
endpointCoverage: `${TEST_HOST}/diff/endpointCoverage`,
projectPath: 'namespace/project', projectPath: 'namespace/project',
currentUser: {}, currentUser: {},
changesEmptyStateIllustration: '', changesEmptyStateIllustration: '',
...@@ -95,6 +96,7 @@ describe('diffs/components/app', () => { ...@@ -95,6 +96,7 @@ describe('diffs/components/app', () => {
jest.spyOn(wrapper.vm, 'fetchDiffFiles').mockImplementation(fetchResolver); jest.spyOn(wrapper.vm, 'fetchDiffFiles').mockImplementation(fetchResolver);
jest.spyOn(wrapper.vm, 'fetchDiffFilesMeta').mockImplementation(fetchResolver); jest.spyOn(wrapper.vm, 'fetchDiffFilesMeta').mockImplementation(fetchResolver);
jest.spyOn(wrapper.vm, 'fetchDiffFilesBatch').mockImplementation(fetchResolver); jest.spyOn(wrapper.vm, 'fetchDiffFilesBatch').mockImplementation(fetchResolver);
jest.spyOn(wrapper.vm, 'fetchCoverageFiles').mockImplementation(fetchResolver);
jest.spyOn(wrapper.vm, 'setDiscussions').mockImplementation(() => {}); jest.spyOn(wrapper.vm, 'setDiscussions').mockImplementation(() => {});
jest.spyOn(wrapper.vm, 'startRenderDiffsQueue').mockImplementation(() => {}); jest.spyOn(wrapper.vm, 'startRenderDiffsQueue').mockImplementation(() => {});
jest.spyOn(wrapper.vm, 'unwatchDiscussions').mockImplementation(() => {}); jest.spyOn(wrapper.vm, 'unwatchDiscussions').mockImplementation(() => {});
...@@ -250,6 +252,7 @@ describe('diffs/components/app', () => { ...@@ -250,6 +252,7 @@ describe('diffs/components/app', () => {
expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled(); expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesMeta).not.toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesMeta).not.toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled();
expect(wrapper.vm.fetchCoverageFiles).toHaveBeenCalled();
expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled(); expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled();
expect(wrapper.vm.diffFilesLength).toEqual(100); expect(wrapper.vm.diffFilesLength).toEqual(100);
expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled(); expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled();
...@@ -269,6 +272,7 @@ describe('diffs/components/app', () => { ...@@ -269,6 +272,7 @@ describe('diffs/components/app', () => {
expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled(); expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled();
expect(wrapper.vm.fetchCoverageFiles).toHaveBeenCalled();
expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled(); expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled();
expect(wrapper.vm.diffFilesLength).toEqual(100); expect(wrapper.vm.diffFilesLength).toEqual(100);
expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled(); expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled();
...@@ -286,6 +290,7 @@ describe('diffs/components/app', () => { ...@@ -286,6 +290,7 @@ describe('diffs/components/app', () => {
expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled(); expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled();
expect(wrapper.vm.fetchCoverageFiles).toHaveBeenCalled();
expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled(); expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled();
expect(wrapper.vm.diffFilesLength).toEqual(100); expect(wrapper.vm.diffFilesLength).toEqual(100);
expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled(); expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled();
......
...@@ -12,6 +12,7 @@ describe('InlineDiffTableRow', () => { ...@@ -12,6 +12,7 @@ describe('InlineDiffTableRow', () => {
vm = createComponentWithStore(Vue.extend(InlineDiffTableRow), createStore(), { vm = createComponentWithStore(Vue.extend(InlineDiffTableRow), createStore(), {
line: thisLine, line: thisLine,
fileHash: diffFileMockData.file_hash, fileHash: diffFileMockData.file_hash,
filePath: diffFileMockData.file_path,
contextLinesPath: 'contextLinesPath', contextLinesPath: 'contextLinesPath',
isHighlighted: false, isHighlighted: false,
}).$mount(); }).$mount();
...@@ -39,4 +40,64 @@ describe('InlineDiffTableRow', () => { ...@@ -39,4 +40,64 @@ describe('InlineDiffTableRow', () => {
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
}); });
describe('sets coverage title and class', () => {
it('for lines with coverage', done => {
vm.$nextTick()
.then(() => {
const name = diffFileMockData.file_path;
const line = thisLine.new_line;
vm.$store.state.diffs.coverageFiles = { files: { [name]: { [line]: 5 } } };
return vm.$nextTick();
})
.then(() => {
const coverage = vm.$el.querySelector('.line-coverage');
expect(coverage.title).toContain('Test coverage: 5 hits');
expect(coverage.classList).toContain('coverage');
})
.then(done)
.catch(done.fail);
});
it('for lines without coverage', done => {
vm.$nextTick()
.then(() => {
const name = diffFileMockData.file_path;
const line = thisLine.new_line;
vm.$store.state.diffs.coverageFiles = { files: { [name]: { [line]: 0 } } };
return vm.$nextTick();
})
.then(() => {
const coverage = vm.$el.querySelector('.line-coverage');
expect(coverage.title).toContain('No test coverage');
expect(coverage.classList).toContain('no-coverage');
})
.then(done)
.catch(done.fail);
});
it('for unknown lines', done => {
vm.$nextTick()
.then(() => {
vm.$store.state.diffs.coverageFiles = {};
return vm.$nextTick();
})
.then(() => {
const coverage = vm.$el.querySelector('.line-coverage');
expect(coverage.title).not.toContain('Coverage');
expect(coverage.classList).not.toContain('coverage');
expect(coverage.classList).not.toContain('no-coverage');
})
.then(done)
.catch(done.fail);
});
});
}); });
...@@ -14,6 +14,7 @@ describe('ParallelDiffTableRow', () => { ...@@ -14,6 +14,7 @@ describe('ParallelDiffTableRow', () => {
vm = createComponentWithStore(Vue.extend(ParallelDiffTableRow), createStore(), { vm = createComponentWithStore(Vue.extend(ParallelDiffTableRow), createStore(), {
line: thisLine, line: thisLine,
fileHash: diffFileMockData.file_hash, fileHash: diffFileMockData.file_hash,
filePath: diffFileMockData.file_path,
contextLinesPath: 'contextLinesPath', contextLinesPath: 'contextLinesPath',
isHighlighted: false, isHighlighted: false,
}).$mount(); }).$mount();
...@@ -52,6 +53,7 @@ describe('ParallelDiffTableRow', () => { ...@@ -52,6 +53,7 @@ describe('ParallelDiffTableRow', () => {
vm = createComponentWithStore(Vue.extend(ParallelDiffTableRow), createStore(), { vm = createComponentWithStore(Vue.extend(ParallelDiffTableRow), createStore(), {
line: thisLine, line: thisLine,
fileHash: diffFileMockData.file_hash, fileHash: diffFileMockData.file_hash,
filePath: diffFileMockData.file_path,
contextLinesPath: 'contextLinesPath', contextLinesPath: 'contextLinesPath',
isHighlighted: false, isHighlighted: false,
}).$mount(); }).$mount();
...@@ -81,5 +83,65 @@ describe('ParallelDiffTableRow', () => { ...@@ -81,5 +83,65 @@ describe('ParallelDiffTableRow', () => {
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
}); });
describe('sets coverage title and class', () => {
it('for lines with coverage', done => {
vm.$nextTick()
.then(() => {
const name = diffFileMockData.file_path;
const line = rightLine.new_line;
vm.$store.state.diffs.coverageFiles = { files: { [name]: { [line]: 5 } } };
return vm.$nextTick();
})
.then(() => {
const coverage = vm.$el.querySelector('.line-coverage.right-side');
expect(coverage.title).toContain('Test coverage: 5 hits');
expect(coverage.classList).toContain('coverage');
})
.then(done)
.catch(done.fail);
});
it('for lines without coverage', done => {
vm.$nextTick()
.then(() => {
const name = diffFileMockData.file_path;
const line = rightLine.new_line;
vm.$store.state.diffs.coverageFiles = { files: { [name]: { [line]: 0 } } };
return vm.$nextTick();
})
.then(() => {
const coverage = vm.$el.querySelector('.line-coverage.right-side');
expect(coverage.title).toContain('No test coverage');
expect(coverage.classList).toContain('no-coverage');
})
.then(done)
.catch(done.fail);
});
it('for unknown lines', done => {
vm.$nextTick()
.then(() => {
vm.$store.state.diffs.coverageFiles = {};
return vm.$nextTick();
})
.then(() => {
const coverage = vm.$el.querySelector('.line-coverage.right-side');
expect(coverage.title).not.toContain('Coverage');
expect(coverage.classList).not.toContain('coverage');
expect(coverage.classList).not.toContain('no-coverage');
})
.then(done)
.catch(done.fail);
});
});
}); });
}); });
...@@ -12,6 +12,7 @@ import actions, { ...@@ -12,6 +12,7 @@ import actions, {
fetchDiffFiles, fetchDiffFiles,
fetchDiffFilesBatch, fetchDiffFilesBatch,
fetchDiffFilesMeta, fetchDiffFilesMeta,
fetchCoverageFiles,
assignDiscussionsToDiff, assignDiscussionsToDiff,
removeDiscussionsFromDiff, removeDiscussionsFromDiff,
startRenderDiffsQueue, startRenderDiffsQueue,
...@@ -73,6 +74,7 @@ describe('DiffsStoreActions', () => { ...@@ -73,6 +74,7 @@ describe('DiffsStoreActions', () => {
const endpoint = '/diffs/set/endpoint'; const endpoint = '/diffs/set/endpoint';
const endpointMetadata = '/diffs/set/endpoint/metadata'; const endpointMetadata = '/diffs/set/endpoint/metadata';
const endpointBatch = '/diffs/set/endpoint/batch'; const endpointBatch = '/diffs/set/endpoint/batch';
const endpointCoverage = '/diffs/set/coverage_reports';
const projectPath = '/root/project'; const projectPath = '/root/project';
const dismissEndpoint = '/-/user_callouts'; const dismissEndpoint = '/-/user_callouts';
const showSuggestPopover = false; const showSuggestPopover = false;
...@@ -84,6 +86,7 @@ describe('DiffsStoreActions', () => { ...@@ -84,6 +86,7 @@ describe('DiffsStoreActions', () => {
endpoint, endpoint,
endpointBatch, endpointBatch,
endpointMetadata, endpointMetadata,
endpointCoverage,
projectPath, projectPath,
dismissEndpoint, dismissEndpoint,
showSuggestPopover, showSuggestPopover,
...@@ -93,6 +96,7 @@ describe('DiffsStoreActions', () => { ...@@ -93,6 +96,7 @@ describe('DiffsStoreActions', () => {
endpoint: '', endpoint: '',
endpointBatch: '', endpointBatch: '',
endpointMetadata: '', endpointMetadata: '',
endpointCoverage: '',
projectPath: '', projectPath: '',
dismissEndpoint: '', dismissEndpoint: '',
showSuggestPopover: true, showSuggestPopover: true,
...@@ -105,6 +109,7 @@ describe('DiffsStoreActions', () => { ...@@ -105,6 +109,7 @@ describe('DiffsStoreActions', () => {
endpoint, endpoint,
endpointMetadata, endpointMetadata,
endpointBatch, endpointBatch,
endpointCoverage,
projectPath, projectPath,
dismissEndpoint, dismissEndpoint,
showSuggestPopover, showSuggestPopover,
...@@ -318,6 +323,44 @@ describe('DiffsStoreActions', () => { ...@@ -318,6 +323,44 @@ describe('DiffsStoreActions', () => {
}); });
}); });
describe('fetchCoverageFiles', () => {
let mock;
const endpointCoverage = '/fetch';
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => mock.restore());
it('should commit SET_COVERAGE_DATA with received response', done => {
const data = { files: { 'app.js': { '1': 0, '2': 1 } } };
mock.onGet(endpointCoverage).reply(200, { data });
testAction(
fetchCoverageFiles,
{},
{ endpointCoverage },
[{ type: types.SET_COVERAGE_DATA, payload: { data } }],
[],
done,
);
});
it('should show flash on API error', done => {
const flashSpy = spyOnDependency(actions, 'createFlash');
mock.onGet(endpointCoverage).reply(400);
testAction(fetchCoverageFiles, {}, { endpointCoverage }, [], [], () => {
expect(flashSpy).toHaveBeenCalledTimes(1);
expect(flashSpy).toHaveBeenCalledWith(jasmine.stringMatching('Something went wrong'));
done();
});
});
});
describe('setHighlightedRow', () => { describe('setHighlightedRow', () => {
it('should mark currently selected diff and set lineHash and fileHash of highlightedRow', () => { it('should mark currently selected diff and set lineHash and fileHash of highlightedRow', () => {
testAction(setHighlightedRow, 'ABC_123', {}, [ testAction(setHighlightedRow, 'ABC_123', {}, [
......
...@@ -282,4 +282,34 @@ describe('Diffs Module Getters', () => { ...@@ -282,4 +282,34 @@ describe('Diffs Module Getters', () => {
expect(getters.currentDiffIndex(localState)).toEqual(0); expect(getters.currentDiffIndex(localState)).toEqual(0);
}); });
}); });
describe('fileLineCoverage', () => {
beforeEach(() => {
Object.assign(localState.coverageFiles, { files: { 'app.js': { '1': 0, '2': 5 } } });
});
it('returns empty object when no coverage data is available', () => {
Object.assign(localState.coverageFiles, {});
expect(getters.fileLineCoverage(localState)('test.js', 2)).toEqual({});
});
it('returns empty object when unknown filename is passed', () => {
expect(getters.fileLineCoverage(localState)('test.js', 2)).toEqual({});
});
it('returns no-coverage info when correct filename and line is passed', () => {
expect(getters.fileLineCoverage(localState)('app.js', 1)).toEqual({
text: 'No test coverage',
class: 'no-coverage',
});
});
it('returns coverage info when correct filename and line is passed', () => {
expect(getters.fileLineCoverage(localState)('app.js', 2)).toEqual({
text: 'Test coverage: 5 hits',
class: 'coverage',
});
});
});
}); });
...@@ -123,6 +123,17 @@ describe('DiffsStoreMutations', () => { ...@@ -123,6 +123,17 @@ describe('DiffsStoreMutations', () => {
}); });
}); });
describe('SET_COVERAGE_DATA', () => {
it('should set coverage data properly', () => {
const state = { coverageFiles: {} };
const coverage = { 'app.js': { '1': 0, '2': 1 } };
mutations[types.SET_COVERAGE_DATA](state, coverage);
expect(state.coverageFiles).toEqual(coverage);
});
});
describe('SET_DIFF_VIEW_TYPE', () => { describe('SET_DIFF_VIEW_TYPE', () => {
it('should set diff view type properly', () => { it('should set diff view type properly', () => {
const state = {}; const state = {};
......
...@@ -45,6 +45,7 @@ describe Gitlab::Ci::Config::Entry::Reports do ...@@ -45,6 +45,7 @@ describe Gitlab::Ci::Config::Entry::Reports do
:performance | 'performance.json' :performance | 'performance.json'
:lsif | 'lsif.json' :lsif | 'lsif.json'
:dotenv | 'build.dotenv' :dotenv | 'build.dotenv'
:cobertura | 'cobertura-coverage.xml'
end end
with_them do with_them do
......
# frozen_string_literal: true
require 'fast_spec_helper'
describe Gitlab::Ci::Parsers::Coverage::Cobertura do
describe '#parse!' do
subject { described_class.new.parse!(cobertura, coverage_report) }
let(:coverage_report) { Gitlab::Ci::Reports::CoverageReports.new }
context 'when data is Cobertura style XML' do
context 'when there is no <class>' do
let(:cobertura) { '' }
it 'parses XML and returns empty coverage' do
expect { subject }.not_to raise_error
expect(coverage_report.files).to eq({})
end
end
context 'when there is a single <class>' do
context 'with no lines' do
let(:cobertura) do
<<-EOF.strip_heredoc
<classes><class filename="app.rb"></class></classes>
EOF
end
it 'parses XML and returns empty coverage' do
expect { subject }.not_to raise_error
expect(coverage_report.files).to eq({})
end
end
context 'with a single line' do
let(:cobertura) do
<<-EOF.strip_heredoc
<classes>
<class filename="app.rb"><lines>
<line number="1" hits="2"/>
</lines></class>
</classes>
EOF
end
it 'parses XML and returns a single file with coverage' do
expect { subject }.not_to raise_error
expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 2 } })
end
end
context 'with multipe lines and methods info' do
let(:cobertura) do
<<-EOF.strip_heredoc
<classes>
<class filename="app.rb"><methods/><lines>
<line number="1" hits="2"/>
<line number="2" hits="0"/>
</lines></class>
</classes>
EOF
end
it 'parses XML and returns a single file with coverage' do
expect { subject }.not_to raise_error
expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 2, 2 => 0 } })
end
end
end
context 'when there are multipe <class>' do
context 'with the same filename and different lines' do
let(:cobertura) do
<<-EOF.strip_heredoc
<classes>
<class filename="app.rb"><methods/><lines>
<line number="1" hits="2"/>
<line number="2" hits="0"/>
</lines></class>
<class filename="app.rb"><methods/><lines>
<line number="6" hits="1"/>
<line number="7" hits="1"/>
</lines></class>
</classes>
EOF
end
it 'parses XML and returns a single file with merged coverage' do
expect { subject }.not_to raise_error
expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 2, 2 => 0, 6 => 1, 7 => 1 } })
end
end
context 'with the same filename and lines' do
let(:cobertura) do
<<-EOF.strip_heredoc
<packages><package><classes>
<class filename="app.rb"><methods/><lines>
<line number="1" hits="2"/>
<line number="2" hits="0"/>
</lines></class>
<class filename="app.rb"><methods/><lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
</lines></class>
</classes></package></packages>
EOF
end
it 'parses XML and returns a single file with summed-up coverage' do
expect { subject }.not_to raise_error
expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 3, 2 => 1 } })
end
end
context 'with missing filename' do
let(:cobertura) do
<<-EOF.strip_heredoc
<classes>
<class filename="app.rb"><methods/><lines>
<line number="1" hits="2"/>
<line number="2" hits="0"/>
</lines></class>
<class><methods/><lines>
<line number="6" hits="1"/>
<line number="7" hits="1"/>
</lines></class>
</classes>
EOF
end
it 'parses XML and ignores class with missing name' do
expect { subject }.not_to raise_error
expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 2, 2 => 0 } })
end
end
context 'with invalid line information' do
let(:cobertura) do
<<-EOF.strip_heredoc
<classes>
<class filename="app.rb"><methods/><lines>
<line number="1" hits="2"/>
<line number="2" hits="0"/>
</lines></class>
<class filename="app.rb"><methods/><lines>
<line null="test" hits="1"/>
<line number="7" hits="1"/>
</lines></class>
</classes>
EOF
end
it 'raises an error' do
expect { subject }.to raise_error(described_class::CoberturaParserError)
end
end
end
end
context 'when data is not Cobertura style XML' do
let(:cobertura) { { coverage: '12%' }.to_json }
it 'raises an error' do
expect { subject }.to raise_error(described_class::CoberturaParserError)
end
end
end
end
...@@ -6,7 +6,7 @@ describe Gitlab::Ci::Parsers do ...@@ -6,7 +6,7 @@ describe Gitlab::Ci::Parsers do
describe '.fabricate!' do describe '.fabricate!' do
subject { described_class.fabricate!(file_type) } subject { described_class.fabricate!(file_type) }
context 'when file_type exists' do context 'when file_type is junit' do
let(:file_type) { 'junit' } let(:file_type) { 'junit' }
it 'fabricates the class' do it 'fabricates the class' do
...@@ -14,6 +14,14 @@ describe Gitlab::Ci::Parsers do ...@@ -14,6 +14,14 @@ describe Gitlab::Ci::Parsers do
end end
end end
context 'when file_type is cobertura' do
let(:file_type) { 'cobertura' }
it 'fabricates the class' do
is_expected.to be_a(described_class::Coverage::Cobertura)
end
end
context 'when file_type does not exist' do context 'when file_type does not exist' do
let(:file_type) { 'undefined' } let(:file_type) { 'undefined' }
......
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Ci::Reports::CoverageReports do
let(:coverage_report) { described_class.new }
it { expect(coverage_report.files).to eq({}) }
describe '#pick' do
before do
coverage_report.add_file('app.rb', { 1 => 0, 2 => 1 })
coverage_report.add_file('routes.rb', { 3 => 1, 4 => 0 })
end
it 'returns only picked files while ignoring nonexistent ones' do
expect(coverage_report.pick(['routes.rb', 'nonexistent.txt'])).to eq({
files: { 'routes.rb' => { 3 => 1, 4 => 0 } }
})
end
end
describe '#add_file' do
context 'when providing two individual files' do
before do
coverage_report.add_file('app.rb', { 1 => 0, 2 => 1 })
coverage_report.add_file('routes.rb', { 3 => 1, 4 => 0 })
end
it 'initializes a new test suite and returns it' do
expect(coverage_report.files).to eq({
'app.rb' => { 1 => 0, 2 => 1 },
'routes.rb' => { 3 => 1, 4 => 0 }
})
end
end
context 'when providing the same files twice' do
context 'with different line coverage' do
before do
coverage_report.add_file('admin.rb', { 1 => 0, 2 => 1 })
coverage_report.add_file('admin.rb', { 3 => 1, 4 => 0 })
end
it 'initializes a new test suite and returns it' do
expect(coverage_report.files).to eq({
'admin.rb' => { 1 => 0, 2 => 1, 3 => 1, 4 => 0 }
})
end
end
context 'with identical line coverage' do
before do
coverage_report.add_file('projects.rb', { 1 => 0, 2 => 1 })
coverage_report.add_file('projects.rb', { 1 => 0, 2 => 1 })
end
it 'initializes a new test suite and returns it' do
expect(coverage_report.files).to eq({
'projects.rb' => { 1 => 0, 2 => 2 }
})
end
end
end
end
end
...@@ -3946,6 +3946,53 @@ describe Ci::Build do ...@@ -3946,6 +3946,53 @@ describe Ci::Build do
end end
end end
describe '#collect_coverage_reports!' do
subject { build.collect_coverage_reports!(coverage_report) }
let(:coverage_report) { Gitlab::Ci::Reports::CoverageReports.new }
it { expect(coverage_report.files).to eq({}) }
context 'when build has a coverage report' do
context 'when there is a Cobertura coverage report from simplecov-cobertura' do
before do
create(:ci_job_artifact, :cobertura, job: build, project: build.project)
end
it 'parses blobs and add the results to the coverage report' do
expect { subject }.not_to raise_error
expect(coverage_report.files.keys).to match_array(['app/controllers/abuse_reports_controller.rb'])
expect(coverage_report.files['app/controllers/abuse_reports_controller.rb'].count).to eq(23)
end
end
context 'when there is a Cobertura coverage report from gocov-xml' do
before do
create(:ci_job_artifact, :coverage_gocov_xml, job: build, project: build.project)
end
it 'parses blobs and add the results to the coverage report' do
expect { subject }.not_to raise_error
expect(coverage_report.files.keys).to match_array(['auth/token.go', 'auth/rpccredentials.go'])
expect(coverage_report.files['auth/token.go'].count).to eq(49)
expect(coverage_report.files['auth/rpccredentials.go'].count).to eq(10)
end
end
context 'when there is a corrupted Cobertura coverage report' do
before do
create(:ci_job_artifact, :coverage_with_corrupted_data, job: build, project: build.project)
end
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Ci::Parsers::Coverage::Cobertura::CoberturaParserError)
end
end
end
end
describe '#report_artifacts' do describe '#report_artifacts' do
subject { build.report_artifacts } subject { build.report_artifacts }
......
...@@ -70,6 +70,22 @@ describe Ci::JobArtifact do ...@@ -70,6 +70,22 @@ describe Ci::JobArtifact do
end end
end end
describe '.coverage_reports' do
subject { described_class.coverage_reports }
context 'when there is a coverage report' do
let!(:artifact) { create(:ci_job_artifact, :cobertura) }
it { is_expected.to eq([artifact]) }
end
context 'when there are no coverage reports' do
let!(:artifact) { create(:ci_job_artifact, :archive) }
it { is_expected.to be_empty }
end
end
describe '.erasable' do describe '.erasable' do
subject { described_class.erasable } subject { described_class.erasable }
......
...@@ -344,9 +344,9 @@ describe Ci::Pipeline, :mailer do ...@@ -344,9 +344,9 @@ describe Ci::Pipeline, :mailer do
end end
describe '.with_reports' do describe '.with_reports' do
subject { described_class.with_reports(Ci::JobArtifact.test_reports) }
context 'when pipeline has a test report' do context 'when pipeline has a test report' do
subject { described_class.with_reports(Ci::JobArtifact.test_reports) }
let!(:pipeline_with_report) { create(:ci_pipeline, :with_test_reports) } let!(:pipeline_with_report) { create(:ci_pipeline, :with_test_reports) }
it 'selects the pipeline' do it 'selects the pipeline' do
...@@ -354,7 +354,19 @@ describe Ci::Pipeline, :mailer do ...@@ -354,7 +354,19 @@ describe Ci::Pipeline, :mailer do
end end
end end
context 'when pipeline has a coverage report' do
subject { described_class.with_reports(Ci::JobArtifact.coverage_reports) }
let!(:pipeline_with_report) { create(:ci_pipeline, :with_coverage_reports) }
it 'selects the pipeline' do
is_expected.to eq([pipeline_with_report])
end
end
context 'when pipeline does not have metrics reports' do context 'when pipeline does not have metrics reports' do
subject { described_class.with_reports(Ci::JobArtifact.test_reports) }
let!(:pipeline_without_report) { create(:ci_empty_pipeline) } let!(:pipeline_without_report) { create(:ci_empty_pipeline) }
it 'does not select the pipeline' do it 'does not select the pipeline' do
...@@ -2730,6 +2742,43 @@ describe Ci::Pipeline, :mailer do ...@@ -2730,6 +2742,43 @@ describe Ci::Pipeline, :mailer do
end end
end end
describe '#coverage_reports' do
subject { pipeline.coverage_reports }
context 'when pipeline has multiple builds with coverage reports' do
let!(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline, project: project) }
let!(:build_golang) { create(:ci_build, :success, name: 'golang', pipeline: pipeline, project: project) }
before do
create(:ci_job_artifact, :cobertura, job: build_rspec, project: project)
create(:ci_job_artifact, :coverage_gocov_xml, job: build_golang, project: project)
end
it 'returns coverage reports with collected data' do
expect(subject.files.keys).to match_array([
"auth/token.go",
"auth/rpccredentials.go",
"app/controllers/abuse_reports_controller.rb"
])
end
context 'when builds are retried' do
let!(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline, project: project) }
let!(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline, project: project) }
it 'does not take retried builds into account' do
expect(subject.files).to eql({})
end
end
end
context 'when pipeline does not have any builds with coverage reports' do
it 'returns empty coverage reports' do
expect(subject.files).to eql({})
end
end
end
describe '#total_size' do describe '#total_size' do
let!(:build_job1) { create(:ci_build, pipeline: pipeline, stage_idx: 0) } let!(:build_job1) { create(:ci_build, pipeline: pipeline, stage_idx: 0) }
let!(:build_job2) { create(:ci_build, pipeline: pipeline, stage_idx: 0) } let!(:build_job2) { create(:ci_build, pipeline: pipeline, stage_idx: 0) }
......
...@@ -908,6 +908,16 @@ describe MergeRequest do ...@@ -908,6 +908,16 @@ describe MergeRequest do
end end
end end
describe '#new_paths' do
let(:merge_request) do
create(:merge_request, source_branch: 'expand-collapse-files', target_branch: 'master')
end
it 'returns new path of changed files' do
expect(merge_request.new_paths.count).to eq(105)
end
end
describe "#related_notes" do describe "#related_notes" do
let!(:merge_request) { create(:merge_request) } let!(:merge_request) { create(:merge_request) }
...@@ -1581,6 +1591,24 @@ describe MergeRequest do ...@@ -1581,6 +1591,24 @@ describe MergeRequest do
end end
end end
describe '#has_coverage_reports?' do
subject { merge_request.has_coverage_reports? }
let(:project) { create(:project, :repository) }
context 'when head pipeline has coverage reports' do
let(:merge_request) { create(:merge_request, :with_coverage_reports, source_project: project) }
it { is_expected.to be_truthy }
end
context 'when head pipeline does not have coverage reports' do
let(:merge_request) { create(:merge_request, source_project: project) }
it { is_expected.to be_falsey }
end
end
describe '#calculate_reactive_cache' do describe '#calculate_reactive_cache' do
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
let(:merge_request) { create(:merge_request, source_project: project) } let(:merge_request) { create(:merge_request, source_project: project) }
...@@ -1663,6 +1691,60 @@ describe MergeRequest do ...@@ -1663,6 +1691,60 @@ describe MergeRequest do
end end
end end
describe '#find_coverage_reports' do
let(:project) { create(:project, :repository) }
let(:merge_request) { create(:merge_request, :with_coverage_reports, source_project: project) }
let(:pipeline) { merge_request.head_pipeline }
subject { merge_request.find_coverage_reports }
context 'when head pipeline has coverage reports' do
let!(:job) do
create(:ci_build, options: { artifacts: { reports: { cobertura: ['cobertura-coverage.xml'] } } }, pipeline: pipeline)
end
let!(:artifacts_metadata) { create(:ci_job_artifact, :metadata, job: job) }
context 'when reactive cache worker is parsing results asynchronously' do
it 'returns status' do
expect(subject[:status]).to eq(:parsing)
end
end
context 'when reactive cache worker is inline' do
before do
synchronous_reactive_cache(merge_request)
end
it 'returns status and data' do
expect(subject[:status]).to eq(:parsed)
end
context 'when an error occurrs' do
before do
merge_request.update!(head_pipeline: nil)
end
it 'returns an error message' do
expect(subject[:status]).to eq(:error)
end
end
context 'when cached results is not latest' do
before do
allow_next_instance_of(Ci::GenerateCoverageReportsService) do |service|
allow(service).to receive(:latest?).and_return(false)
end
end
it 'raises and InvalidateReactiveCache error' do
expect { subject }.to raise_error(ReactiveCaching::InvalidateReactiveCache)
end
end
end
end
end
describe '#compare_test_reports' do describe '#compare_test_reports' do
subject { merge_request.compare_test_reports } subject { merge_request.compare_test_reports }
......
...@@ -36,7 +36,8 @@ describe Ci::RetryBuildService do ...@@ -36,7 +36,8 @@ describe Ci::RetryBuildService do
job_artifacts_performance job_artifacts_lsif job_artifacts_performance job_artifacts_lsif
job_artifacts_codequality job_artifacts_metrics scheduled_at job_artifacts_codequality job_artifacts_metrics scheduled_at
job_variables waiting_for_resource_at job_artifacts_metrics_referee job_variables waiting_for_resource_at job_artifacts_metrics_referee
job_artifacts_network_referee job_artifacts_dotenv needs].freeze job_artifacts_network_referee job_artifacts_dotenv
job_artifacts_cobertura needs].freeze
IGNORE_ACCESSORS = IGNORE_ACCESSORS =
%i[type lock_version target_url base_tags trace_sections %i[type lock_version target_url base_tags trace_sections
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment