Commit 8a942142 authored by Phil Hughes's avatar Phil Hughes

Merge branch '45318-junit-FE' into 'master'

Frontend code for "JUnit XML Test Summary In MR widget"

See merge request gitlab-org/gitlab-ce!20936
parents beda5ca5 94981308
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
import { componentNames } from '~/vue_shared/components/reports/issue_body';
import ReportSection from '~/vue_shared/components/reports/report_section.vue';
import SummaryRow from '~/vue_shared/components/reports/summary_row.vue';
import IssuesList from '~/vue_shared/components/reports/issues_list.vue';
import Modal from './modal.vue';
import createStore from '../store';
import { summaryTextBuilder, reportTextBuilder, statusIcon } from '../store/utils';
export default {
name: 'GroupedTestReportsApp',
store: createStore(),
components: {
ReportSection,
SummaryRow,
IssuesList,
Modal,
},
props: {
endpoint: {
type: String,
required: true,
},
},
componentNames,
computed: {
...mapState([
'reports',
'isLoading',
'hasError',
'summary',
]),
...mapState({
modalTitle: state => state.modal.title || '',
modalData: state => state.modal.data || {},
}),
...mapGetters([
'summaryStatus',
]),
groupedSummaryText() {
if (this.isLoading) {
return s__('Reports|Test summary results are being parsed');
}
if (this.hasError) {
return s__('Reports|Test summary failed loading results');
}
return summaryTextBuilder(s__('Reports|Test summary'), this.summary);
},
},
created() {
this.setEndpoint(this.endpoint);
this.fetchReports();
},
methods: {
...mapActions(['setEndpoint', 'fetchReports']),
reportText(report) {
const summary = report.summary || {};
return reportTextBuilder(report.name, summary);
},
getReportIcon(report) {
return statusIcon(report.status);
},
shouldRenderIssuesList(report) {
return (
report.existing_failures.length > 0 ||
report.new_failures.length > 0 ||
report.resolved_failures > 0
);
},
},
};
</script>
<template>
<report-section
:status="summaryStatus"
:success-text="groupedSummaryText"
:loading-text="groupedSummaryText"
:error-text="groupedSummaryText"
:has-issues="reports.length > 0"
class="mr-widget-border-top grouped-security-reports"
>
<div
slot="body"
class="mr-widget-grouped-section report-block"
>
<template
v-for="(report, i) in reports"
>
<summary-row
:summary="reportText(report)"
:status-icon="getReportIcon(report)"
:key="`summary-row-${i}`"
/>
<issues-list
v-if="shouldRenderIssuesList(report)"
:unresolved-issues="report.existing_failures"
:new-issues="report.new_failures"
:resolved-issues="report.resolved_failures"
:key="`issues-list-${i}`"
:component="$options.componentNames.TestIssueBody"
class="report-block-group-list"
/>
</template>
<modal
:title="modalTitle"
:modal-data="modalData"
/>
</div>
</report-section>
</template>
<script>
import Modal from '~/vue_shared/components/gl_modal.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import CodeBlock from '~/vue_shared/components/code_block.vue';
import { fieldTypes } from '../constants';
export default {
components: {
Modal,
LoadingButton,
CodeBlock,
},
props: {
title: {
type: String,
required: true,
},
modalData: {
type: Object,
required: true,
},
},
fieldTypes,
};
</script>
<template>
<modal
id="modal-mrwidget-reports"
:header-title-text="title"
class="modal-security-report-dast modal-hide-footer"
>
<slot>
<div
v-for="(field, key, index) in modalData"
v-if="field.value"
:key="index"
class="row prepend-top-10 append-bottom-10"
>
<strong class="col-sm-2 text-right">
{{ field.text }}:
</strong>
<div class="col-sm-10 text-secondary">
<code-block
v-if="field.type === $options.fieldTypes.codeBock"
:code="field.value"
/>
<template v-else-if="field.type === $options.fieldTypes.link">
<a
:href="field.value"
target="_blank"
rel="noopener noreferrer"
class="js-modal-link"
>
{{ field.value }}
</a>
</template>
<template v-else-if="field.type === $options.fieldTypes.miliseconds">
{{ field.value }} ms
</template>
<template v-else-if="field.type === $options.fieldTypes.text">
{{ field.value }}
</template>
</div>
</div>
</slot>
<div slot="footer">
</div>
</modal>
</template>
<script>
import { mapActions } from 'vuex';
export default {
name: 'TestIssueBody',
props: {
issue: {
type: Object,
required: true,
},
// failed || success
status: {
type: String,
required: true,
},
isNew: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
...mapActions(['openModal']),
},
};
</script>
<template>
<div class="report-block-list-issue-description prepend-top-5 append-bottom-5">
<div class="report-block-list-issue-description-text">
<button
type="button"
class="btn-link btn-blank text-left break-link vulnerability-name-button"
@click="openModal({ issue })"
>
<div
v-if="isNew"
class="badge badge-danger append-right-5"
>
{{ s__('New') }}
</div>{{ issue.name }}
</button>
</div>
</div>
</template>
export const fieldTypes = {
codeBock: 'codeBlock',
link: 'link',
miliseconds: 'miliseconds',
text: 'text',
};
export const LOADING = 'LOADING';
export const ERROR = 'ERROR';
export const SUCCESS = 'SUCCESS';
export const STATUS_FAILED = 'failed';
export const STATUS_SUCCESS = 'success';
export const ICON_WARNING = 'warning';
export const ICON_SUCCESS = 'success';
export const ICON_NOTFOUND = 'notfound';
import Visibility from 'visibilityjs'; import Visibility from 'visibilityjs';
import $ from 'jquery';
import axios from '../../lib/utils/axios_utils'; import axios from '../../lib/utils/axios_utils';
import Poll from '../../lib/utils/poll'; import Poll from '../../lib/utils/poll';
import * as types from './mutation_types'; import * as types from './mutation_types';
import httpStatusCodes from '../../lib/utils/http_status';
export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint); export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
...@@ -41,12 +43,19 @@ export const fetchReports = ({ state, dispatch }) => { ...@@ -41,12 +43,19 @@ export const fetchReports = ({ state, dispatch }) => {
}, },
data: state.endpoint, data: state.endpoint,
method: 'getReports', method: 'getReports',
successCallback: ({ data }) => dispatch('receiveReportsSuccess', data), successCallback: ({ data, status }) => dispatch('receiveReportsSuccess', {
data, status,
}),
errorCallback: () => dispatch('receiveReportsError'), errorCallback: () => dispatch('receiveReportsError'),
}); });
if (!Visibility.hidden()) { if (!Visibility.hidden()) {
eTagPoll.makeRequest(); eTagPoll.makeRequest();
} else {
axios
.get(state.endpoint)
.then(({ data, status }) => dispatch('receiveReportsSuccess', { data, status }))
.catch(() => dispatch('receiveReportsError'));
} }
Visibility.change(() => { Visibility.change(() => {
...@@ -58,10 +67,22 @@ export const fetchReports = ({ state, dispatch }) => { ...@@ -58,10 +67,22 @@ export const fetchReports = ({ state, dispatch }) => {
}); });
}; };
export const receiveReportsSuccess = ({ commit }, response) => export const receiveReportsSuccess = ({ commit }, response) => {
commit(types.RECEIVE_REPORTS_SUCCESS, response); // With 204 we keep polling and don't update the state
if (response.status === httpStatusCodes.OK) {
commit(types.RECEIVE_REPORTS_SUCCESS, response.data);
}
};
export const receiveReportsError = ({ commit }) => commit(types.RECEIVE_REPORTS_ERROR); export const receiveReportsError = ({ commit }) => commit(types.RECEIVE_REPORTS_ERROR);
export const openModal = ({ dispatch }, payload) => {
dispatch('setModalData', payload);
$('#modal-mrwidget-reports').modal('show');
};
export const setModalData = ({ commit }, payload) => commit(types.SET_ISSUE_MODAL_DATA, payload);
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../constants';
export const summaryStatus = state => {
if (state.isLoading) {
return LOADING;
}
if (state.hasError || state.status === STATUS_FAILED) {
return ERROR;
}
return SUCCESS;
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import * as actions from './actions'; import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations'; import mutations from './mutations';
import state from './state'; import state from './state';
...@@ -9,5 +10,6 @@ Vue.use(Vuex); ...@@ -9,5 +10,6 @@ Vue.use(Vuex);
export default () => new Vuex.Store({ export default () => new Vuex.Store({
actions, actions,
mutations, mutations,
getters,
state: state(), state: state(),
}); });
...@@ -3,3 +3,5 @@ export const SET_ENDPOINT = 'SET_ENDPOINT'; ...@@ -3,3 +3,5 @@ export const SET_ENDPOINT = 'SET_ENDPOINT';
export const REQUEST_REPORTS = 'REQUEST_REPORTS'; export const REQUEST_REPORTS = 'REQUEST_REPORTS';
export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS'; export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS';
export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR'; export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR';
export const SET_ISSUE_MODAL_DATA = 'SET_ISSUE_MODAL_DATA';
...@@ -16,11 +16,32 @@ export default { ...@@ -16,11 +16,32 @@ export default {
state.summary.resolved = response.summary.resolved; state.summary.resolved = response.summary.resolved;
state.summary.failed = response.summary.failed; state.summary.failed = response.summary.failed;
state.status = response.status;
state.reports = response.suites; state.reports = response.suites;
}, },
[types.RECEIVE_REPORTS_ERROR](state) { [types.RECEIVE_REPORTS_ERROR](state) {
state.isLoading = false; state.isLoading = false;
state.hasError = true; state.hasError = true;
state.reports = [];
state.summary = {
total: 0,
resolved: 0,
failed: 0,
};
state.status = null;
},
[types.SET_ISSUE_MODAL_DATA](state, payload) {
state.modal.title = payload.issue.name;
Object.keys(payload.issue).forEach((key) => {
if (Object.prototype.hasOwnProperty.call(state.modal.data, key)) {
state.modal.data[key] = {
...state.modal.data[key],
value: payload.issue[key],
};
}
});
}, },
}; };
import { s__ } from '~/locale';
import { fieldTypes } from '../constants';
export default () => ({ export default () => ({
endpoint: null, endpoint: null,
isLoading: false, isLoading: false,
hasError: false, hasError: false,
status: null,
summary: { summary: {
total: 0, total: 0,
resolved: 0, resolved: 0,
...@@ -25,4 +30,32 @@ export default () => ({ ...@@ -25,4 +30,32 @@ export default () => ({
* } * }
*/ */
reports: [], reports: [],
modal: {
title: null,
data: {
class: {
value: null,
text: s__('Reports|Class'),
type: fieldTypes.link,
},
execution_time: {
value: null,
text: s__('Reports|Execution time'),
type: fieldTypes.miliseconds,
},
failure: {
value: null,
text: s__('Reports|Failure'),
type: fieldTypes.codeBock,
},
system_output: {
value: null,
text: s__('Reports|System output'),
type: fieldTypes.codeBock,
},
},
},
}); });
import { sprintf, n__, s__ } from '~/locale';
import {
STATUS_FAILED,
STATUS_SUCCESS,
ICON_WARNING,
ICON_SUCCESS,
ICON_NOTFOUND,
} from '../constants';
const textBuilder = results => {
const { failed, resolved, total } = results;
const failedString = failed
? n__('%d failed test result', '%d failed test results', failed)
: null;
const resolvedString = resolved
? n__('%d fixed test result', '%d fixed test results', resolved)
: null;
const totalString = total ? n__('out of %d total test', 'out of %d total tests', total) : null;
let resultsString = s__('Reports|no changed test results');
if (failed) {
if (resolved) {
resultsString = sprintf(s__('Reports|%{failedString} and %{resolvedString}'), {
failedString,
resolvedString,
});
} else {
resultsString = failedString;
}
} else if (resolved) {
resultsString = resolvedString;
}
return `${resultsString} ${totalString}`;
};
export const summaryTextBuilder = (name = '', results = {}) => {
const resultsString = textBuilder(results);
return `${name} contained ${resultsString}`;
};
export const reportTextBuilder = (name = '', results = {}) => {
const resultsString = textBuilder(results);
return `${name} found ${resultsString}`;
};
export const statusIcon = status => {
if (status === STATUS_FAILED) {
return ICON_WARNING;
}
if (status === STATUS_SUCCESS) {
return ICON_SUCCESS;
}
return ICON_NOTFOUND;
};
...@@ -36,6 +36,7 @@ import { ...@@ -36,6 +36,7 @@ import {
notify, notify,
SourceBranchRemovalStatus, SourceBranchRemovalStatus,
} from './dependencies'; } from './dependencies';
import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue';
import { setFaviconOverlay } from '../lib/utils/common_utils'; import { setFaviconOverlay } from '../lib/utils/common_utils';
export default { export default {
...@@ -68,6 +69,7 @@ export default { ...@@ -68,6 +69,7 @@ export default {
'mr-widget-auto-merge-failed': AutoMergeFailed, 'mr-widget-auto-merge-failed': AutoMergeFailed,
'mr-widget-rebase': RebaseState, 'mr-widget-rebase': RebaseState,
SourceBranchRemovalStatus, SourceBranchRemovalStatus,
GroupedTestReportsApp,
}, },
props: { props: {
mrData: { mrData: {
...@@ -260,6 +262,10 @@ export default { ...@@ -260,6 +262,10 @@ export default {
:deployment="deployment" :deployment="deployment"
/> />
<div class="mr-section-container"> <div class="mr-section-container">
<grouped-test-reports-app
v-if="mr.testResultsPath"
:endpoint="mr.testResultsPath"
/>
<div class="mr-widget-section"> <div class="mr-widget-section">
<component <component
:is="componentName" :is="componentName"
......
...@@ -108,6 +108,8 @@ export default class MergeRequestStore { ...@@ -108,6 +108,8 @@ export default class MergeRequestStore {
this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false; this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false;
this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null; this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
this.testResultsPath = data.test_reports_path;
this.setState(data); this.setState(data);
} }
......
<script>
export default {
name: 'CodeBlock',
props: {
code: {
type: String,
required: true,
},
},
};
</script>
<template>
<pre class="code-block rounded">
<code class="d-block">{{ code }}</code>
</pre>
</template>
export const components = {}; import TestIssueBody from '~/reports/components/test_issue_body.vue';
export const componentNames = {}; export const components = {
TestIssueBody,
};
export const componentNames = {
TestIssueBody: TestIssueBody.name,
};
...@@ -18,6 +18,11 @@ export default { ...@@ -18,6 +18,11 @@ export default {
failed: STATUS_FAILED, failed: STATUS_FAILED,
neutral: STATUS_NEUTRAL, neutral: STATUS_NEUTRAL,
props: { props: {
newIssues: {
type: Array,
required: false,
default: () => [],
},
unresolvedIssues: { unresolvedIssues: {
type: Array, type: Array,
required: false, required: false,
...@@ -44,6 +49,15 @@ export default { ...@@ -44,6 +49,15 @@ export default {
<template> <template>
<div class="report-block-container"> <div class="report-block-container">
<issues-block
v-if="newIssues.length"
:component="component"
:issues="newIssues"
class="js-mr-code-new-issues"
status="failed"
is-new
/>
<issues-block <issues-block
v-if="unresolvedIssues.length" v-if="unresolvedIssues.length"
:component="component" :component="component"
......
...@@ -24,6 +24,11 @@ export default { ...@@ -24,6 +24,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
isNew: {
type: Boolean,
required: false,
default: false,
},
}, },
}; };
</script> </script>
...@@ -46,6 +51,7 @@ export default { ...@@ -46,6 +51,7 @@ export default {
:is="component" :is="component"
:issue="issue" :issue="issue"
:status="issue.status || status" :status="issue.status || status"
:is-new="isNew"
/> />
</li> </li>
</ul> </ul>
......
...@@ -29,7 +29,8 @@ export default { ...@@ -29,7 +29,8 @@ export default {
}, },
popoverOptions: { popoverOptions: {
type: Object, type: Object,
required: true, required: false,
default: null,
}, },
}, },
computed: { computed: {
...@@ -60,7 +61,11 @@ export default { ...@@ -60,7 +61,11 @@ export default {
{{ summary }} {{ summary }}
</div> </div>
<popover :options="popoverOptions" /> <popover
v-if="popoverOptions"
:options="popoverOptions"
/>
</div> </div>
</div> </div>
</template> </template>
...@@ -353,3 +353,18 @@ ...@@ -353,3 +353,18 @@
.flex-right { .flex-right {
margin-left: auto; margin-left: auto;
} }
.code-block {
background: $black;
color: $gray-darkest;
white-space: pre;
overflow-x: auto;
font-size: 12px;
border: 0;
padding: $grid-size;
code {
background-color: inherit;
padding: inherit;
}
}
...@@ -469,3 +469,4 @@ img.emoji { ...@@ -469,3 +469,4 @@ img.emoji {
.inline { display: inline-block; } .inline { display: inline-block; }
.center { text-align: center; } .center { text-align: center; }
.vertical-align-middle { vertical-align: middle; } .vertical-align-middle { vertical-align: middle; }
.flex-align-self-center { align-self: center; }
...@@ -15,6 +15,39 @@ ...@@ -15,6 +15,39 @@
} }
} }
.mr-widget-border-top {
border-top: 1px solid $border-color;
}
.media-section {
@include media-breakpoint-down(md) {
align-items: flex-start;
.media-body {
flex-direction: column;
align-items: flex-start;
}
}
.code-text {
@include media-breakpoint-up(lg) {
align-self: center;
flex: 1;
}
}
}
.mr-widget-section {
.media {
align-items: center;
}
.code-text {
flex: 1;
}
}
.mr-widget-heading { .mr-widget-heading {
position: relative; position: relative;
border: 1px solid $border-color; border: 1px solid $border-color;
...@@ -54,6 +87,14 @@ ...@@ -54,6 +87,14 @@
padding: 0; padding: 0;
} }
.grouped-security-reports {
padding: 0;
> .media {
padding: $gl-padding;
}
}
form { form {
margin-bottom: 0; margin-bottom: 0;
......
---
title: Adds frontend support to render test reports on the MR widget
merge_request: 20936
author:
type: added
...@@ -36,6 +36,16 @@ msgid_plural "%d exporters" ...@@ -36,6 +36,16 @@ msgid_plural "%d exporters"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "%d failed test result"
msgid_plural "%d failed test results"
msgstr[0] ""
msgstr[1] ""
msgid "%d fixed test result"
msgid_plural "%d fixed test results"
msgstr[0] ""
msgstr[1] ""
msgid "%d issue" msgid "%d issue"
msgid_plural "%d issues" msgid_plural "%d issues"
msgstr[0] "" msgstr[0] ""
...@@ -4390,6 +4400,33 @@ msgstr "" ...@@ -4390,6 +4400,33 @@ msgstr ""
msgid "Reply to this email directly or %{view_it_on_gitlab}." msgid "Reply to this email directly or %{view_it_on_gitlab}."
msgstr "" msgstr ""
msgid "Reports|%{failedString} and %{resolvedString}"
msgstr ""
msgid "Reports|Class"
msgstr ""
msgid "Reports|Execution time"
msgstr ""
msgid "Reports|Failure"
msgstr ""
msgid "Reports|System output"
msgstr ""
msgid "Reports|Test summary"
msgstr ""
msgid "Reports|Test summary failed loading results"
msgstr ""
msgid "Reports|Test summary results are being parsed"
msgstr ""
msgid "Reports|no changed test results"
msgstr ""
msgid "Repository" msgid "Repository"
msgstr "" msgstr ""
...@@ -6273,6 +6310,11 @@ msgstr "" ...@@ -6273,6 +6310,11 @@ msgstr ""
msgid "or" msgid "or"
msgstr "" msgstr ""
msgid "out of %d total test"
msgid_plural "out of %d total tests"
msgstr[0] ""
msgstr[1] ""
msgid "parent" msgid "parent"
msgid_plural "parents" msgid_plural "parents"
msgstr[0] "" msgstr[0] ""
......
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import state from '~/reports/store/state';
import component from '~/reports/components/grouped_test_reports_app.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
import newFailedTestReports from '../mock_data/new_failures_report.json';
import successTestReports from '../mock_data/no_failures_report.json';
import mixedResultsTestReports from '../mock_data/new_and_fixed_failures_report.json';
describe('Grouped Test Reports App', () => {
let vm;
let mock;
const Component = Vue.extend(component);
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
vm.$store.replaceState(state());
vm.$destroy();
mock.restore();
});
describe('with success result', () => {
beforeEach(() => {
mock.onGet('test_results.json').reply(200, successTestReports, {});
vm = mountComponent(Component, {
endpoint: 'test_results.json',
});
});
it('renders success summary text', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
'Test summary contained no changed test results out of 11 total tests',
);
expect(vm.$el.textContent).toContain(
'rspec:pg found no changed test results out of 8 total tests',
);
expect(vm.$el.textContent).toContain(
'java ant found no changed test results out of 3 total tests',
);
done();
}, 0);
});
});
describe('with 204 result', () => {
beforeEach(() => {
mock.onGet('test_results.json').reply(204, {}, {});
vm = mountComponent(Component, {
endpoint: 'test_results.json',
});
});
it('renders success summary text', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.fa-spinner')).not.toBeNull();
expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
'Test summary results are being parsed',
);
done();
}, 0);
});
});
describe('with new failed result', () => {
beforeEach(() => {
mock.onGet('test_results.json').reply(200, newFailedTestReports, {});
vm = mountComponent(Component, {
endpoint: 'test_results.json',
});
});
it('renders failed summary text + new badge', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
'Test summary contained 2 failed test results out of 11 total tests',
);
expect(vm.$el.textContent).toContain(
'rspec:pg found 2 failed test results out of 8 total tests',
);
expect(vm.$el.textContent).toContain('New');
expect(vm.$el.textContent).toContain(
'java ant found no changed test results out of 3 total tests',
);
done();
}, 0);
});
});
describe('with mixed results', () => {
beforeEach(() => {
mock.onGet('test_results.json').reply(200, mixedResultsTestReports, {});
vm = mountComponent(Component, {
endpoint: 'test_results.json',
});
});
it('renders summary text', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
'Test summary contained 2 failed test results and 2 fixed test results out of 11 total tests',
);
expect(vm.$el.textContent).toContain(
'rspec:pg found 1 failed test result and 2 fixed test results out of 8 total tests',
);
expect(vm.$el.textContent).toContain('New');
expect(vm.$el.textContent).toContain(
' java ant found 1 failed test result out of 3 total tests',
);
done();
}, 0);
});
});
describe('with error', () => {
beforeEach(() => {
mock.onGet('test_results.json').reply(500, {}, {});
vm = mountComponent(Component, {
endpoint: 'test_results.json',
});
});
it('renders loading summary text with loading icon', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
'Test summary failed loading results',
);
done();
}, 0);
});
});
describe('while loading', () => {
beforeEach(() => {
mock.onGet('test_results.json').reply(200, {}, {});
vm = mountComponent(Component, {
endpoint: 'test_results.json',
});
});
it('renders loading summary text with loading icon', done => {
expect(vm.$el.querySelector('.fa-spinner')).not.toBeNull();
expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
'Test summary results are being parsed',
);
setTimeout(() => {
done();
}, 0);
});
});
});
import Vue from 'vue';
import component from '~/reports/components/modal.vue';
import state from '~/reports/store/state';
import mountComponent from '../../helpers/vue_mount_component_helper';
import { trimText } from '../../helpers/vue_component_helper';
describe('Grouped Test Reports Modal', () => {
const Component = Vue.extend(component);
const modalDataStructure = state().modal.data;
// populate data
modalDataStructure.execution_time.value = 0.009411;
modalDataStructure.system_output.value = 'Failure/Error: is_expected.to eq(3)\n\n';
modalDataStructure.class.value = 'link';
let vm;
beforeEach(() => {
vm = mountComponent(Component, {
title: 'Test#sum when a is 1 and b is 2 returns summary',
modalData: modalDataStructure,
});
});
afterEach(() => {
vm.$destroy();
});
it('renders code block', () => {
expect(vm.$el.querySelector('code').textContent).toEqual(modalDataStructure.system_output.value);
});
it('renders link', () => {
expect(vm.$el.querySelector('.js-modal-link').getAttribute('href')).toEqual(modalDataStructure.class.value);
expect(trimText(vm.$el.querySelector('.js-modal-link').textContent)).toEqual(modalDataStructure.class.value);
});
it('renders miliseconds', () => {
expect(vm.$el.textContent).toContain(`${modalDataStructure.execution_time.value} ms`);
});
it('render title', () => {
expect(trimText(vm.$el.querySelector('.modal-title').textContent)).toEqual('Test#sum when a is 1 and b is 2 returns summary');
});
});
import Vue from 'vue';
import component from '~/reports/components/test_issue_body.vue';
import createStore from '~/reports/store';
import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper';
import { trimText } from '../../helpers/vue_component_helper';
import { issue } from '../mock_data/mock_data';
describe('Test Issue body', () => {
let vm;
const Component = Vue.extend(component);
const store = createStore();
const commonProps = {
issue,
status: 'failed',
};
afterEach(() => {
vm.$destroy();
});
describe('on click', () => {
it('calls openModal action', () => {
vm = mountComponentWithStore(Component, {
store,
props: commonProps,
});
spyOn(vm, 'openModal');
vm.$el.querySelector('button').click();
expect(vm.openModal).toHaveBeenCalledWith({
issue: commonProps.issue,
});
});
});
describe('is new', () => {
beforeEach(() => {
vm = mountComponentWithStore(Component, {
store,
props: Object.assign({}, commonProps, { isNew: true }),
});
});
it('renders issue name', () => {
expect(vm.$el.textContent).toContain(commonProps.issue.name);
});
it('renders new badge', () => {
expect(trimText(vm.$el.querySelector('.badge').textContent)).toEqual('New');
});
});
describe('not new', () => {
beforeEach(() => {
vm = mountComponentWithStore(Component, {
store,
props: commonProps,
});
});
it('renders issue name', () => {
expect(vm.$el.textContent).toContain(commonProps.issue.name);
});
it('does not renders new badge', () => {
expect(vm.$el.querySelector('.badge')).toEqual(null);
});
});
});
// eslint-disable-next-line import/prefer-default-export
export const issue = {
result: 'failure',
name: 'Test#sum when a is 1 and b is 2 returns summary',
execution_time: 0.009411,
system_output:
"Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in \u003ctop (required)\u003e'",
};
{"status":"failed","summary":{"total":11,"resolved":2,"failed":2},"suites":[{"name":"rspec:pg","status":"failed","summary":{"total":8,"resolved":2,"failed":1},"new_failures":[{"status":"failed","name":"Test#subtract when a is 2 and b is 1 returns correct result","execution_time":0.00908,"system_output":"Failure/Error: is_expected.to eq(1)\n\n expected: 1\n got: 3\n\n (compared using ==)\n./spec/test_spec.rb:43:in `block (4 levels) in <top (required)>'"}],"resolved_failures":[{"status":"success","name":"Test#sum when a is 1 and b is 2 returns summary","execution_time":0.000318,"system_output":null},{"status":"success","name":"Test#sum when a is 100 and b is 200 returns summary","execution_time":0.000074,"system_output":null}],"existing_failures":[]},{"name":"java ant","status":"failed","summary":{"total":3,"resolved":0,"failed":1},"new_failures":[],"resolved_failures":[],"existing_failures":[{"status":"failed","name":"sumTest","execution_time":0.004,"system_output":"junit.framework.AssertionFailedError: expected:<3> but was:<-1>\n\tat CalculatorTest.sumTest(Unknown Source)\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)\n\tat java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\n"}]}]}
\ No newline at end of file
{"summary":{"total":11,"resolved":0,"failed":2},"suites":[{"name":"rspec:pg","summary":{"total":8,"resolved":0,"failed":2},"new_failures":[{"result":"failure","name":"Test#sum when a is 1 and b is 2 returns summary","execution_time":0.009411,"system_output":"Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in <top (required)>'"},{"result":"failure","name":"Test#sum when a is 100 and b is 200 returns summary","execution_time":0.000162,"system_output":"Failure/Error: is_expected.to eq(300)\n\n expected: 300\n got: -100\n\n (compared using ==)\n./spec/test_spec.rb:21:in `block (4 levels) in <top (required)>'"}],"resolved_failures":[],"existing_failures":[]},{"name":"java ant","summary":{"total":3,"resolved":0,"failed":0},"new_failures":[],"resolved_failures":[],"existing_failures":[]}]}
\ No newline at end of file
{"status":"success","summary":{"total":11,"resolved":0,"failed":0},"suites":[{"name":"rspec:pg","status":"success","summary":{"total":8,"resolved":0,"failed":0},"new_failures":[],"resolved_failures":[],"existing_failures":[]},{"name":"java ant","status":"success","summary":{"total":3,"resolved":0,"failed":0},"new_failures":[],"resolved_failures":[],"existing_failures":[]}]}
\ No newline at end of file
...@@ -8,6 +8,8 @@ import { ...@@ -8,6 +8,8 @@ import {
clearEtagPoll, clearEtagPoll,
receiveReportsSuccess, receiveReportsSuccess,
receiveReportsError, receiveReportsError,
openModal,
setModalData,
} from '~/reports/store/actions'; } from '~/reports/store/actions';
import state from '~/reports/store/state'; import state from '~/reports/store/state';
import * as types from '~/reports/store/mutation_types'; import * as types from '~/reports/store/mutation_types';
...@@ -56,7 +58,9 @@ describe('Reports Store Actions', () => { ...@@ -56,7 +58,9 @@ describe('Reports Store Actions', () => {
describe('success', () => { describe('success', () => {
it('dispatches requestReports and receiveReportsSuccess ', done => { it('dispatches requestReports and receiveReportsSuccess ', done => {
mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { summary: {}, suites: [{ name: 'rspec' }] }); mock
.onGet(`${TEST_HOST}/endpoint.json`)
.replyOnce(200, { summary: {}, suites: [{ name: 'rspec' }] });
testAction( testAction(
fetchReports, fetchReports,
...@@ -68,7 +72,7 @@ describe('Reports Store Actions', () => { ...@@ -68,7 +72,7 @@ describe('Reports Store Actions', () => {
type: 'requestReports', type: 'requestReports',
}, },
{ {
payload: { summary: {}, suites: [{ name: 'rspec' }] }, payload: { data: { summary: {}, suites: [{ name: 'rspec' }] }, status: 200 },
type: 'receiveReportsSuccess', type: 'receiveReportsSuccess',
}, },
], ],
...@@ -103,16 +107,27 @@ describe('Reports Store Actions', () => { ...@@ -103,16 +107,27 @@ describe('Reports Store Actions', () => {
}); });
describe('receiveReportsSuccess', () => { describe('receiveReportsSuccess', () => {
it('should commit RECEIVE_REPORTS_SUCCESS mutation', done => { it('should commit RECEIVE_REPORTS_SUCCESS mutation with 200', done => {
testAction( testAction(
receiveReportsSuccess, receiveReportsSuccess,
{ summary: {} }, { data: { summary: {} }, status: 200 },
mockedState, mockedState,
[{ type: types.RECEIVE_REPORTS_SUCCESS, payload: { summary: {} } }], [{ type: types.RECEIVE_REPORTS_SUCCESS, payload: { summary: {} } }],
[], [],
done, done,
); );
}); });
it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', done => {
testAction(
receiveReportsSuccess,
{ data: { summary: {} }, status: 204 },
mockedState,
[],
[],
done,
);
});
}); });
describe('receiveReportsError', () => { describe('receiveReportsError', () => {
...@@ -127,4 +142,30 @@ describe('Reports Store Actions', () => { ...@@ -127,4 +142,30 @@ describe('Reports Store Actions', () => {
); );
}); });
}); });
describe('openModal', () => {
it('should dispatch setModalData', done => {
testAction(
openModal,
{ name: 'foo' },
mockedState,
[],
[{ type: 'setModalData', payload: { name: 'foo' } }],
done,
);
});
});
describe('setModalData', () => {
it('should commit SET_ISSUE_MODAL_DATA', done => {
testAction(
setModalData,
{ name: 'foo' },
mockedState,
[{ type: types.SET_ISSUE_MODAL_DATA, payload: { name: 'foo' } }],
[],
done,
);
});
});
}); });
import state from '~/reports/store/state'; import state from '~/reports/store/state';
import mutations from '~/reports/store/mutations'; import mutations from '~/reports/store/mutations';
import * as types from '~/reports/store/mutation_types'; import * as types from '~/reports/store/mutation_types';
import { issue } from '../mock_data/mock_data';
describe('Reports Store Mutations', () => { describe('Reports Store Mutations', () => {
let stateCopy; let stateCopy;
...@@ -42,24 +43,21 @@ describe('Reports Store Mutations', () => { ...@@ -42,24 +43,21 @@ describe('Reports Store Mutations', () => {
{ {
name: 'StringHelper#concatenate when a is git and b is lab returns summary', name: 'StringHelper#concatenate when a is git and b is lab returns summary',
execution_time: 0.0092435, execution_time: 0.0092435,
system_output: system_output: "Failure/Error: is_expected.to eq('gitlab')",
'Failure/Error: is_expected.to eq(\'gitlab\')',
}, },
], ],
resolved_failures: [ resolved_failures: [
{ {
name: 'StringHelper#concatenate when a is git and b is lab returns summary', name: 'StringHelper#concatenate when a is git and b is lab returns summary',
execution_time: 0.009235, execution_time: 0.009235,
system_output: system_output: "Failure/Error: is_expected.to eq('gitlab')",
'Failure/Error: is_expected.to eq(\'gitlab\')',
}, },
], ],
existing_failures: [ existing_failures: [
{ {
name: 'StringHelper#concatenate when a is git and b is lab returns summary', name: 'StringHelper#concatenate when a is git and b is lab returns summary',
execution_time: 1232.08, execution_time: 1232.08,
system_output: system_output: "Failure/Error: is_expected.to eq('gitlab')",
'Failure/Error: is_expected.to eq(\'gitlab\')',
}, },
], ],
}, },
...@@ -89,6 +87,7 @@ describe('Reports Store Mutations', () => { ...@@ -89,6 +87,7 @@ describe('Reports Store Mutations', () => {
beforeEach(() => { beforeEach(() => {
mutations[types.RECEIVE_REPORTS_ERROR](stateCopy); mutations[types.RECEIVE_REPORTS_ERROR](stateCopy);
}); });
it('should reset isLoading', () => { it('should reset isLoading', () => {
expect(stateCopy.isLoading).toEqual(false); expect(stateCopy.isLoading).toEqual(false);
}); });
...@@ -97,5 +96,25 @@ describe('Reports Store Mutations', () => { ...@@ -97,5 +96,25 @@ describe('Reports Store Mutations', () => {
expect(stateCopy.hasError).toEqual(true); expect(stateCopy.hasError).toEqual(true);
}); });
it('should reset reports', () => {
expect(stateCopy.reports).toEqual([]);
});
});
describe('SET_ISSUE_MODAL_DATA', () => {
beforeEach(() => {
mutations[types.SET_ISSUE_MODAL_DATA](stateCopy, {
issue,
});
});
it('should set modal title', () => {
expect(stateCopy.modal.title).toEqual(issue.name);
});
it('should set modal data', () => {
expect(stateCopy.modal.data.execution_time.value).toEqual(issue.execution_time);
expect(stateCopy.modal.data.system_output.value).toEqual(issue.system_output);
});
}); });
}); });
import * as utils from '~/reports/store/utils';
import {
STATUS_FAILED,
STATUS_SUCCESS,
ICON_WARNING,
ICON_SUCCESS,
ICON_NOTFOUND,
} from '~/reports/constants';
describe('Reports store utils', () => {
describe('summaryTextbuilder', () => {
it('should render text for no changed results in multiple tests', () => {
const name = 'Test summary';
const data = { total: 10 };
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe('Test summary contained no changed test results out of 10 total tests');
});
it('should render text for no changed results in one test', () => {
const name = 'Test summary';
const data = { total: 1 };
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe('Test summary contained no changed test results out of 1 total test');
});
it('should render text for multiple failed results', () => {
const name = 'Test summary';
const data = { failed: 3, total: 10 };
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe('Test summary contained 3 failed test results out of 10 total tests');
});
it('should render text for multiple fixed results', () => {
const name = 'Test summary';
const data = { resolved: 4, total: 10 };
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe('Test summary contained 4 fixed test results out of 10 total tests');
});
it('should render text for multiple fixed, and multiple failed results', () => {
const name = 'Test summary';
const data = { failed: 3, resolved: 4, total: 10 };
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe(
'Test summary contained 3 failed test results and 4 fixed test results out of 10 total tests',
);
});
it('should render text for a singular fixed, and a singular failed result', () => {
const name = 'Test summary';
const data = { failed: 1, resolved: 1, total: 10 };
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe(
'Test summary contained 1 failed test result and 1 fixed test result out of 10 total tests',
);
});
});
describe('reportTextBuilder', () => {
it('should render text for no changed results in multiple tests', () => {
const name = 'Rspec';
const data = { total: 10 };
const result = utils.reportTextBuilder(name, data);
expect(result).toBe('Rspec found no changed test results out of 10 total tests');
});
it('should render text for no changed results in one test', () => {
const name = 'Rspec';
const data = { total: 1 };
const result = utils.reportTextBuilder(name, data);
expect(result).toBe('Rspec found no changed test results out of 1 total test');
});
it('should render text for multiple failed results', () => {
const name = 'Rspec';
const data = { failed: 3, total: 10 };
const result = utils.reportTextBuilder(name, data);
expect(result).toBe('Rspec found 3 failed test results out of 10 total tests');
});
it('should render text for multiple fixed results', () => {
const name = 'Rspec';
const data = { resolved: 4, total: 10 };
const result = utils.reportTextBuilder(name, data);
expect(result).toBe('Rspec found 4 fixed test results out of 10 total tests');
});
it('should render text for multiple fixed, and multiple failed results', () => {
const name = 'Rspec';
const data = { failed: 3, resolved: 4, total: 10 };
const result = utils.reportTextBuilder(name, data);
expect(result).toBe(
'Rspec found 3 failed test results and 4 fixed test results out of 10 total tests',
);
});
it('should render text for a singular fixed, and a singular failed result', () => {
const name = 'Rspec';
const data = { failed: 1, resolved: 1, total: 10 };
const result = utils.reportTextBuilder(name, data);
expect(result).toBe(
'Rspec found 1 failed test result and 1 fixed test result out of 10 total tests',
);
});
});
describe('statusIcon', () => {
describe('with failed status', () => {
it('returns ICON_WARNING', () => {
expect(utils.statusIcon(STATUS_FAILED)).toEqual(ICON_WARNING);
});
});
describe('with success status', () => {
it('returns ICON_SUCCESS', () => {
expect(utils.statusIcon(STATUS_SUCCESS)).toEqual(ICON_SUCCESS);
});
});
describe('without a status', () => {
it('returns ICON_NOTFOUND', () => {
expect(utils.statusIcon()).toEqual(ICON_NOTFOUND);
});
});
});
});
import Vue from 'vue';
import component from '~/vue_shared/components/code_block.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
describe('Code Block', () => {
const Component = Vue.extend(component);
let vm;
afterEach(() => {
vm.$destroy();
});
it('renders a code block with the provided code', () => {
const code =
"Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in \u003ctop (required)\u003e'";
vm = mountComponent(Component, {
code,
});
expect(vm.$el.querySelector('code').textContent).toEqual(code);
});
it('escapes XSS injections', () => {
const code = 'CCC&lt;img src=x onerror=alert(document.domain)&gt;';
vm = mountComponent(Component, {
code,
});
expect(vm.$el.querySelector('code').textContent).toEqual(code);
});
});
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