Commit fcf89c94 authored by Kamil Trzciński's avatar Kamil Trzciński

Merge branch 'feat/mr-diff-coverage-visualisation' into 'master'

Add inline file coverage view for merge request diffs

Closes #3708

See merge request gitlab-org/gitlab!21791
parents 754f4ca7 51a66e6b
......@@ -50,6 +50,11 @@ export default {
type: String,
required: true,
},
endpointCoverage: {
type: String,
required: false,
default: '',
},
projectPath: {
type: String,
required: true,
......@@ -169,6 +174,7 @@ export default {
endpoint: this.endpoint,
endpointMetadata: this.endpointMetadata,
endpointBatch: this.endpointBatch,
endpointCoverage: this.endpointCoverage,
projectPath: this.projectPath,
dismissEndpoint: this.dismissEndpoint,
showSuggestPopover: this.showSuggestPopover,
......@@ -218,6 +224,7 @@ export default {
'fetchDiffFiles',
'fetchDiffFilesMeta',
'fetchDiffFilesBatch',
'fetchCoverageFiles',
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
'setHighlightedRow',
......@@ -292,6 +299,10 @@ export default {
});
}
if (this.endpointCoverage) {
this.fetchCoverageFiles();
}
if (!this.isNotesFetched) {
eventHub.$emit('fetchNotesData');
}
......
......@@ -54,7 +54,7 @@ export default {
colspan: {
type: Number,
required: false,
default: 3,
default: 4,
},
},
computed: {
......
......@@ -51,7 +51,7 @@ export default {
<template>
<tr v-if="shouldRender" :class="className" class="notes_holder">
<td class="notes-content" colspan="3">
<td class="notes-content" colspan="4">
<div class="content">
<diff-discussions
v-if="line.discussions.length"
......
<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 {
MATCH_LINE_TYPE,
......@@ -15,11 +16,18 @@ export default {
components: {
DiffTableCell,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
fileHash: {
type: String,
required: true,
},
filePath: {
type: String,
required: true,
},
contextLinesPath: {
type: String,
required: true,
......@@ -40,6 +48,7 @@ export default {
};
},
computed: {
...mapGetters('diffs', ['fileLineCoverage']),
...mapState({
isHighlighted(state) {
return this.line.line_code !== null && this.line.line_code === state.diffs.highlightedRow;
......@@ -62,6 +71,9 @@ export default {
isMatchLine() {
return this.line.type === MATCH_LINE_TYPE;
},
coverageState() {
return this.fileLineCoverage(this.filePath, this.line.new_line);
},
},
created() {
this.newLineType = NEW_LINE_TYPE;
......@@ -113,6 +125,12 @@ export default {
:is-highlighted="isHighlighted"
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
:class="[
line.type,
......@@ -120,7 +138,7 @@ export default {
hll: isHighlighted,
},
]"
class="line_content"
class="line_content with-coverage"
v-html="line.rich_text"
></td>
</tr>
......
......@@ -48,6 +48,7 @@ export default {
<colgroup>
<col style="width: 50px;" />
<col style="width: 50px;" />
<col style="width: 8px;" />
<col />
</colgroup>
<tbody>
......@@ -63,6 +64,7 @@ export default {
<inline-diff-table-row
:key="`${line.line_code || index}`"
:file-hash="diffFile.file_hash"
:file-path="diffFile.file_path"
:context-lines-path="diffFile.context_lines_path"
:line="line"
:is-bottom="index + 1 === diffLinesLength"
......
......@@ -122,7 +122,7 @@ export default {
<template>
<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">
<diff-discussions
:discussions="line.left.discussions"
......@@ -147,7 +147,7 @@ export default {
</template>
</diff-discussion-reply>
</td>
<td class="notes-content parallel new" colspan="2">
<td class="notes-content parallel new" colspan="3">
<div v-if="shouldRenderDiscussionsOnRight" class="content">
<diff-discussions
:discussions="line.right.discussions"
......
......@@ -49,7 +49,7 @@ export default {
:line="line.left"
:is-top="isTop"
:is-bottom="isBottom"
:colspan="4"
:colspan="6"
/>
</template>
</tr>
......
<script>
import { mapActions, mapState } from 'vuex';
import { mapActions, mapGetters, mapState } from 'vuex';
import $ from 'jquery';
import { GlTooltipDirective } from '@gitlab/ui';
import DiffTableCell from './diff_table_cell.vue';
import {
MATCH_LINE_TYPE,
......@@ -18,11 +19,18 @@ export default {
components: {
DiffTableCell,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
fileHash: {
type: String,
required: true,
},
filePath: {
type: String,
required: true,
},
contextLinesPath: {
type: String,
required: true,
......@@ -44,6 +52,7 @@ export default {
};
},
computed: {
...mapGetters('diffs', ['fileLineCoverage']),
...mapState({
isHighlighted(state) {
const lineCode =
......@@ -82,6 +91,9 @@ export default {
isMatchLineRight() {
return this.line.right && this.line.right.type === MATCH_LINE_TYPE;
},
coverageState() {
return this.fileLineCoverage(this.filePath, this.line.right.new_line);
},
},
created() {
this.newLineType = NEW_LINE_TYPE;
......@@ -99,7 +111,7 @@ export default {
const allCellsInHoveringRow = Array.from(e.currentTarget.children);
const hoverIndex = allCellsInHoveringRow.indexOf(hoveringCell);
if (hoverIndex >= 2) {
if (hoverIndex >= 3) {
this.isRightHover = isHover;
} else {
this.isLeftHover = isHover;
......@@ -143,17 +155,19 @@ export default {
line-position="left"
class="diff-line-num old_line"
/>
<td :class="parallelViewLeftLineType" class="line-coverage left-side"></td>
<td
:id="line.left.line_code"
:class="parallelViewLeftLineType"
class="line_content parallel left-side"
class="line_content with-coverage parallel left-side"
@mousedown="handleParallelLineMouseDown"
v-html="line.left.rich_text"
></td>
</template>
<template v-else>
<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 v-if="line.right && !isMatchLineRight">
<diff-table-cell
......@@ -169,6 +183,12 @@ export default {
line-position="right"
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
:id="line.right.line_code"
:class="[
......@@ -177,14 +197,15 @@ export default {
hll: isHighlighted,
},
]"
class="line_content parallel right-side"
class="line_content with-coverage parallel right-side"
@mousedown="handleParallelLineMouseDown"
v-html="line.right.rich_text"
></td>
</template>
<template v-else>
<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>
</tr>
</template>
......@@ -47,8 +47,10 @@ export default {
>
<colgroup>
<col style="width: 50px;" />
<col style="width: 8px;" />
<col />
<col style="width: 50px;" />
<col style="width: 8px;" />
<col />
</colgroup>
<tbody>
......@@ -64,6 +66,7 @@ export default {
<parallel-diff-table-row
:key="line.line_code"
:file-hash="diffFile.file_hash"
:file-path="diffFile.file_path"
:context-lines-path="diffFile.context_lines_path"
:line="line"
:is-bottom="index + 1 === diffLinesLength"
......
......@@ -69,6 +69,7 @@ export default function initDiffsApp(store) {
endpoint: dataset.endpoint,
endpointMetadata: dataset.endpointMetadata || '',
endpointBatch: dataset.endpointBatch || '',
endpointCoverage: dataset.endpointCoverage || '',
projectPath: dataset.projectPath,
helpPagePath: dataset.helpPagePath,
currentUser: JSON.parse(dataset.currentUserData) || {},
......@@ -104,6 +105,7 @@ export default function initDiffsApp(store) {
endpoint: this.endpoint,
endpointMetadata: this.endpointMetadata,
endpointBatch: this.endpointBatch,
endpointCoverage: this.endpointCoverage,
currentUser: this.currentUser,
projectPath: this.projectPath,
helpPagePath: this.helpPagePath,
......
import Vue from 'vue';
import Cookies from 'js-cookie';
import Poll from '~/lib/utils/poll';
import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { __, s__ } from '~/locale';
import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
import TreeWorker from '../workers/tree_worker';
......@@ -43,6 +45,7 @@ export const setBaseConfig = ({ commit }, options) => {
endpoint,
endpointMetadata,
endpointBatch,
endpointCoverage,
projectPath,
dismissEndpoint,
showSuggestPopover,
......@@ -52,6 +55,7 @@ export const setBaseConfig = ({ commit }, options) => {
endpoint,
endpointMetadata,
endpointBatch,
endpointCoverage,
projectPath,
dismissEndpoint,
showSuggestPopover,
......@@ -170,6 +174,26 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
.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) => {
const fileHash = lineCode.split('_')[0];
commit(types.SET_HIGHLIGHTED_ROW, lineCode);
......
import { __, n__ } from '~/locale';
import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants';
export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW_TYPE;
......@@ -98,6 +99,29 @@ export const allBlobs = (state, getters) =>
export const getCommentFormForDiffFile = state => 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 {number}
......
......@@ -17,6 +17,7 @@ export default () => ({
commit: null,
startVersion: null,
diffFiles: [],
coverageFiles: {},
mergeRequestDiffs: [],
mergeRequestDiff: null,
diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType,
......
......@@ -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_BATCH = 'SET_DIFF_DATA_BATCH';
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 TOGGLE_LINE_HAS_FORM = 'TOGGLE_LINE_HAS_FORM';
export const ADD_CONTEXT_LINES = 'ADD_CONTEXT_LINES';
......
......@@ -16,6 +16,7 @@ export default {
endpoint,
endpointMetadata,
endpointBatch,
endpointCoverage,
projectPath,
dismissEndpoint,
showSuggestPopover,
......@@ -25,6 +26,7 @@ export default {
endpoint,
endpointMetadata,
endpointBatch,
endpointCoverage,
projectPath,
dismissEndpoint,
showSuggestPopover,
......@@ -69,6 +71,10 @@ export default {
});
},
[types.SET_COVERAGE_DATA](state, coverageFiles) {
Object.assign(state, { coverageFiles });
},
[types.RENDER_FILE](state, file) {
Object.assign(file, {
renderIt: true,
......
......@@ -29,3 +29,15 @@
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;
$dark-hll-bg: #373b41;
$dark-over-bg: #9f9ab5;
$dark-expanded-bg: #3e3e3e;
$dark-coverage: #b5bd68;
$dark-no-coverage: #de935f;
$dark-c: #969896;
$dark-err: #c66;
$dark-k: #b294bb;
......@@ -124,12 +126,18 @@ $dark-il: #de935f;
}
td.diff-line-num.hll:not(.empty-cell),
td.line-coverage.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: $dark-diff-not-empty-bg;
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,
.line-coverage.new,
.line_content.new {
@include diff-background($dark-new-bg, $dark-new-idiff, $dark-border);
......@@ -140,6 +148,7 @@ $dark-il: #de935f;
}
.diff-line-num.old,
.line-coverage.old,
.line_content.old {
@include diff-background($dark-old-bg, $dark-old-idiff, $dark-border);
......@@ -168,6 +177,7 @@ $dark-il: #de935f;
&:not(.diff-expanded) + .diff-expanded,
&.diff-expanded + .line_holder:not(.diff-expanded) {
> .diff-line-num,
> .line-coverage,
> .line_content {
border-top: 1px solid $black;
}
......@@ -175,6 +185,7 @@ $dark-il: #de935f;
&.diff-expanded {
> .diff-line-num,
> .line-coverage,
> .line_content {
background: $dark-expanded-bg;
border-color: $dark-expanded-bg;
......
......@@ -17,6 +17,8 @@ $monokai-diff-border: #808080;
$monokai-highlight-bg: #ffe792;
$monokai-over-bg: #9f9ab5;
$monokai-expanded-bg: #3e3e3e;
$monokai-coverage: #a6e22e;
$monokai-no-coverage: #fd971f;
$monokai-new-bg: rgba(166, 226, 46, 0.1);
$monokai-new-idiff: rgba(166, 226, 46, 0.15);
......@@ -124,12 +126,18 @@ $monokai-gi: #a6e22e;
}
td.diff-line-num.hll:not(.empty-cell),
td.line-coverage.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: $monokai-line-empty-bg;
border-color: $monokai-line-empty-border;
}
.line-coverage {
@include line-coverage-border-color($monokai-coverage, $monokai-no-coverage);
}
.diff-line-num.new,
.line-coverage.new,
.line_content.new {
@include diff-background($monokai-new-bg, $monokai-new-idiff, $monokai-diff-border);
......@@ -140,6 +148,7 @@ $monokai-gi: #a6e22e;
}
.diff-line-num.old,
.line-coverage.old,
.line_content.old {
@include diff-background($monokai-old-bg, $monokai-old-idiff, $monokai-diff-border);
......@@ -168,6 +177,7 @@ $monokai-gi: #a6e22e;
&:not(.diff-expanded) + .diff-expanded,
&.diff-expanded + .line_holder:not(.diff-expanded) {
> .diff-line-num,
> .line-coverage,
> .line_content {
border-top: 1px solid $black;
}
......@@ -175,6 +185,7 @@ $monokai-gi: #a6e22e;
&.diff-expanded {
> .diff-line-num,
> .line-coverage,
> .line_content {
background: $monokai-expanded-bg;
border-color: $monokai-expanded-bg;
......
......@@ -51,6 +51,15 @@
@include match-line;
}
.line-coverage {
@include line-coverage-border-color($green-500, $orange-500);
&.old,
&.new {
background-color: $white-normal;
}
}
.diff-line-num {
&.old {
a {
......@@ -83,6 +92,7 @@
&:not(.diff-expanded) + .diff-expanded,
&.diff-expanded + .line_holder:not(.diff-expanded) {
> .diff-line-num,
> .line-coverage,
> .line_content {
border-top: 1px solid $none-expanded-border;
}
......@@ -90,6 +100,7 @@
&.diff-expanded {
> .diff-line-num,
> .line-coverage,
> .line_content {
background: $none-expanded-bg;
border-color: $none-expanded-bg;
......
......@@ -21,6 +21,8 @@ $solarized-dark-highlight: #094554;
$solarized-dark-hll-bg: #174652;
$solarized-dark-over-bg: #9f9ab5;
$solarized-dark-expanded-bg: #010d10;
$solarized-dark-coverage: #859900;
$solarized-dark-no-coverage: #cb4b16;
$solarized-dark-c: #586e75;
$solarized-dark-err: #93a1a1;
$solarized-dark-g: #93a1a1;
......@@ -128,12 +130,18 @@ $solarized-dark-il: #2aa198;
}
td.diff-line-num.hll:not(.empty-cell),
td.line-coverage.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: $solarized-dark-hll-bg;
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,
.line-coverage.new,
.line_content.new {
@include diff-background($solarized-dark-new-bg, $solarized-dark-new-idiff, $solarized-dark-border);
......@@ -144,6 +152,7 @@ $solarized-dark-il: #2aa198;
}
.diff-line-num.old,
.line-coverage.old,
.line_content.old {
@include diff-background($solarized-dark-old-bg, $solarized-dark-old-idiff, $solarized-dark-border);
......@@ -172,6 +181,7 @@ $solarized-dark-il: #2aa198;
&:not(.diff-expanded) + .diff-expanded,
&.diff-expanded + .line_holder:not(.diff-expanded) {
> .diff-line-num,
> .line-coverage,
> .line_content {
border-top: 1px solid $black;
}
......@@ -179,6 +189,7 @@ $solarized-dark-il: #2aa198;
&.diff-expanded {
> .diff-line-num,
> .line-coverage,
> .line_content {
background: $solarized-dark-expanded-bg;
border-color: $solarized-dark-expanded-bg;
......
......@@ -23,6 +23,8 @@ $solarized-light-hll-bg: #ddd8c5;
$solarized-light-over-bg: #ded7fc;
$solarized-light-expanded-border: #d2cdbd;
$solarized-light-expanded-bg: #ece6d4;
$solarized-light-coverage: #859900;
$solarized-light-no-coverage: #cb4b16;
$solarized-light-c: #93a1a1;
$solarized-light-err: #586e75;
$solarized-light-g: #586e75;
......@@ -135,12 +137,18 @@ $solarized-light-il: #2aa198;
}
td.diff-line-num.hll:not(.empty-cell),
td.line-coverage.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: $solarized-light-hll-bg;
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,
.line-coverage.new,
.line_content.new {
@include diff-background($solarized-light-new-bg,
$solarized-light-new-idiff, $solarized-light-border);
......@@ -152,6 +160,7 @@ $solarized-light-il: #2aa198;
}
.diff-line-num.old,
.line-coverage.old,
.line_content.old {
@include diff-background($solarized-light-old-bg, $solarized-light-old-idiff, $solarized-light-border);
......@@ -180,6 +189,7 @@ $solarized-light-il: #2aa198;
&:not(.diff-expanded) + .diff-expanded,
&.diff-expanded + .line_holder:not(.diff-expanded) {
> .diff-line-num,
> .line-coverage,
> .line_content {
border-top: 1px solid $solarized-light-expanded-border;
}
......@@ -187,6 +197,7 @@ $solarized-light-il: #2aa198;
&.diff-expanded {
> .diff-line-num,
> .line-coverage,
> .line_content {
background: $solarized-light-expanded-bg;
border-color: $solarized-light-expanded-bg;
......
......@@ -151,6 +151,7 @@ pre.code,
&:not(.diff-expanded) + .diff-expanded,
&.diff-expanded + .line_holder:not(.diff-expanded) {
> .diff-line-num,
> .line-coverage,
> .line_content {
border-top: 1px solid $white-expanded-border;
}
......@@ -158,6 +159,7 @@ pre.code,
&.diff-expanded {
> .diff-line-num,
> .line-coverage,
> .line_content {
background: $white-expanded-bg;
border-color: $white-expanded-bg;
......@@ -197,6 +199,22 @@ pre.code,
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
......
......@@ -514,6 +514,10 @@ table.code {
position: absolute;
left: 0.5em;
}
&.with-coverage::before {
left: 0;
}
}
&.new {
......@@ -522,6 +526,10 @@ table.code {
position: absolute;
left: 0.5em;
}
&.with-coverage::before {
left: 0;
}
}
}
}
......
......@@ -14,7 +14,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
skip_before_action :merge_request, only: [:index, :bulk_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_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 :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
......@@ -63,6 +63,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar')
@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
@coverage_path = coverage_reports_project_merge_request_path(@project, @merge_request, format: :json) if @merge_request.has_coverage_reports?
set_pipeline_variables
......@@ -131,6 +132,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
reports_response(@merge_request.compare_test_reports)
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
if @merge_request.has_exposed_artifacts?
reports_response(@merge_request.find_exposed_artifacts)
......
......@@ -916,6 +916,14 @@ module Ci
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
job_artifacts.with_reports
end
......
......@@ -11,6 +11,7 @@ module Ci
NotSupportedAdapterError = Class.new(StandardError)
TEST_REPORT_FILE_TYPES = %w[junit].freeze
COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze
NON_ERASABLE_FILE_TYPES = %w[trace].freeze
DEFAULT_FILE_NAMES = {
archive: nil,
......@@ -29,7 +30,8 @@ module Ci
performance: 'performance.json',
metrics: 'metrics.txt',
lsif: 'lsif.json',
dotenv: '.env'
dotenv: '.env',
cobertura: 'cobertura-coverage.xml'
}.freeze
INTERNAL_TYPES = {
......@@ -45,6 +47,7 @@ module Ci
network_referee: :gzip,
lsif: :gzip,
dotenv: :gzip,
cobertura: :gzip,
# All these file formats use `raw` as we need to store them uncompressed
# for Frontend to fetch the files and do analysis
......@@ -92,6 +95,10 @@ module Ci
with_file_types(TEST_REPORT_FILE_TYPES)
end
scope :coverage_reports, -> do
with_file_types(COVERAGE_REPORT_FILE_TYPES)
end
scope :erasable, -> do
types = self.file_types.reject { |file_type| NON_ERASABLE_FILE_TYPES.include?(file_type) }.values
......@@ -121,7 +128,8 @@ module Ci
metrics_referee: 13, ## runner referees
network_referee: 14, ## runner referees
lsif: 15, # LSIF data for code navigation
dotenv: 16
dotenv: 16,
cobertura: 17
}
enum file_format: {
......
......@@ -820,6 +820,14 @@ module Ci
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?
complete? && builds.latest.with_exposed_artifacts.exists?
end
......
......@@ -567,6 +567,10 @@ class MergeRequest < ApplicationRecord
diffs.modified_paths
end
def new_paths
diffs.diff_files.map(&:new_path)
end
def diff_base_commit
if merge_request_diff.persisted?
merge_request_diff.base_commit
......@@ -1295,6 +1299,24 @@ class MergeRequest < ApplicationRecord
compare_reports(Ci::CompareTestReportsService)
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?
return false unless Feature.enabled?(:ci_expose_arbitrary_artifacts_in_mr, default_enabled: true)
......@@ -1318,7 +1340,7 @@ class MergeRequest < ApplicationRecord
# issue: https://gitlab.com/gitlab-org/gitlab/issues/34224
def compare_reports(service_class, current_user = nil)
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)
raise InvalidateReactiveCache
end
......@@ -1335,7 +1357,7 @@ class MergeRequest < ApplicationRecord
raise NameError, service_class unless service_class < Ci::CompareReportsBaseService
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
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 @@
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_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,
current_user_data: @current_user_data,
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],
post :rebase
get :test_reports
get :exposed_artifacts
get :coverage_reports
scope constraints: ->(req) { req.format == :json }, as: :json do
get :commits
......
......@@ -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`. |
| [`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`. |
| [`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. |
| [`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. |
......@@ -2286,6 +2286,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 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)**
> 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:
| [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. |
| [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)**
......
---
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 {
<template>
<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>
</td>
</tr>
......
......@@ -34,11 +34,11 @@ export default {
<template>
<tr :class="className" class="notes_holder">
<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>
</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>
</td>
</tr>
......
......@@ -14,7 +14,7 @@ module Gitlab
ALLOWED_KEYS =
%i[junit codequality sast dependency_scanning container_scanning
dast performance license_management license_scanning metrics lsif
dotenv].freeze
dotenv cobertura].freeze
attributes ALLOWED_KEYS
......@@ -35,6 +35,7 @@ module Gitlab
validates :metrics, array_of_strings_or_string: true
validates :lsif, array_of_strings_or_string: true
validates :dotenv, array_of_strings_or_string: true
validates :cobertura, array_of_strings_or_string: true
end
end
......
......@@ -9,7 +9,8 @@ module Gitlab
def self.parsers
{
junit: ::Gitlab::Ci::Parsers::Test::Junit
junit: ::Gitlab::Ci::Parsers::Test::Junit,
cobertura: ::Gitlab::Ci::Parsers::Coverage::Cobertura
}
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 ""
msgid "An error occurred while enabling Service Desk."
msgstr ""
msgid "An error occurred while fetching coverage reports."
msgstr ""
msgid "An error occurred while fetching environments."
msgstr ""
......@@ -13325,6 +13328,9 @@ msgstr ""
msgid "No template"
msgstr ""
msgid "No test coverage"
msgstr ""
msgid "No thanks, don't show this again"
msgstr ""
......@@ -19472,6 +19478,11 @@ msgstr ""
msgid "Test coverage parsing"
msgstr ""
msgid "Test coverage: %d hit"
msgid_plural "Test coverage: %d hits"
msgstr[0] ""
msgstr[1] ""
msgid "Test failed."
msgstr ""
......
......@@ -984,6 +984,136 @@ describe Projects::MergeRequestsController do
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
let(:merge_request) do
create(:merge_request,
......
......@@ -311,6 +311,12 @@ FactoryBot.define do
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
artifacts_expire_at { 1.minute.ago }
end
......
......@@ -129,6 +129,36 @@ FactoryBot.define do
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
file_type { :codequality }
file_format { :raw }
......
......@@ -67,6 +67,14 @@ FactoryBot.define do
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
status { :success }
......
......@@ -121,6 +121,18 @@ FactoryBot.define do
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
after(:build) do |merge_request|
merge_request.head_pipeline = build(
......
......@@ -190,7 +190,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do
def find_line(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
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', () => {
endpoint: TEST_ENDPOINT,
endpointMetadata: `${TEST_HOST}/diff/endpointMetadata`,
endpointBatch: `${TEST_HOST}/diff/endpointBatch`,
endpointCoverage: `${TEST_HOST}/diff/endpointCoverage`,
projectPath: 'namespace/project',
currentUser: {},
changesEmptyStateIllustration: '',
......@@ -95,6 +96,7 @@ describe('diffs/components/app', () => {
jest.spyOn(wrapper.vm, 'fetchDiffFiles').mockImplementation(fetchResolver);
jest.spyOn(wrapper.vm, 'fetchDiffFilesMeta').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, 'startRenderDiffsQueue').mockImplementation(() => {});
jest.spyOn(wrapper.vm, 'unwatchDiscussions').mockImplementation(() => {});
......@@ -250,6 +252,7 @@ describe('diffs/components/app', () => {
expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesMeta).not.toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled();
expect(wrapper.vm.fetchCoverageFiles).toHaveBeenCalled();
expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled();
expect(wrapper.vm.diffFilesLength).toEqual(100);
expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled();
......@@ -269,6 +272,7 @@ describe('diffs/components/app', () => {
expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled();
expect(wrapper.vm.fetchCoverageFiles).toHaveBeenCalled();
expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled();
expect(wrapper.vm.diffFilesLength).toEqual(100);
expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled();
......@@ -286,6 +290,7 @@ describe('diffs/components/app', () => {
expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled();
expect(wrapper.vm.fetchCoverageFiles).toHaveBeenCalled();
expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled();
expect(wrapper.vm.diffFilesLength).toEqual(100);
expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled();
......
......@@ -12,6 +12,7 @@ describe('InlineDiffTableRow', () => {
vm = createComponentWithStore(Vue.extend(InlineDiffTableRow), createStore(), {
line: thisLine,
fileHash: diffFileMockData.file_hash,
filePath: diffFileMockData.file_path,
contextLinesPath: 'contextLinesPath',
isHighlighted: false,
}).$mount();
......@@ -39,4 +40,64 @@ describe('InlineDiffTableRow', () => {
.then(done)
.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', () => {
vm = createComponentWithStore(Vue.extend(ParallelDiffTableRow), createStore(), {
line: thisLine,
fileHash: diffFileMockData.file_hash,
filePath: diffFileMockData.file_path,
contextLinesPath: 'contextLinesPath',
isHighlighted: false,
}).$mount();
......@@ -52,6 +53,7 @@ describe('ParallelDiffTableRow', () => {
vm = createComponentWithStore(Vue.extend(ParallelDiffTableRow), createStore(), {
line: thisLine,
fileHash: diffFileMockData.file_hash,
filePath: diffFileMockData.file_path,
contextLinesPath: 'contextLinesPath',
isHighlighted: false,
}).$mount();
......@@ -81,5 +83,65 @@ describe('ParallelDiffTableRow', () => {
.then(done)
.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, {
fetchDiffFiles,
fetchDiffFilesBatch,
fetchDiffFilesMeta,
fetchCoverageFiles,
assignDiscussionsToDiff,
removeDiscussionsFromDiff,
startRenderDiffsQueue,
......@@ -73,6 +74,7 @@ describe('DiffsStoreActions', () => {
const endpoint = '/diffs/set/endpoint';
const endpointMetadata = '/diffs/set/endpoint/metadata';
const endpointBatch = '/diffs/set/endpoint/batch';
const endpointCoverage = '/diffs/set/coverage_reports';
const projectPath = '/root/project';
const dismissEndpoint = '/-/user_callouts';
const showSuggestPopover = false;
......@@ -84,6 +86,7 @@ describe('DiffsStoreActions', () => {
endpoint,
endpointBatch,
endpointMetadata,
endpointCoverage,
projectPath,
dismissEndpoint,
showSuggestPopover,
......@@ -93,6 +96,7 @@ describe('DiffsStoreActions', () => {
endpoint: '',
endpointBatch: '',
endpointMetadata: '',
endpointCoverage: '',
projectPath: '',
dismissEndpoint: '',
showSuggestPopover: true,
......@@ -105,6 +109,7 @@ describe('DiffsStoreActions', () => {
endpoint,
endpointMetadata,
endpointBatch,
endpointCoverage,
projectPath,
dismissEndpoint,
showSuggestPopover,
......@@ -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', () => {
it('should mark currently selected diff and set lineHash and fileHash of highlightedRow', () => {
testAction(setHighlightedRow, 'ABC_123', {}, [
......
......@@ -282,4 +282,34 @@ describe('Diffs Module Getters', () => {
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', () => {
});
});
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', () => {
it('should set diff view type properly', () => {
const state = {};
......
......@@ -45,6 +45,7 @@ describe Gitlab::Ci::Config::Entry::Reports do
:performance | 'performance.json'
:lsif | 'lsif.json'
:dotenv | 'build.dotenv'
:cobertura | 'cobertura-coverage.xml'
end
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
describe '.fabricate!' do
subject { described_class.fabricate!(file_type) }
context 'when file_type exists' do
context 'when file_type is junit' do
let(:file_type) { 'junit' }
it 'fabricates the class' do
......@@ -14,6 +14,14 @@ describe Gitlab::Ci::Parsers do
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
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
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
subject { build.report_artifacts }
......
......@@ -70,6 +70,22 @@ describe Ci::JobArtifact do
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
subject { described_class.erasable }
......
......@@ -344,9 +344,9 @@ describe Ci::Pipeline, :mailer do
end
describe '.with_reports' do
subject { described_class.with_reports(Ci::JobArtifact.test_reports) }
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) }
it 'selects the pipeline' do
......@@ -354,7 +354,19 @@ describe Ci::Pipeline, :mailer do
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
subject { described_class.with_reports(Ci::JobArtifact.test_reports) }
let!(:pipeline_without_report) { create(:ci_empty_pipeline) }
it 'does not select the pipeline' do
......@@ -2730,6 +2742,43 @@ describe Ci::Pipeline, :mailer do
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
let!(:build_job1) { 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
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
let!(:merge_request) { create(:merge_request) }
......@@ -1581,6 +1591,24 @@ describe MergeRequest do
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
let(:project) { create(:project, :repository) }
let(:merge_request) { create(:merge_request, source_project: project) }
......@@ -1663,6 +1691,60 @@ describe MergeRequest do
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
subject { merge_request.compare_test_reports }
......
......@@ -36,7 +36,8 @@ describe Ci::RetryBuildService do
job_artifacts_performance job_artifacts_lsif
job_artifacts_codequality job_artifacts_metrics scheduled_at
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 =
%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