Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFilipa Lacerda <filipa@gitlab.com>2018-07-31 18:50:34 +0300
committerFilipa Lacerda <filipa@gitlab.com>2018-08-02 19:59:22 +0300
commitbfeb7c2c3f61a6ea8f36479e7260ba97459152c9 (patch)
tree9b813c6ab41cf1456d8f306ec4b8ee19be7b0872
parent28c15d4f94cda8c6635d832d6bde7a131e207023 (diff)
Adds frontend support to render test reports on theMR widget
Creates an app to render grouped test reports in the MR widget Ports CSS from EE into CE Creates a reusable code component Adds getters and utils to the existing reports store
-rw-r--r--app/assets/javascripts/reports/components/grouped_test_reports_app.vue114
-rw-r--r--app/assets/javascripts/reports/components/modal.vue73
-rw-r--r--app/assets/javascripts/reports/components/test_issue_body.vue48
-rw-r--r--app/assets/javascripts/reports/constants.js16
-rw-r--r--app/assets/javascripts/reports/store/actions.js9
-rw-r--r--app/assets/javascripts/reports/store/getters.js24
-rw-r--r--app/assets/javascripts/reports/store/index.js2
-rw-r--r--app/assets/javascripts/reports/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/reports/store/mutations.js15
-rw-r--r--app/assets/javascripts/reports/store/state.js35
-rw-r--r--app/assets/javascripts/reports/store/utils.js60
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/reports/issue_body.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/reports/issues_list.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/reports/report_issues.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/reports/summary_row.vue9
-rw-r--r--app/assets/stylesheets/framework/blocks.scss24
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss41
-rw-r--r--changelogs/unreleased/45318-junit-FE.yml5
-rw-r--r--locale/gitlab.pot42
-rw-r--r--spec/javascripts/reports/components/grouped_test_reports_app_spec.js143
-rw-r--r--spec/javascripts/reports/components/modal_spec.js45
-rw-r--r--spec/javascripts/reports/components/test_issue_body_spec.js72
-rw-r--r--spec/javascripts/reports/mock_data/mock_data.js8
-rw-r--r--spec/javascripts/reports/mock_data/new_and_fixed_failures_report.json1
-rw-r--r--spec/javascripts/reports/mock_data/new_failures_report.json1
-rw-r--r--spec/javascripts/reports/mock_data/no_failures_report.json1
-rw-r--r--spec/javascripts/reports/store/actions_spec.js29
-rw-r--r--spec/javascripts/reports/store/mutations_spec.js27
-rw-r--r--spec/javascripts/reports/store/utils_spec.js138
-rw-r--r--spec/javascripts/vue_shared/components/code_block_spec.js33
33 files changed, 1067 insertions, 4 deletions
diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
new file mode 100644
index 00000000000..d2e72e72421
--- /dev/null
+++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
@@ -0,0 +1,114 @@
+<script>
+ import { mapActions, mapGetters } 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: {
+ ...mapGetters([
+ 'reports',
+ 'summaryStatus',
+ 'isLoading',
+ 'hasError',
+ 'summaryCounts',
+ 'modalTitle',
+ 'modalData',
+ 'isCreatingNewIssue',
+ ]),
+
+ groupedSummaryText() {
+ if (this.isLoading) {
+ return s__('Reports|Test summary results are being parsed');
+ }
+
+ if (this.hasError || !this.summaryCounts) {
+ return s__('Reports|Test summary failed loading results');
+ }
+
+ return summaryTextBuilder(s__('Reports|Test summary'), this.summaryCounts);
+ },
+ },
+ 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>
diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue
new file mode 100644
index 00000000000..6257ebf71fc
--- /dev/null
+++ b/app/assets/javascripts/reports/components/modal.vue
@@ -0,0 +1,73 @@
+<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"
+ >
+ <label class="col-sm-2 text-right font-weight-bold">
+ {{ field.text }}:
+ </label>
+
+ <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>
diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue
new file mode 100644
index 00000000000..13b7fdf58bc
--- /dev/null
+++ b/app/assets/javascripts/reports/components/test_issue_body.vue
@@ -0,0 +1,48 @@
+<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']),
+ handleIssueClick() {
+ const { issue, status, openModal } = this;
+ openModal({ issue, status });
+ },
+ },
+ };
+</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="handleIssueClick()"
+ >
+ <div
+ v-if="isNew"
+ class="badge badge-danger append-right-5"
+ >
+ {{ s__('New') }}
+ </div>{{ issue.name }}
+ </button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js
new file mode 100644
index 00000000000..807ecb1039e
--- /dev/null
+++ b/app/assets/javascripts/reports/constants.js
@@ -0,0 +1,16 @@
+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';
diff --git a/app/assets/javascripts/reports/store/actions.js b/app/assets/javascripts/reports/store/actions.js
index 15c077b0fd8..edbf860ecc6 100644
--- a/app/assets/javascripts/reports/store/actions.js
+++ b/app/assets/javascripts/reports/store/actions.js
@@ -1,4 +1,5 @@
import Visibility from 'visibilityjs';
+import $ from 'jquery';
import axios from '../../lib/utils/axios_utils';
import Poll from '../../lib/utils/poll';
import * as types from './mutation_types';
@@ -63,5 +64,13 @@ export const receiveReportsSuccess = ({ commit }, response) =>
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
export default () => {};
diff --git a/app/assets/javascripts/reports/store/getters.js b/app/assets/javascripts/reports/store/getters.js
new file mode 100644
index 00000000000..5fbafa201b5
--- /dev/null
+++ b/app/assets/javascripts/reports/store/getters.js
@@ -0,0 +1,24 @@
+import { LOADING, ERROR, SUCCESS } from '../constants';
+
+export const reports = state => state.reports;
+export const summaryCounts = state => state.summary;
+export const isLoading = state => state.isLoading;
+export const hasError = state => state.hasError;
+export const modalTitle = state => state.modal.title || '';
+export const modalData = state => state.modal.data || {};
+export const isCreatingNewIssue = state => state.modal.isLoading;
+
+export const summaryStatus = state => {
+ if (state.isLoading) {
+ return LOADING;
+ }
+
+ if (state.hasError) {
+ return ERROR;
+ }
+
+ return SUCCESS;
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/reports/store/index.js b/app/assets/javascripts/reports/store/index.js
index af4f9688fb4..9d8f7dc3b74 100644
--- a/app/assets/javascripts/reports/store/index.js
+++ b/app/assets/javascripts/reports/store/index.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
+import * as getters from './getters';
import mutations from './mutations';
import state from './state';
@@ -9,5 +10,6 @@ Vue.use(Vuex);
export default () => new Vuex.Store({
actions,
mutations,
+ getters,
state: state(),
});
diff --git a/app/assets/javascripts/reports/store/mutation_types.js b/app/assets/javascripts/reports/store/mutation_types.js
index 77722974c45..82bda31df5d 100644
--- a/app/assets/javascripts/reports/store/mutation_types.js
+++ b/app/assets/javascripts/reports/store/mutation_types.js
@@ -3,3 +3,5 @@ export const SET_ENDPOINT = 'SET_ENDPOINT';
export const REQUEST_REPORTS = 'REQUEST_REPORTS';
export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS';
export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR';
+export const SET_ISSUE_MODAL_DATA = 'SET_ISSUE_MODAL_DATA';
+
diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js
index d9d301826cf..6b900356bfc 100644
--- a/app/assets/javascripts/reports/store/mutations.js
+++ b/app/assets/javascripts/reports/store/mutations.js
@@ -16,11 +16,26 @@ export default {
state.summary.resolved = response.summary.resolved;
state.summary.failed = response.summary.failed;
+ state.status = response.status;
state.reports = response.suites;
},
[types.RECEIVE_REPORTS_ERROR](state) {
state.isLoading = false;
state.hasError = true;
+ state.reports = [];
+ },
+ [types.SET_ISSUE_MODAL_DATA](state, payload) {
+ state.modal.title = payload.issue.name;
+ state.modal.status = payload.status;
+
+ 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],
+ };
+ }
+ });
},
};
diff --git a/app/assets/javascripts/reports/store/state.js b/app/assets/javascripts/reports/store/state.js
index 97f9d0a6859..fd1819dbfea 100644
--- a/app/assets/javascripts/reports/store/state.js
+++ b/app/assets/javascripts/reports/store/state.js
@@ -1,9 +1,14 @@
+import { s__ } from '~/locale';
+import { fieldTypes } from '../constants';
+
export default () => ({
endpoint: null,
isLoading: false,
hasError: false,
+ status: null,
+
summary: {
total: 0,
resolved: 0,
@@ -25,4 +30,34 @@ export default () => ({
* }
*/
reports: [],
+
+ modal: {
+ title: null,
+
+ status: 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,
+ },
+ },
+ },
+
});
diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js
new file mode 100644
index 00000000000..caa4039af72
--- /dev/null
+++ b/app/assets/javascripts/reports/store/utils.js
@@ -0,0 +1,60 @@
+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;
+
+ if (failed) {
+ if (resolved) {
+ resultsString = sprintf(s__('Reports|%{failedString} and %{resolvedString}'), {
+ failedString,
+ resolvedString,
+ });
+ } else {
+ resultsString = failedString;
+ }
+ } else if (resolved) {
+ resultsString = resolvedString;
+ } else {
+ resultsString = s__('Reports|no changed test results');
+ }
+
+ 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;
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index b5de3dd6d73..80593d1f34a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -36,6 +36,7 @@ import {
notify,
SourceBranchRemovalStatus,
} from './dependencies';
+import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue';
import { setFaviconOverlay } from '../lib/utils/common_utils';
export default {
@@ -68,6 +69,7 @@ export default {
'mr-widget-auto-merge-failed': AutoMergeFailed,
'mr-widget-rebase': RebaseState,
SourceBranchRemovalStatus,
+ GroupedTestReportsApp,
},
props: {
mrData: {
@@ -260,6 +262,10 @@ export default {
:deployment="deployment"
/>
<div class="mr-section-container">
+ <grouped-test-reports-app
+ v-if="mr.testResultsPath"
+ :endpoint="mr.testResultsPath"
+ />
<div class="mr-widget-section">
<component
:is="componentName"
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index e84c436905d..672e5280b5e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -108,6 +108,8 @@ export default class MergeRequestStore {
this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false;
this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
+ this.testResultsPath = data.test_reports_path;
+
this.setState(data);
}
diff --git a/app/assets/javascripts/vue_shared/components/code_block.vue b/app/assets/javascripts/vue_shared/components/code_block.vue
new file mode 100644
index 00000000000..30920a2a86d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/code_block.vue
@@ -0,0 +1,16 @@
+<script>
+export default {
+ name: 'CodeBlock',
+ props: {
+ code: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <pre class="code-block build-trace-rounded">
+ <code class="bash">{{ code }}</code>
+ </pre>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/reports/issue_body.js b/app/assets/javascripts/vue_shared/components/reports/issue_body.js
index f2141e519da..54dfb7b16bf 100644
--- a/app/assets/javascripts/vue_shared/components/reports/issue_body.js
+++ b/app/assets/javascripts/vue_shared/components/reports/issue_body.js
@@ -1,3 +1,9 @@
-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,
+};
diff --git a/app/assets/javascripts/vue_shared/components/reports/issues_list.vue b/app/assets/javascripts/vue_shared/components/reports/issues_list.vue
index c01f77c2509..2545e84f932 100644
--- a/app/assets/javascripts/vue_shared/components/reports/issues_list.vue
+++ b/app/assets/javascripts/vue_shared/components/reports/issues_list.vue
@@ -18,6 +18,11 @@ export default {
failed: STATUS_FAILED,
neutral: STATUS_NEUTRAL,
props: {
+ newIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
unresolvedIssues: {
type: Array,
required: false,
@@ -45,6 +50,15 @@ export default {
<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
v-if="unresolvedIssues.length"
:component="component"
:issues="unresolvedIssues"
diff --git a/app/assets/javascripts/vue_shared/components/reports/report_issues.vue b/app/assets/javascripts/vue_shared/components/reports/report_issues.vue
index 2d1f3d82234..1f13e555b31 100644
--- a/app/assets/javascripts/vue_shared/components/reports/report_issues.vue
+++ b/app/assets/javascripts/vue_shared/components/reports/report_issues.vue
@@ -24,6 +24,11 @@ export default {
type: String,
required: true,
},
+ isNew: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
};
</script>
@@ -46,6 +51,7 @@ export default {
:is="component"
:issue="issue"
:status="issue.status || status"
+ :is-new="isNew"
/>
</li>
</ul>
diff --git a/app/assets/javascripts/vue_shared/components/reports/summary_row.vue b/app/assets/javascripts/vue_shared/components/reports/summary_row.vue
index 997bad960e2..28156d7c983 100644
--- a/app/assets/javascripts/vue_shared/components/reports/summary_row.vue
+++ b/app/assets/javascripts/vue_shared/components/reports/summary_row.vue
@@ -29,7 +29,8 @@ export default {
},
popoverOptions: {
type: Object,
- required: true,
+ required: false,
+ default: null,
},
},
computed: {
@@ -60,7 +61,11 @@ export default {
{{ summary }}
</div>
- <popover :options="popoverOptions" />
+ <popover
+ v-if="popoverOptions"
+ :options="popoverOptions"
+ />
+
</div>
</div>
</template>
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index 340fddd398b..12fc307130a 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -353,3 +353,27 @@
.flex-right {
margin-left: auto;
}
+
+.code-block {
+ background: $black;
+ color: $gray-darkest;
+ white-space: pre;
+ overflow-x: auto;
+ font-size: 12px;
+ border-radius: 0;
+ border: 0;
+ padding: $grid-size;
+
+ code {
+ background-color: inherit;
+ padding: inherit;
+ }
+
+ .bash {
+ display: block;
+ }
+
+ &.build-trace-rounded {
+ border-radius: $border-radius-base;
+ }
+}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 7bd0f0bf1e0..a355ceea7a0 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -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 {
position: relative;
border: 1px solid $border-color;
@@ -54,6 +87,14 @@
padding: 0;
}
+ .grouped-security-reports {
+ padding: 0;
+
+ > .media {
+ padding: $gl-padding;
+ }
+ }
+
form {
margin-bottom: 0;
diff --git a/changelogs/unreleased/45318-junit-FE.yml b/changelogs/unreleased/45318-junit-FE.yml
new file mode 100644
index 00000000000..bbc08f54484
--- /dev/null
+++ b/changelogs/unreleased/45318-junit-FE.yml
@@ -0,0 +1,5 @@
+---
+title: Adds frontend support to render test reports on the MR widget
+merge_request: 20936
+author:
+type: added
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 2d700fe8773..21a7c62debe 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -36,6 +36,16 @@ msgid_plural "%d exporters"
msgstr[0] ""
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_plural "%d issues"
msgstr[0] ""
@@ -4381,6 +4391,33 @@ msgstr ""
msgid "Reply to this email directly or %{view_it_on_gitlab}."
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"
msgstr ""
@@ -6264,6 +6301,11 @@ msgstr ""
msgid "or"
msgstr ""
+msgid "out of %d total test"
+msgid_plural "out of %d total tests"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "parent"
msgid_plural "parents"
msgstr[0] ""
diff --git a/spec/javascripts/reports/components/grouped_test_reports_app_spec.js b/spec/javascripts/reports/components/grouped_test_reports_app_spec.js
new file mode 100644
index 00000000000..6ca03620ca3
--- /dev/null
+++ b/spec/javascripts/reports/components/grouped_test_reports_app_spec.js
@@ -0,0 +1,143 @@
+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 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);
+ });
+ });
+});
diff --git a/spec/javascripts/reports/components/modal_spec.js b/spec/javascripts/reports/components/modal_spec.js
new file mode 100644
index 00000000000..3a567c40eca
--- /dev/null
+++ b/spec/javascripts/reports/components/modal_spec.js
@@ -0,0 +1,45 @@
+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');
+ });
+});
diff --git a/spec/javascripts/reports/components/test_issue_body_spec.js b/spec/javascripts/reports/components/test_issue_body_spec.js
new file mode 100644
index 00000000000..242eed114d9
--- /dev/null
+++ b/spec/javascripts/reports/components/test_issue_body_spec.js
@@ -0,0 +1,72 @@
+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,
+ status: commonProps.status,
+ });
+ });
+ });
+
+ 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);
+ });
+ });
+});
diff --git a/spec/javascripts/reports/mock_data/mock_data.js b/spec/javascripts/reports/mock_data/mock_data.js
new file mode 100644
index 00000000000..0d90253bad2
--- /dev/null
+++ b/spec/javascripts/reports/mock_data/mock_data.js
@@ -0,0 +1,8 @@
+// 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'",
+};
diff --git a/spec/javascripts/reports/mock_data/new_and_fixed_failures_report.json b/spec/javascripts/reports/mock_data/new_and_fixed_failures_report.json
new file mode 100644
index 00000000000..ceaf894375a
--- /dev/null
+++ b/spec/javascripts/reports/mock_data/new_and_fixed_failures_report.json
@@ -0,0 +1 @@
+{"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
diff --git a/spec/javascripts/reports/mock_data/new_failures_report.json b/spec/javascripts/reports/mock_data/new_failures_report.json
new file mode 100644
index 00000000000..930efe16f65
--- /dev/null
+++ b/spec/javascripts/reports/mock_data/new_failures_report.json
@@ -0,0 +1 @@
+{"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
diff --git a/spec/javascripts/reports/mock_data/no_failures_report.json b/spec/javascripts/reports/mock_data/no_failures_report.json
new file mode 100644
index 00000000000..6c0675ff7dc
--- /dev/null
+++ b/spec/javascripts/reports/mock_data/no_failures_report.json
@@ -0,0 +1 @@
+{"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
diff --git a/spec/javascripts/reports/store/actions_spec.js b/spec/javascripts/reports/store/actions_spec.js
index c714c5af156..3b2fcd0edba 100644
--- a/spec/javascripts/reports/store/actions_spec.js
+++ b/spec/javascripts/reports/store/actions_spec.js
@@ -8,6 +8,8 @@ import {
clearEtagPoll,
receiveReportsSuccess,
receiveReportsError,
+ openModal,
+ setModalData,
} from '~/reports/store/actions';
import state from '~/reports/store/state';
import * as types from '~/reports/store/mutation_types';
@@ -127,4 +129,31 @@ 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,
+ );
+ });
+ });
+
});
diff --git a/spec/javascripts/reports/store/mutations_spec.js b/spec/javascripts/reports/store/mutations_spec.js
index 3e0b15438c3..c3e2bb8e00c 100644
--- a/spec/javascripts/reports/store/mutations_spec.js
+++ b/spec/javascripts/reports/store/mutations_spec.js
@@ -1,6 +1,7 @@
import state from '~/reports/store/state';
import mutations from '~/reports/store/mutations';
import * as types from '~/reports/store/mutation_types';
+import { issue } from '../mock_data/mock_data';
describe('Reports Store Mutations', () => {
let stateCopy;
@@ -89,6 +90,7 @@ describe('Reports Store Mutations', () => {
beforeEach(() => {
mutations[types.RECEIVE_REPORTS_ERROR](stateCopy);
});
+
it('should reset isLoading', () => {
expect(stateCopy.isLoading).toEqual(false);
});
@@ -97,5 +99,30 @@ describe('Reports Store Mutations', () => {
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,
+ status: 'failed',
+ });
+ });
+
+ it('should set modal title', () => {
+ expect(stateCopy.modal.title).toEqual(issue.name);
+ });
+
+ it('should set modal status', () => {
+ expect(stateCopy.modal.status).toEqual('failed');
+ });
+
+ 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);
+ });
});
});
diff --git a/spec/javascripts/reports/store/utils_spec.js b/spec/javascripts/reports/store/utils_spec.js
new file mode 100644
index 00000000000..1679d120db2
--- /dev/null
+++ b/spec/javascripts/reports/store/utils_spec.js
@@ -0,0 +1,138 @@
+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);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/code_block_spec.js b/spec/javascripts/vue_shared/components/code_block_spec.js
new file mode 100644
index 00000000000..6b91a20ff76
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/code_block_spec.js
@@ -0,0 +1,33 @@
+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);
+ });
+});