Commit 876d90a2 authored by Filipa Lacerda's avatar Filipa Lacerda

Transform code quality component into reusable report collapsible section

parent b1606d65
......@@ -777,7 +777,10 @@
}
.mr-widget-code-quality {
padding-top: $gl-padding-top;
ci-status-icon-warning svg {
fill: $theme-gray-600;
}
.code-quality-container {
border-top: 1px solid $gray-darker;
......@@ -788,15 +791,25 @@
.mr-widget-code-quality-list {
list-style: none;
padding: 4px 36px;
padding: 0px 12px;
margin: 0;
line-height: $code_line_height;
li.success {
.mr-widget-code-quality-icon {
margin-right: 12px;
fill: currentColor;
svg {
width: 10px;
height: 10px;
}
}
.success {
color: $green-500;
}
li.failed {
.failed {
color: $red-500;
}
}
......
<script>
export default {
name: 'MRWidgetCodeQualityIssues',
props: {
issues: {
type: Array,
required: true,
},
type: {
type: String,
required: true,
},
},
};
</script>
<template>
<ul class="mr-widget-code-quality-list">
<li
class="commit-sha"
:class="{
failed: type === 'failed',
success: type === 'success'
}
"v-for="issue in issues">
<i
class="fa"
:class="{
'fa-minus': type === 'failed',
'fa-plus': type === 'success'
}"
aria-hidden="true">
</i>
<span>
<span v-if="type === 'success'">Fixed:</span>
{{issue.check_name}}
{{issue.location.path}}
{{issue.location.positions}}
{{issue.location.lines}}
</span>
</li>
</ul>
</template>
......@@ -2,20 +2,44 @@
import statusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import '~/lib/utils/text_utility';
import issuesBlock from './mr_widget_code_quality_issues.vue';
import issuesBlock from './mr_widget_report_issues.vue';
export default {
name: 'MRWidgetCodeQuality',
props: {
mr: {
type: Object,
// security | codequality
type: {
type: String,
required: true,
},
service: {
type: Object,
// loading | success | error
status: {
type: String,
required: true,
},
loadingText: {
type: String,
required: true,
},
errorText: {
type: String,
required: true,
},
successText: {
type: String,
required: true,
},
unresolvedIssues: {
type: Array,
required: false,
default: () => [],
},
resolvedIssues: {
type: Array,
required: false,
default: () => [],
},
},
components: {
......@@ -28,100 +52,37 @@ export default {
return {
collapseText: 'Expand',
isCollapsed: true,
isLoading: false,
loadingFailed: false,
};
},
computed: {
status() {
if (this.loadingFailed || this.mr.codeclimateMetrics.newIssues.length) {
return 'failed';
}
return 'success';
isLoading() {
return this.status === 'loading';
},
hasNoneIssues() {
const { newIssues, resolvedIssues } = this.mr.codeclimateMetrics;
return !newIssues.length && !resolvedIssues.length;
loadingFailed() {
return this.status === 'error';
},
hasIssues() {
const { newIssues, resolvedIssues } = this.mr.codeclimateMetrics;
return newIssues.length || resolvedIssues.length;
isSuccess() {
return this.status === 'success';
},
codeText() {
const { newIssues, resolvedIssues } = this.mr.codeclimateMetrics;
let newIssuesText;
let resolvedIssuesText;
let text = [];
if (this.hasNoneIssues) {
text.push('No changes to code quality');
} else if (this.hasIssues) {
if (newIssues.length) {
newIssuesText = ` degraded on ${newIssues.length} ${this.pointsText(newIssues)}`;
}
if (resolvedIssues.length) {
resolvedIssuesText = ` improved on ${resolvedIssues.length} ${this.pointsText(resolvedIssues)}`;
}
const connector = (newIssues.length > 0 && resolvedIssues.length > 0) ? ' and' : null;
text = ['Code quality'];
if (resolvedIssuesText) {
text.push(resolvedIssuesText);
}
if (connector) {
text.push(connector);
}
if (newIssuesText) {
text.push(newIssuesText);
}
statusIconName() {
if (this.loadingFailed || this.unresolvedIssues.length) {
return 'warning';
}
return text.join('');
return 'success';
},
hasIssues() {
return this.unresolvedIssues.length || this.resolvedIssues.length;
},
},
methods: {
pointsText(issues) {
return gl.text.pluralize('point', issues.length);
},
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
const text = this.isCollapsed ? 'Expand' : 'Collapse';
this.collapseText = text;
},
handleError() {
this.isLoading = false;
this.loadingFailed = true;
},
},
created() {
const { head_path, base_path } = this.mr.codeclimate;
this.isLoading = true;
Promise.all([
this.service.fetchCodeclimate(head_path)
.then(resp => resp.json()),
this.service.fetchCodeclimate(base_path)
.then(resp => resp.json()),
])
.then((values) => {
this.mr.compareCodeclimateMetrics(values[0], values[1]);
this.isLoading = false;
})
.catch(() => this.handleError());
},
};
</script>
......@@ -132,23 +93,21 @@ export default {
v-if="isLoading"
class="media">
<div class="mr-widget-icon">
<i
class="fa fa-spinner fa-spin"
aria-hidden="true">
</i>
<loading-icon />
</div>
<div class="media-body">
Loading codeclimate report
{{loadingText}}
</div>
</div>
<div
v-else-if="!isLoading && !loadingFailed"
v-else-if="isSuccess"
class="media">
<status-icon :status="status" />
<status-icon :status="statusIconName" />
<div class="media-body space-children">
<span class="js-code-text">
{{codeText}}
{{successText}}
</span>
<button
......@@ -159,32 +118,34 @@ export default {
{{collapseText}}
</button>
</div>
</div>
<div
class="code-quality-container"
v-if="hasIssues"
v-show="!isCollapsed">
<issues-block
class="js-mr-code-resolved-issues"
v-if="mr.codeclimateMetrics.resolvedIssues.length"
type="success"
:issues="mr.codeclimateMetrics.resolvedIssues"
/>
</div>
<issues-block
class="js-mr-code-new-issues"
v-if="mr.codeclimateMetrics.newIssues.length"
type="failed"
:issues="mr.codeclimateMetrics.newIssues"
/>
</div>
<div
class="code-quality-container"
v-if="hasIssues"
v-show="!isCollapsed">
<issues-block
class="js-mr-code-resolved-issues"
v-if="resolvedIssues.length"
:type="type"
status="success"
:issues="resolvedIssues"
/>
<issues-block
class="js-mr-code-new-issues"
v-if="unresolvedIssues.length"
:type="type"
status="failed"
:issues="unresolvedIssues"
/>
</div>
<div
v-else-if="loadingFailed"
class="media">
<status-icon status="failed" />
<div class="media-body">
Failed to load codeclimate report
{{errorText}}
</div>
</div>
</section>
......
<script>
import { spriteIcon } from '~/lib/utils/common_utils';
export default {
name: 'mrWidgetReportIssues',
props: {
issues: {
type: Array,
required: true,
},
// security || codequality
type: {
type: String,
required: true,
},
// failed || success
status: {
type: String,
required: true,
},
},
computed: {
icon() {
return this.isStatusFailed ? spriteIcon('cut') : spriteIcon('plus');
},
isStatusFailed() {
return this.status === 'failed';
},
isStatusSuccess() {
return this.status === 'success';
},
isTypeQuality() {
return this.type === 'codequality';
},
isTypeSecurity() {
return this.type === 'security';
},
},
};
</script>
<template>
<ul class="mr-widget-code-quality-list">
<li
:class="{
failed: isStatusFailed,
success: isStatusSuccess
}
"v-for="issue in issues">
<span
class="mr-widget-code-quality-icon"
v-html="icon">
</span>
<template v-if="isStatusSuccess && isTypeQuality">Fixed:</template>
<template v-if="isTypeSecurity && issue.priority">{{issue.priority}}:</template>
{{issue.name}}
<template v-if="issue.path">
in
<a
:href="issue.urlPath"
target="_blank"
rel="noopener noreferrer nofollow">
{{issue.path}}<template v-if="issue.line">:{{issue.line}}</template>
</a>
</template>
</li>
</ul>
</template>
......@@ -2,7 +2,7 @@ import CEWidgetOptions from '~/vue_merge_request_widget/mr_widget_options';
import WidgetApprovals from './components/approvals/mr_widget_approvals';
import GeoSecondaryNode from './components/states/mr_widget_secondary_geo_node';
import RebaseState from './components/states/mr_widget_rebase.vue';
import WidgetCodeQuality from './components/mr_widget_code_quality.vue';
import collapsibleSection from './components/mr_widget_report_collapsible_section.vue';
export default {
extends: CEWidgetOptions,
......@@ -10,7 +10,15 @@ export default {
'mr-widget-approvals': WidgetApprovals,
'mr-widget-geo-secondary-node': GeoSecondaryNode,
'mr-widget-rebase': RebaseState,
'mr-widget-code-quality': WidgetCodeQuality,
collapsibleSection,
},
data() {
return {
isLoadingCodequality: false,
isLoadingSecurity: false,
loadingCodequalityFailed: false,
loadingSecurityFailed: false,
};
},
computed: {
shouldRenderApprovals() {
......@@ -20,6 +28,116 @@ export default {
const { codeclimate } = this.mr;
return codeclimate && codeclimate.head_path && codeclimate.base_path;
},
shouldRenderSecurityReport() {
return this.mr.security && this.mr.security.sast;
},
codequalityText() {
const { newIssues, resolvedIssues } = this.mr.codeclimateMetrics;
let newIssuesText;
let resolvedIssuesText;
let text = [];
if (!newIssues.length && !resolvedIssues.length) {
text.push('No changes to code quality');
} else if (newIssues.length || resolvedIssues.length) {
if (newIssues.length) {
newIssuesText = ` degraded on ${newIssues.length} ${this.pointsText(newIssues)}`;
}
if (resolvedIssues.length) {
resolvedIssuesText = ` improved on ${resolvedIssues.length} ${this.pointsText(resolvedIssues)}`;
}
const connector = (newIssues.length > 0 && resolvedIssues.length > 0) ? ' and' : null;
text = ['Code quality'];
if (resolvedIssuesText) {
text.push(resolvedIssuesText);
}
if (connector) {
text.push(connector);
}
if (newIssuesText) {
text.push(newIssuesText);
}
}
return text.join('');
},
securityText() {
const { securityReport } = this.mr;
if (securityReport.length) {
const vulnerabilitiesText = gl.text.pluralize('vulnerabilities', securityReport.length);
return `${securityReport.length} security ${vulnerabilitiesText} detected`;
}
return 'No security vulnerabilities detected';
},
codequalityStatus() {
if (this.isLoadingCodequality) {
return 'loading';
} else if (this.loadingCodequalityFailed) {
return 'error';
}
return 'success';
},
securityStatus() {
if (this.isLoadingSecurity) {
return 'loading';
} else if (this.loadingSecurityFailed) {
return 'error';
}
return 'success';
},
},
methods: {
fetchCodeQuality() {
const { head_path, base_path } = this.mr.codeclimate;
this.isLoadingCodequality = true;
Promise.all([
this.service.fetchReport(head_path),
this.service.fetchReport(base_path),
])
.then((values) => {
this.mr.compareCodeclimateMetrics(values[0], values[1]);
this.isLoadingCodequality = false;
})
.catch(() => {
this.isLoadingCodequality = false;
this.loadingCodequalityFailed = true;
});
},
fetchSecurity() {
this.isLoadingSecurity = true;
this.service.fetchReport(this.mr.security.sast)
.then((data) => {
this.mr.setSecurityReport(data);
this.isLoadingSecurity = false;
})
.catch(() => {
this.isLoadingSecurity = false;
this.loadingSecurityFailed = true;
});
},
pointsText(issues) {
return gl.text.pluralize('point', issues.length);
},
},
created() {
if (this.shouldRenderCodeQuality) {
this.fetchCodeQuality();
}
if (this.shouldRenderSecurityReport) {
this.fetchSecurity();
}
},
template: `
<div class="mr-state-widget prepend-top-default">
......@@ -35,10 +153,25 @@ export default {
v-if="mr.approvalsRequired"
:mr="mr"
:service="service" />
<mr-widget-code-quality
<collapsible-section
class="js-codequality-widget"
v-if="shouldRenderCodeQuality"
:mr="mr"
:service="service"
type="codequality"
:status="codequalityStatus"
loadingText="Loading codeclimate report"
errorText="Failed to load codeclimate report"
:successText="codequalityText"
:unresolvedIssues="mr.codeclimateMetrics.newIssues"
:resolvedIssues="mr.codeclimateMetrics.resolvedIssues"
/>
<collapsible-section
v-if="shouldRenderSecurityReport"
type="security"
:status="securityStatus"
loadingText="Loading security report"
errorText="Failed to load security report"
:successText="securityText"
:unresolvedIssues="mr.securityReport"
/>
<div class="mr-widget-section">
<component
......
......@@ -35,7 +35,7 @@ export default class MRWidgetService extends CEWidgetService {
return this.rebaseResource.save();
}
fetchCodeclimate(endpoint) { // eslint-disable-line
return Vue.http.get(endpoint);
fetchReport(endpoint) { // eslint-disable-line
return Vue.http.get(endpoint).then(res => res.json());
}
}
......@@ -4,6 +4,7 @@ export default class MergeRequestStore extends CEMergeRequestStore {
constructor(data) {
super(data);
this.initCodeclimate(data);
this.initSecurityReport(data);
}
setData(data) {
......@@ -56,12 +57,82 @@ export default class MergeRequestStore extends CEMergeRequestStore {
};
}
compareCodeclimateMetrics(headIssues, baseIssues) {
this.codeclimateMetrics.newIssues = this.filterByFingerprint(headIssues, baseIssues);
this.codeclimateMetrics.resolvedIssues = this.filterByFingerprint(baseIssues, headIssues);
initSecurityReport(data) {
this.security = data.security;
this.securityReport = [];
}
filterByFingerprint(firstArray, secondArray) { // eslint-disable-line
setSecurityReport(issues) {
this.securityReport = MergeRequestStore.parseIssues(issues);
}
// TODO: get changes from codequality MR
compareCodeclimateMetrics(headIssues, baseIssues, headBlobPath, baseBlobPath) {
const parsedHeadIssues = MergeRequestStore.parseIssues(headIssues, headBlobPath);
const parsedBaseIssues = MergeRequestStore.parseIssues(baseIssues, baseBlobPath);
this.codeclimateMetrics.newIssues = MergeRequestStore.filterByFingerprint(
parsedHeadIssues,
parsedBaseIssues,
);
this.codeclimateMetrics.resolvedIssues = MergeRequestStore.filterByFingerprint(
parsedBaseIssues,
parsedHeadIssues,
);
}
/**
* In order to reuse the same component we need
* to set both codequality and security issues to have the same data structure:
* [
* {
* name: String,
* priority: String,
* fingerprint: String,
* path: String,
* line: Number,
* urlPath: String
* }
* ]
* @param {array} issues
* @return {array}
*/
static parseIssues(issues, path) {
return issues.map((issue) => {
const parsedIssue = {
name: issue.check_name || issue.message,
...issue,
};
// code quality
if (issue.location) {
let parseCodeQualityUrl;
if (issue.location.path) {
parseCodeQualityUrl = `${path}/${issue.location.path}`;
parsedIssue.path = issue.location.path;
}
if (issue.location.lines && issue.location.lines.begin) {
parsedIssue.line = issue.location.lines.begin;
parsedIssue.urlPath = parseCodeQualityUrl += `#L${issue.location.lines.begin}`;
}
} else {
// security
let parsedSecurityUrl;
if (issue.file) {
parsedSecurityUrl = `${path}/${issue.file}`;
parsedIssue.path = issue.file;
}
if (issue.line) {
parsedIssue.urlPath = parsedSecurityUrl += `#L${issue.line}`;
}
}
return parsedIssue;
});
}
static filterByFingerprint(firstArray, secondArray) {
return firstArray.filter(item => !secondArray.find(el => el.fingerprint === item.fingerprint));
}
}
import Vue from 'vue';
import mrWidgetCodeQualityIssues from 'ee/vue_merge_request_widget/components/mr_widget_code_quality_issues.vue';
describe('Merge Request Code Quality Issues', () => {
let vm;
let MRWidgetCodeQualityIssues;
let mountComponent;
beforeEach(() => {
MRWidgetCodeQualityIssues = Vue.extend(mrWidgetCodeQualityIssues);
mountComponent = props => new MRWidgetCodeQualityIssues({ propsData: props }).$mount();
});
describe('Renders provided list of issues', () => {
describe('with positions and lines', () => {
beforeEach(() => {
vm = mountComponent({
type: 'success',
issues: [{
check_name: 'foo',
location: {
path: 'bar',
positions: '81',
lines: '21',
},
}],
});
});
it('should render issue', () => {
expect(
vm.$el.querySelector('li span').textContent.trim().replace(/\s+/g, ''),
).toEqual('Fixed:foobar8121');
});
});
describe('without positions and lines', () => {
beforeEach(() => {
vm = mountComponent({
type: 'success',
issues: [{
check_name: 'foo',
location: {
path: 'bar',
},
}],
});
});
it('should render issue without position and lines', () => {
expect(
vm.$el.querySelector('li span').textContent.trim().replace(/\s+/g, ''),
).toEqual('Fixed:foobar');
});
});
describe('for type failed', () => {
beforeEach(() => {
vm = mountComponent({
type: 'failed',
issues: [{
check_name: 'foo',
location: {
path: 'bar',
positions: '81',
lines: '21',
},
}],
});
});
it('should render failed minus icon', () => {
expect(vm.$el.querySelector('li').classList.contains('failed')).toEqual(true);
expect(vm.$el.querySelector('li i').classList.contains('fa-minus')).toEqual(true);
});
});
describe('for type success', () => {
beforeEach(() => {
vm = mountComponent({
type: 'success',
issues: [{
check_name: 'foo',
location: {
path: 'bar',
positions: '81',
lines: '21',
},
}],
});
});
it('should render success plus icon', () => {
expect(vm.$el.querySelector('li').classList.contains('success')).toEqual(true);
expect(vm.$el.querySelector('li i').classList.contains('fa-plus')).toEqual(true);
});
});
});
});
import Vue from 'vue';
import mrWidgetCodeQuality from 'ee/vue_merge_request_widget/components/mr_widget_code_quality.vue';
import Store from 'ee/vue_merge_request_widget/stores/mr_widget_store';
import Service from 'ee/vue_merge_request_widget/services/mr_widget_service';
import mockData, { baseIssues, headIssues } from '../mock_data';
describe('Merge Request Code Quality', () => {
let vm;
let MRWidgetCodeQuality;
let store;
let mountComponent;
let service;
beforeEach(() => {
MRWidgetCodeQuality = Vue.extend(mrWidgetCodeQuality);
store = new Store(mockData);
service = new Service('');
mountComponent = props => new MRWidgetCodeQuality({ propsData: props }).$mount();
});
afterEach(() => {
vm.$destroy();
});
describe('when it is loading', () => {
beforeEach(() => {
vm = mountComponent({
mr: store,
service,
});
});
it('should render loading indicator', () => {
expect(vm.$el.textContent.trim()).toEqual('Loading codeclimate report');
});
});
describe('with successful request', () => {
const interceptor = (request, next) => {
if (request.url === 'head.json') {
next(request.respondWith(JSON.stringify(headIssues), {
status: 200,
}));
}
if (request.url === 'base.json') {
next(request.respondWith(JSON.stringify(baseIssues), {
status: 200,
}));
}
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
vm = mountComponent({
mr: store,
service,
});
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('should render provided data', (done) => {
setTimeout(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('Code quality improved on 1 point and degraded on 1 point');
done();
}, 0);
});
describe('text connector', () => {
it('should only render information about fixed issues', (done) => {
setTimeout(() => {
vm.mr.codeclimateMetrics.newIssues = [];
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('Code quality improved on 1 point');
done();
});
}, 0);
});
it('should only render information about added issues', (done) => {
setTimeout(() => {
vm.mr.codeclimateMetrics.resolvedIssues = [];
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('Code quality degraded on 1 point');
done();
});
}, 0);
});
});
describe('toggleCollapsed', () => {
it('toggles issues', (done) => {
setTimeout(() => {
vm.$el.querySelector('button').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.code-quality-container').geAttribute('style'),
).toEqual(null);
expect(
vm.$el.querySelector('button').textContent.trim(),
).toEqual('Collapse');
vm.$el.querySelector('button').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.code-quality-container').geAttribute('style'),
).toEqual('display: none;');
expect(
vm.$el.querySelector('button').textContent.trim(),
).toEqual('Expand');
});
});
done();
}, 0);
});
});
});
describe('with empty successful request', () => {
const emptyInterceptor = (request, next) => {
if (request.url === 'head.json') {
next(request.respondWith(JSON.stringify([]), {
status: 200,
}));
}
if (request.url === 'base.json') {
next(request.respondWith(JSON.stringify([]), {
status: 200,
}));
}
};
beforeEach(() => {
Vue.http.interceptors.push(emptyInterceptor);
vm = mountComponent({
mr: store,
service,
});
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, emptyInterceptor);
});
it('should render provided data', (done) => {
setTimeout(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('No changes to code quality');
done();
}, 0);
});
});
describe('with failed request', () => {
const errorInterceptor = (request, next) => {
if (request.url === 'head.json') {
next(request.respondWith(JSON.stringify([]), {
status: 500,
}));
}
if (request.url === 'base.json') {
next(request.respondWith(JSON.stringify([]), {
status: 500,
}));
}
};
beforeEach(() => {
Vue.http.interceptors.push(errorInterceptor);
vm = mountComponent({
mr: store,
service,
});
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, errorInterceptor);
});
it('should render error indicator', (done) => {
setTimeout(() => {
expect(vm.$el.textContent.trim()).toEqual('Failed to load codeclimate report');
done();
}, 0);
});
});
});
import Vue from 'vue';
import mrWidgetCodeQuality from 'ee/vue_merge_request_widget/components/mr_widget_report_collapsible_section.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
import { codequalityParsedIssues } from '../mock_data';
describe('Merge Request collapsible section', () => {
let vm;
let MRWidgetCodeQuality;
beforeEach(() => {
MRWidgetCodeQuality = Vue.extend(mrWidgetCodeQuality);
});
afterEach(() => {
vm.$destroy();
});
describe('when it is loading', () => {
it('should render loading indicator', () => {
vm = mountComponent(MRWidgetCodeQuality, {
type: 'codequality',
status: 'loading',
loadingText: 'Loading codeclimate report',
errorText: 'foo',
successText: 'Code quality improved on 1 point and degraded on 1 point',
});
expect(vm.$el.textContent.trim()).toEqual('Loading codeclimate report');
});
});
describe('with successful request', () => {
it('should render provided data', () => {
vm = mountComponent(MRWidgetCodeQuality, {
type: 'codequality',
status: 'success',
loadingText: 'Loading codeclimate report',
errorText: 'foo',
successText: 'Code quality improved on 1 point and degraded on 1 point',
resolvedIssues: codequalityParsedIssues,
});
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('Code quality improved on 1 point and degraded on 1 point');
expect(
vm.$el.querySelectorAll('.js-mr-code-resolved-issues li').length,
).toEqual(codequalityParsedIssues.length);
});
describe('toggleCollapsed', () => {
it('toggles issues', () => {
vm = mountComponent(MRWidgetCodeQuality, {
type: 'codequality',
status: 'success',
loadingText: 'Loading codeclimate report',
errorText: 'foo',
successText: 'Code quality improved on 1 point and degraded on 1 point',
resolvedIssues: codequalityParsedIssues,
});
vm.$el.querySelector('button').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.code-quality-container').geAttribute('style'),
).toEqual(null);
expect(
vm.$el.querySelector('button').textContent.trim(),
).toEqual('Collapse');
vm.$el.querySelector('button').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.code-quality-container').geAttribute('style'),
).toEqual('display: none;');
expect(
vm.$el.querySelector('button').textContent.trim(),
).toEqual('Expand');
});
});
});
});
});
describe('with failed request', () => {
it('should render error indicator', () => {
vm = mountComponent(MRWidgetCodeQuality, {
type: 'codequality',
status: 'error',
loadingText: 'Loading codeclimate report',
errorText: 'Failed to load codeclimate report',
successText: 'Code quality improved on 1 point and degraded on 1 point',
});
expect(vm.$el.textContent.trim()).toEqual('Failed to load codeclimate report');
});
});
});
import Vue from 'vue';
import mrWidgetCodeQualityIssues from 'ee/vue_merge_request_widget/components/mr_widget_report_issues.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
import { securityParsedIssues, codequalityParsedIssues } from '../mock_data';
describe('merge request report issues', () => {
let vm;
let MRWidgetCodeQualityIssues;
beforeEach(() => {
MRWidgetCodeQualityIssues = Vue.extend(mrWidgetCodeQualityIssues);
});
afterEach(() => {
vm.$destroy();
});
describe('for codequality issues', () => {
describe('resolved issues', () => {
beforeEach(() => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
issues: codequalityParsedIssues,
type: 'codequality',
status: 'success',
});
});
it('should render a list of resolved issues', () => {
expect(vm.$el.querySelectorAll('.mr-widget-code-quality-list li').length).toEqual(codequalityParsedIssues.length);
});
it('should render "Fixed" keyword', () => {
expect(vm.$el.querySelector('.mr-widget-code-quality-list li').textContent).toContain('Fixed');
expect(
vm.$el.querySelector('.mr-widget-code-quality-list li').textContent.trim().replace(/\s+/g, ''),
).toEqual('Fixed:InsecureDependencyinGemfile.lock:12');
});
});
describe('unresolved issues', () => {
beforeEach(() => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
issues: codequalityParsedIssues,
type: 'codequality',
status: 'failed',
});
});
it('should render a list of unresolved issues', () => {
expect(vm.$el.querySelectorAll('.mr-widget-code-quality-list li').length).toEqual(codequalityParsedIssues.length);
});
it('should not render "Fixed" keyword', () => {
expect(vm.$el.querySelector('.mr-widget-code-quality-list li').textContent).not.toContain('Fixed');
});
});
});
describe('for security issues', () => {
beforeEach(() => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
issues: securityParsedIssues,
type: 'security',
status: 'failed',
});
});
it('should render a list of unresolved issues', () => {
expect(vm.$el.querySelectorAll('.mr-widget-code-quality-list li').length).toEqual(securityParsedIssues.length);
});
it('should render priority', () => {
expect(vm.$el.querySelector('.mr-widget-code-quality-list li').textContent).toContain(securityParsedIssues[0].priority);
});
});
describe('with location', () => {
it('should render location', () => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
issues: securityParsedIssues,
type: 'security',
status: 'failed',
});
expect(vm.$el.querySelector('.mr-widget-code-quality-list li').textContent).toContain('in');
expect(vm.$el.querySelector('.mr-widget-code-quality-list li a').getAttribute('href')).toEqual(securityParsedIssues[0].urlPath);
});
});
describe('without location', () => {
it('should not render location', () => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
issues: [{
name: 'foo',
}],
type: 'security',
status: 'failed',
});
expect(vm.$el.querySelector('.mr-widget-code-quality-list li').textContent).not.toContain('in');
expect(vm.$el.querySelector('.mr-widget-code-quality-list li a')).toEqual(null);
});
});
});
import Vue from 'vue';
import mrWidgetOptions from 'ee/vue_merge_request_widget/mr_widget_options';
import MRWidgetService from 'ee/vue_merge_request_widget/services/mr_widget_service';
import MRWidgetStore from 'ee/vue_merge_request_widget/stores/mr_widget_store';
import mockData, { baseIssues, headIssues } from './mock_data';
describe('ee merge request widget options', () => {
let vm;
let Component;
let mountComponent;
beforeEach(() => {
delete mrWidgetOptions.extends.el; // Prevent component mounting
gl.mrWidgetData = {
...mockData,
codeclimate: {
head_path: 'head.json',
base_path: 'base.json',
},
};
Component = Vue.extend(mrWidgetOptions);
Component.mr = new MRWidgetStore(gl.mrWidgetData);
Component.service = new MRWidgetService({});
mountComponent = () => new Component().$mount();
});
afterEach(() => {
vm.$destroy();
});
describe('security', () => {
beforeEach(() => {
});
});
describe('code quality', () => {
describe('when it is loading', () => {
it('should render loading indicator', () => {
vm = mountComponent();
expect(
vm.$el.querySelector('.js-codequality-widget').textContent.trim(),
).toContain('Loading codeclimate report');
});
});
describe('with successful request', () => {
const interceptor = (request, next) => {
if (request.url === 'head.json') {
next(request.respondWith(JSON.stringify(headIssues), {
status: 200,
}));
}
if (request.url === 'base.json') {
next(request.respondWith(JSON.stringify(baseIssues), {
status: 200,
}));
}
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
vm = mountComponent();
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('should render provided data', (done) => {
setTimeout(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('Code quality improved on 1 point and degraded on 1 point');
done();
}, 0);
});
describe('text connector', () => {
it('should only render information about fixed issues', (done) => {
setTimeout(() => {
vm.mr.codeclimateMetrics.newIssues = [];
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('Code quality improved on 1 point');
done();
});
}, 0);
});
it('should only render information about added issues', (done) => {
setTimeout(() => {
vm.mr.codeclimateMetrics.resolvedIssues = [];
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('Code quality degraded on 1 point');
done();
});
}, 0);
});
});
});
describe('with empty successful request', () => {
const emptyInterceptor = (request, next) => {
if (request.url === 'head.json') {
next(request.respondWith(JSON.stringify([]), {
status: 200,
}));
}
if (request.url === 'base.json') {
next(request.respondWith(JSON.stringify([]), {
status: 200,
}));
}
};
beforeEach(() => {
Vue.http.interceptors.push(emptyInterceptor);
vm = mountComponent();
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, emptyInterceptor);
});
it('should render provided data', (done) => {
setTimeout(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('No changes to code quality');
done();
}, 0);
});
});
describe('with failed request', () => {
const errorInterceptor = (request, next) => {
if (request.url === 'head.json') {
next(request.respondWith(JSON.stringify([]), {
status: 500,
}));
}
if (request.url === 'base.json') {
next(request.respondWith(JSON.stringify([]), {
status: 500,
}));
}
};
beforeEach(() => {
Vue.http.interceptors.push(errorInterceptor);
vm = mountComponent();
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, errorInterceptor);
});
it('should render error indicator', (done) => {
setTimeout(() => {
expect(vm.$el.querySelector('.js-codequality-widget').textContent.trim()).toContain('Failed to load codeclimate report');
done();
}, 0);
});
});
});
});
......@@ -249,6 +249,28 @@ export const headIssues = [
}
];
export const parsedHeadIssues = [
{
"check_name": "Rubocop/Lint/UselessAssignment",
"location": {
"path": "lib/six.rb",
"positions": {
"begin": {
"column": 6,
"line": 59
},
"end": {
"column": 7,
"line": 59
}
}
},
"fingerprint": "e879dd9bbc0953cad5037cde7ff0f627",
name: 'Rubocop/Lint/UselessAssignment',
path: 'lib/six.rb',
}
];
export const baseIssues = [
{
"categories": ["Security"],
......@@ -274,4 +296,65 @@ export const baseIssues = [
},
"fingerprint": "ca2354534dee94ae60ba2f54e3857c50e5",
}
]
];
export const parsedBaseIssues = [
{
"categories": ["Security"],
"check_name": "Insecure Dependency",
"location": {
"path": "Gemfile.lock",
"lines": {
"begin": 21,
"end": 21
}
},
"fingerprint": "ca2354534dee94ae60ba2f54e3857c50e5",
name: "Insecure Dependency",
path: "Gemfile.lock",
line: 21,
urlPath: 'undefined/Gemfile.lock#L21',
},
];
export const codequalityParsedIssues = [
{
name: 'Insecure Dependency',
fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5',
path: 'Gemfile.lock',
line: 12,
urlPath: 'foo/Gemfile.lock',
},
];
export const securityParsedIssues = [
{
name: 'Arbitrary file existence disclosure in Action Pack',
path: 'Gemfile.lock',
line: 12,
priority: 'High',
urlPath: 'foo/Gemfile.lock',
},
];
export const securityIssues = [
{
tool: 'bundler_audit',
message: 'Arbitrary file existence disclosure in Action Pack',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/rMTQy4oRCGk',
cve: 'CVE-2014-7829',
file: 'Gemfile.lock',
solution: 'upgrade to ~> 3.2.21, ~> 4.0.11.1, ~> 4.0.12, ~> 4.1.7.1, >= 4.1.8',
priority:'High',
line: 12,
},
{
tool: 'bundler_audit',
message: 'Possible Information Leak Vulnerability in Action View',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
cve: 'CVE-2016-0752',
file: 'Gemfile.lock',
solution: 'upgrade to >= 5.0.0.beta1.1, >= 4.2.5.1, ~> 4.2.5, >= 4.1.14.1, ~> 4.1.14, ~> 3.2.22.1',
priority: 'Medium',
}
];
import MergeRequestStore from 'ee/vue_merge_request_widget/stores/mr_widget_store';
import mockData, { headIssues, baseIssues } from '../mock_data';
import mockData, {
headIssues,
baseIssues,
securityIssues,
parsedBaseIssues,
parsedHeadIssues,
} from '../mock_data';
describe('MergeRequestStore', () => {
let store;
......@@ -60,11 +66,24 @@ describe('MergeRequestStore', () => {
});
it('should return the new issues', () => {
expect(store.codeclimateMetrics.newIssues[0]).toEqual(headIssues[0]);
expect(store.codeclimateMetrics.newIssues[0]).toEqual(parsedHeadIssues[0]);
});
it('should return the resolved issues', () => {
expect(store.codeclimateMetrics.resolvedIssues[0]).toEqual(baseIssues[1]);
expect(store.codeclimateMetrics.resolvedIssues[0]).toEqual(parsedBaseIssues[0]);
});
});
describe('parseIssues', () => {
it('should parse the received issues', () => {
const codequality = MergeRequestStore.parseIssues(baseIssues, 'path')[0];
expect(codequality.name).toEqual(baseIssues[0].check_name);
expect(codequality.path).toEqual(baseIssues[0].location.path);
expect(codequality.line).toEqual(baseIssues[0].location.lines.begin);
const security = MergeRequestStore.parseIssues(securityIssues, 'path')[0];
expect(security.name).toEqual(securityIssues[0].message);
expect(security.path).toEqual(securityIssues[0].file);
});
});
});
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