Commit fd86f88d authored by Fatih Acet's avatar Fatih Acet

Merge branch '24792-artifact-based-view-for-junit-xml' into 'master'

Resolve "Artifact-based view for Junit XML"

Closes #24792

See merge request gitlab-org/gitlab!18255
parents c6b15aaf e524b752
// /test_report is an alias for show
import '../show/index';
<script>
import { mapActions, mapState } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import TestSuiteTable from './test_suite_table.vue';
import TestSummary from './test_summary.vue';
import TestSummaryTable from './test_summary_table.vue';
import store from '~/pipelines/stores/test_reports';
export default {
name: 'TestReports',
components: {
GlLoadingIcon,
TestSuiteTable,
TestSummary,
TestSummaryTable,
},
store,
computed: {
...mapState(['isLoading', 'selectedSuite', 'testReports']),
showSuite() {
return this.selectedSuite.total_count > 0;
},
showTests() {
return this.testReports.total_count > 0;
},
},
methods: {
...mapActions(['setSelectedSuite', 'removeSelectedSuite']),
summaryBackClick() {
this.removeSelectedSuite();
},
summaryTableRowClick(suite) {
this.setSelectedSuite(suite);
},
beforeEnterTransition() {
document.documentElement.style.overflowX = 'hidden';
},
afterLeaveTransition() {
document.documentElement.style.overflowX = '';
},
},
};
</script>
<template>
<div v-if="isLoading">
<gl-loading-icon size="lg" class="prepend-top-default js-loading-spinner" />
</div>
<div
v-else-if="!isLoading && showTests"
ref="container"
class="tests-detail position-relative js-tests-detail"
>
<transition
name="slide"
@before-enter="beforeEnterTransition"
@after-leave="afterLeaveTransition"
>
<div v-if="showSuite" key="detail" class="w-100 position-absolute slide-enter-to-element">
<test-summary :report="selectedSuite" show-back @on-back-click="summaryBackClick" />
<test-suite-table />
</div>
<div v-else key="summary" class="w-100 position-absolute slide-enter-from-element">
<test-summary :report="testReports" />
<test-summary-table @row-click="summaryTableRowClick" />
</div>
</transition>
</div>
<div v-else>
<div class="row prepend-top-default">
<div class="col-12">
<p class="js-no-tests-to-show">{{ s__('TestReports|There are no tests to show.') }}</p>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import store from '~/pipelines/stores/test_reports';
import { __ } from '~/locale';
export default {
name: 'TestsSuiteTable',
components: {
Icon,
},
store,
props: {
heading: {
type: String,
required: false,
default: __('Tests'),
},
},
computed: {
...mapGetters(['getSuiteTests']),
hasSuites() {
return this.getSuiteTests.length > 0;
},
},
};
</script>
<template>
<div>
<div class="row prepend-top-default">
<div class="col-12">
<h4>{{ heading }}</h4>
</div>
</div>
<div v-if="hasSuites" class="test-reports-table js-test-cases-table">
<div role="row" class="gl-responsive-table-row table-row-header font-weight-bold fgray">
<div role="rowheader" class="table-section section-20">
{{ __('Class') }}
</div>
<div role="rowheader" class="table-section section-20">
{{ __('Name') }}
</div>
<div role="rowheader" class="table-section section-10 text-center">
{{ __('Status') }}
</div>
<div role="rowheader" class="table-section flex-grow-1">
{{ __('Trace'), }}
</div>
<div role="rowheader" class="table-section section-10 text-right">
{{ __('Duration') }}
</div>
</div>
<div
v-for="(testCase, index) in getSuiteTests"
:key="index"
class="gl-responsive-table-row rounded align-items-md-start mt-sm-3 js-case-row"
>
<div class="table-section section-20 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ __('Class') }}</div>
<div class="table-mobile-content pr-md-1">{{ testCase.classname }}</div>
</div>
<div class="table-section section-20 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div>
<div class="table-mobile-content">{{ testCase.name }}</div>
</div>
<div class="table-section section-10 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ __('Status') }}</div>
<div class="table-mobile-content text-center">
<div
class="add-border ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center"
:class="`ci-status-icon-${testCase.status}`"
>
<icon :size="24" :name="testCase.icon" />
</div>
</div>
</div>
<div class="table-section flex-grow-1">
<div role="rowheader" class="table-mobile-header">{{ __('Trace'), }}</div>
<div class="table-mobile-content">
<pre
v-if="testCase.system_output"
class="build-trace build-trace-rounded text-left"
><code class="bash p-0">{{testCase.system_output}}</code></pre>
</div>
</div>
<div class="table-section section-10 section-wrap">
<div role="rowheader" class="table-mobile-header">
{{ __('Duration') }}
</div>
<div class="table-mobile-content text-right">
{{ testCase.formattedTime }}
</div>
</div>
</div>
</div>
<div v-else>
<p class="js-no-test-cases">{{ s__('TestReports|There are no test cases to display.') }}</p>
</div>
</div>
</template>
<script>
import { GlButton, GlLink, GlProgressBar } from '@gitlab/ui';
import { __ } from '~/locale';
import { formatTime } from '~/lib/utils/datetime_utility';
import Icon from '~/vue_shared/components/icon.vue';
export default {
name: 'TestSummary',
components: {
GlButton,
GlLink,
GlProgressBar,
Icon,
},
props: {
report: {
type: Object,
required: true,
},
showBack: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
heading() {
return this.report.name || __('Summary');
},
successPercentage() {
return Math.round((this.report.success_count / this.report.total_count) * 100) || 0;
},
formattedDuration() {
return formatTime(this.report.total_time * 1000);
},
progressBarVariant() {
if (this.successPercentage < 33) {
return 'danger';
}
if (this.successPercentage >= 33 && this.successPercentage < 66) {
return 'warning';
}
if (this.successPercentage >= 66 && this.successPercentage < 90) {
return 'primary';
}
return 'success';
},
},
methods: {
onBackClick() {
this.$emit('on-back-click');
},
},
};
</script>
<template>
<div>
<div class="row">
<div class="col-12 d-flex prepend-top-8 align-items-center">
<gl-button
v-if="showBack"
size="sm"
class="append-right-default js-back-button"
@click="onBackClick"
>
<icon name="angle-left" />
</gl-button>
<h4>{{ heading }}</h4>
</div>
</div>
<div class="row mt-2">
<div class="col-4 col-md">
<span class="js-total-tests">{{
sprintf(s__('TestReports|%{count} jobs'), { count: report.total_count })
}}</span>
</div>
<div class="col-4 col-md text-center text-md-center">
<span class="js-failed-tests">{{
sprintf(s__('TestReports|%{count} failures'), { count: report.failed_count })
}}</span>
</div>
<div class="col-4 col-md text-right text-md-center">
<span class="js-errored-tests">{{
sprintf(s__('TestReports|%{count} errors'), { count: report.error_count })
}}</span>
</div>
<div class="col-6 mt-3 col-md mt-md-0 text-md-center">
<span class="js-success-rate">{{
sprintf(s__('TestReports|%{rate}%{sign} success rate'), {
rate: successPercentage,
sign: '%',
})
}}</span>
</div>
<div class="col-6 mt-3 col-md mt-md-0 text-right">
<span class="js-duration">{{ formattedDuration }}</span>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<gl-progress-bar :value="successPercentage" :variant="progressBarVariant" height="10px" />
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { s__ } from '~/locale';
import store from '~/pipelines/stores/test_reports';
export default {
name: 'TestsSummaryTable',
store,
props: {
heading: {
type: String,
required: false,
default: s__('TestReports|Test suites'),
},
},
computed: {
...mapGetters(['getTestSuites']),
hasSuites() {
return this.getTestSuites.length > 0;
},
},
methods: {
tableRowClick(suite) {
this.$emit('row-click', suite);
},
},
};
</script>
<template>
<div>
<div class="row prepend-top-default">
<div class="col-12">
<h4>{{ heading }}</h4>
</div>
</div>
<div v-if="hasSuites" class="test-reports-table js-test-suites-table">
<div role="row" class="gl-responsive-table-row table-row-header font-weight-bold">
<div role="rowheader" class="table-section section-25 pl-3">
{{ __('Suite') }}
</div>
<div role="rowheader" class="table-section section-25">
{{ __('Duration') }}
</div>
<div role="rowheader" class="table-section section-10 text-center">
{{ __('Failed') }}
</div>
<div role="rowheader" class="table-section section-10 text-center">
{{ __('Errors'), }}
</div>
<div role="rowheader" class="table-section section-10 text-center">
{{ __('Skipped'), }}
</div>
<div role="rowheader" class="table-section section-10 text-center">
{{ __('Passed'), }}
</div>
<div role="rowheader" class="table-section section-10 pr-3 text-right">
{{ __('Total') }}
</div>
</div>
<div
v-for="(testSuite, index) in getTestSuites"
:key="index"
role="row"
class="gl-responsive-table-row test-reports-summary-row rounded cursor-pointer js-suite-row"
@click="tableRowClick(testSuite)"
>
<div class="table-section section-25">
<div role="rowheader" class="table-mobile-header font-weight-bold">
{{ __('Suite') }}
</div>
<div class="table-mobile-content test-reports-summary-suite cgray pl-3">
{{ testSuite.name }}
</div>
</div>
<div class="table-section section-25">
<div role="rowheader" class="table-mobile-header font-weight-bold">
{{ __('Duration') }}
</div>
<div class="table-mobile-content text-md-left">
{{ testSuite.formattedTime }}
</div>
</div>
<div class="table-section section-10 text-center">
<div role="rowheader" class="table-mobile-header font-weight-bold">
{{ __('Failed') }}
</div>
<div class="table-mobile-content">{{ testSuite.failed_count }}</div>
</div>
<div class="table-section section-10 text-center">
<div role="rowheader" class="table-mobile-header font-weight-bold">
{{ __('Errors') }}
</div>
<div class="table-mobile-content">{{ testSuite.error_count }}</div>
</div>
<div class="table-section section-10 text-center">
<div role="rowheader" class="table-mobile-header font-weight-bold">
{{ __('Skipped') }}
</div>
<div class="table-mobile-content">{{ testSuite.skipped_count }}</div>
</div>
<div class="table-section section-10 text-center">
<div role="rowheader" class="table-mobile-header font-weight-bold">
{{ __('Passed') }}
</div>
<div class="table-mobile-content">{{ testSuite.success_count }}</div>
</div>
<div class="table-section section-10 text-right pr-md-3">
<div role="rowheader" class="table-mobile-header font-weight-bold">
{{ __('Total') }}
</div>
<div class="table-mobile-content">{{ testSuite.total_count }}</div>
</div>
</div>
</div>
<div v-else>
<p class="js-no-tests-suites">{{ s__('TestReports|There are no test suites to show.') }}</p>
</div>
</div>
</template>
export const CANCEL_REQUEST = 'CANCEL_REQUEST'; export const CANCEL_REQUEST = 'CANCEL_REQUEST';
export const PIPELINES_TABLE = 'PIPELINES_TABLE'; export const PIPELINES_TABLE = 'PIPELINES_TABLE';
export const LAYOUT_CHANGE_DELAY = 300; export const LAYOUT_CHANGE_DELAY = 300;
export const TestStatus = {
FAILED: 'failed',
SKIPPED: 'skipped',
SUCCESS: 'success',
};
...@@ -7,6 +7,8 @@ import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; ...@@ -7,6 +7,8 @@ import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator'; import PipelinesMediator from './pipeline_details_mediator';
import pipelineHeader from './components/header_component.vue'; import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub'; import eventHub from './event_hub';
import TestReports from './components/test_reports/test_reports.vue';
import testReportsStore from './stores/test_reports';
Vue.use(Translate); Vue.use(Translate);
...@@ -17,7 +19,7 @@ export default () => { ...@@ -17,7 +19,7 @@ export default () => {
mediator.fetchPipeline(); mediator.fetchPipeline();
// eslint-disable-next-line // eslint-disable-next-line no-new
new Vue({ new Vue({
el: '#js-pipeline-graph-vue', el: '#js-pipeline-graph-vue',
components: { components: {
...@@ -47,7 +49,7 @@ export default () => { ...@@ -47,7 +49,7 @@ export default () => {
}, },
}); });
// eslint-disable-next-line // eslint-disable-next-line no-new
new Vue({ new Vue({
el: '#js-pipeline-header-vue', el: '#js-pipeline-header-vue',
components: { components: {
...@@ -81,4 +83,23 @@ export default () => { ...@@ -81,4 +83,23 @@ export default () => {
}); });
}, },
}); });
const testReportsEnabled =
window.gon && window.gon.features && window.gon.features.junitPipelineView;
if (testReportsEnabled) {
testReportsStore.dispatch('setEndpoint', dataset.testReportEndpoint);
testReportsStore.dispatch('fetchReports');
// eslint-disable-next-line no-new
new Vue({
el: '#js-pipeline-tests-detail',
components: {
TestReports,
},
render(createElement) {
return createElement('test-reports');
},
});
}
}; };
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
import createFlash from '~/flash';
import { s__ } from '~/locale';
export const setEndpoint = ({ commit }, data) => commit(types.SET_ENDPOINT, data);
export const fetchReports = ({ state, commit, dispatch }) => {
dispatch('toggleLoading');
return axios
.get(state.endpoint)
.then(response => {
const { data } = response;
commit(types.SET_REPORTS, data);
})
.catch(() => {
createFlash(s__('TestReports|There was an error fetching the test reports.'));
})
.finally(() => {
dispatch('toggleLoading');
});
};
export const setSelectedSuite = ({ commit }, data) => commit(types.SET_SELECTED_SUITE, data);
export const removeSelectedSuite = ({ commit }) => commit(types.SET_SELECTED_SUITE, {});
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import { addIconStatus, formattedTime, sortTestCases } from './utils';
export const getTestSuites = state => {
const { test_suites: testSuites = [] } = state.testReports;
return testSuites.map(suite => ({
...suite,
formattedTime: formattedTime(suite.total_time),
}));
};
export const getSuiteTests = state => {
const { selectedSuite } = state;
if (selectedSuite.test_cases) {
return selectedSuite.test_cases.sort(sortTestCases).map(addIconStatus);
}
return [];
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import state from './state';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
Vue.use(Vuex);
export default new Vuex.Store({
actions,
getters,
mutations,
state,
});
export const SET_ENDPOINT = 'SET_ENDPOINT';
export const SET_REPORTS = 'SET_REPORTS';
export const SET_SELECTED_SUITE = 'SET_SELECTED_SUITE';
export const TOGGLE_LOADING = 'TOGGLE_LOADING';
import * as types from './mutation_types';
export default {
[types.SET_ENDPOINT](state, endpoint) {
Object.assign(state, { endpoint });
},
[types.SET_REPORTS](state, testReports) {
Object.assign(state, { testReports });
},
[types.SET_SELECTED_SUITE](state, selectedSuite) {
Object.assign(state, { selectedSuite });
},
[types.TOGGLE_LOADING](state) {
Object.assign(state, { isLoading: !state.isLoading });
},
};
export default () => ({
endpoint: '',
testReports: {},
selectedSuite: {},
isLoading: false,
});
import { TestStatus } from '~/pipelines/constants';
import { formatTime } from '~/lib/utils/datetime_utility';
function iconForTestStatus(status) {
switch (status) {
case 'success':
return 'status_success_borderless';
case 'failed':
return 'status_failed_borderless';
default:
return 'status_skipped_borderless';
}
}
export const formattedTime = timeInSeconds => formatTime(timeInSeconds * 1000);
export const addIconStatus = testCase => ({
...testCase,
icon: iconForTestStatus(testCase.status),
formattedTime: formattedTime(testCase.execution_time),
});
export const sortTestCases = (a, b) => {
if (a.status === b.status) {
return 0;
}
switch (b.status) {
case TestStatus.SUCCESS:
return -1;
case TestStatus.FAILED:
return 1;
default:
return 0;
}
};
...@@ -11,3 +11,27 @@ ...@@ -11,3 +11,27 @@
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
.slide-enter-from-element {
&.slide-enter,
&.slide-leave-to {
transform: translateX(-150%);
}
}
.slide-enter-to-element {
&.slide-enter,
&.slide-leave-to {
transform: translateX(150%);
}
}
.slide-enter-active,
.slide-leave-active {
transition: transform 300ms ease-out;
}
.slide-enter-to,
.slide-leave {
transform: translateX(0);
}
...@@ -1082,3 +1082,25 @@ button.mini-pipeline-graph-dropdown-toggle { ...@@ -1082,3 +1082,25 @@ button.mini-pipeline-graph-dropdown-toggle {
.legend-success { .legend-success {
color: $green-500; color: $green-500;
} }
.test-reports-table {
color: $gray-700;
.test-reports-summary-row {
&:hover {
background-color: $gray-light;
.test-reports-summary-suite {
text-decoration: underline;
}
}
}
.build-trace {
@include build-trace();
}
}
.progress-bar.bg-primary {
background-color: $blue-500 !important;
}
...@@ -12,6 +12,7 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -12,6 +12,7 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action do before_action do
push_frontend_feature_flag(:hide_dismissed_vulnerabilities) push_frontend_feature_flag(:hide_dismissed_vulnerabilities)
push_frontend_feature_flag(:junit_pipeline_view)
end end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show] around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
......
- test_reports_enabled = Feature.enabled?(:junit_pipeline_view)
.tabs-holder .tabs-holder
%ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs %ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs
%li.js-pipeline-tab-link %li.js-pipeline-tab-link
...@@ -12,6 +14,11 @@ ...@@ -12,6 +14,11 @@
= link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do = link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do
= _('Failed Jobs') = _('Failed Jobs')
%span.badge.badge-pill.js-failures-counter= @pipeline.failed_builds.count %span.badge.badge-pill.js-failures-counter= @pipeline.failed_builds.count
- if test_reports_enabled
%li.js-tests-tab-link
= link_to test_report_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-tests', action: 'test_report', toggle: 'tab' }, class: 'test-tab' do
= s_('TestReports|Tests')
%span.badge.badge-pill= pipeline.test_reports.total_count
= render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project = render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project
.tab-content .tab-content
...@@ -71,4 +78,7 @@ ...@@ -71,4 +78,7 @@
%pre.build-trace.build-trace-rounded %pre.build-trace.build-trace-rounded
%code.bash.js-build-output %code.bash.js-build-output
= build_summary(build) = build_summary(build)
#js-tab-tests.tab-pane
#js-pipeline-tests-detail
= render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project = render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project
...@@ -20,4 +20,5 @@ ...@@ -20,4 +20,5 @@
- else - else
= render "projects/pipelines/with_tabs", pipeline: @pipeline = render "projects/pipelines/with_tabs", pipeline: @pipeline
.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json) } } .js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json),
test_report_endpoint: test_report_project_pipeline_path(@project, @pipeline, format: :json) } }
---
title: Added Tests tab to pipeline detail that contains a UI for browsing test reports
produced by JUnit
merge_request: 18255
author:
type: added
...@@ -3266,6 +3266,9 @@ msgstr "" ...@@ -3266,6 +3266,9 @@ msgstr ""
msgid "CiVariable|Validation failed" msgid "CiVariable|Validation failed"
msgstr "" msgstr ""
msgid "Class"
msgstr ""
msgid "Classification Label (optional)" msgid "Classification Label (optional)"
msgstr "" msgstr ""
...@@ -5873,6 +5876,9 @@ msgstr "" ...@@ -5873,6 +5876,9 @@ msgstr ""
msgid "Due date" msgid "Due date"
msgstr "" msgstr ""
msgid "Duration"
msgstr ""
msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below." msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
msgstr "" msgstr ""
...@@ -11738,6 +11744,9 @@ msgstr "" ...@@ -11738,6 +11744,9 @@ msgstr ""
msgid "Part of merge request changes" msgid "Part of merge request changes"
msgstr "" msgstr ""
msgid "Passed"
msgstr ""
msgid "Password" msgid "Password"
msgstr "" msgstr ""
...@@ -15501,6 +15510,9 @@ msgstr "" ...@@ -15501,6 +15510,9 @@ msgstr ""
msgid "Skip this for now" msgid "Skip this for now"
msgstr "" msgstr ""
msgid "Skipped"
msgstr ""
msgid "Slack application" msgid "Slack application"
msgstr "" msgstr ""
...@@ -16302,6 +16314,12 @@ msgstr "" ...@@ -16302,6 +16314,12 @@ msgstr ""
msgid "Suggestions:" msgid "Suggestions:"
msgstr "" msgstr ""
msgid "Suite"
msgstr ""
msgid "Summary"
msgstr ""
msgid "Sunday" msgid "Sunday"
msgstr "" msgstr ""
...@@ -16530,6 +16548,39 @@ msgstr "" ...@@ -16530,6 +16548,39 @@ msgstr ""
msgid "TestHooks|Ensure the wiki is enabled and has pages." msgid "TestHooks|Ensure the wiki is enabled and has pages."
msgstr "" msgstr ""
msgid "TestReports|%{count} errors"
msgstr ""
msgid "TestReports|%{count} failures"
msgstr ""
msgid "TestReports|%{count} jobs"
msgstr ""
msgid "TestReports|%{rate}%{sign} success rate"
msgstr ""
msgid "TestReports|Test suites"
msgstr ""
msgid "TestReports|Tests"
msgstr ""
msgid "TestReports|There are no test cases to display."
msgstr ""
msgid "TestReports|There are no test suites to show."
msgstr ""
msgid "TestReports|There are no tests to show."
msgstr ""
msgid "TestReports|There was an error fetching the test reports."
msgstr ""
msgid "Tests"
msgstr ""
msgid "Thank you for signing up for your free trial! You will get additional instructions in your inbox shortly." msgid "Thank you for signing up for your free trial! You will get additional instructions in your inbox shortly."
msgstr "" msgstr ""
...@@ -17715,6 +17766,9 @@ msgstr "" ...@@ -17715,6 +17766,9 @@ msgstr ""
msgid "Total: %{total}" msgid "Total: %{total}"
msgstr "" msgstr ""
msgid "Trace"
msgstr ""
msgid "Tracing" msgid "Tracing"
msgstr "" msgstr ""
......
import { formatTime } from '~/lib/utils/datetime_utility';
import { TestStatus } from '~/pipelines/constants';
export const testCases = [
{
classname: 'spec.test_spec',
execution_time: 0.000748,
name: 'Test#subtract when a is 1 and b is 2 raises an error',
stack_trace: null,
status: TestStatus.SUCCESS,
system_output: null,
},
{
classname: 'spec.test_spec',
execution_time: 0.000064,
name: 'Test#subtract when a is 2 and b is 1 returns correct result',
stack_trace: null,
status: TestStatus.SUCCESS,
system_output: null,
},
{
classname: 'spec.test_spec',
execution_time: 0.009292,
name: 'Test#sum when a is 1 and b is 2 returns summary',
stack_trace: null,
status: TestStatus.FAILED,
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)>'",
},
{
classname: 'spec.test_spec',
execution_time: 0.00018,
name: 'Test#sum when a is 100 and b is 200 returns summary',
stack_trace: null,
status: TestStatus.FAILED,
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)>'",
},
{
classname: 'spec.test_spec',
execution_time: 0,
name: 'Test#skipped text',
stack_trace: null,
status: TestStatus.SKIPPED,
system_output: null,
},
];
export const testCasesFormatted = [
{
...testCases[2],
icon: 'status_failed_borderless',
formattedTime: formatTime(testCases[0].execution_time * 1000),
},
{
...testCases[3],
icon: 'status_failed_borderless',
formattedTime: formatTime(testCases[1].execution_time * 1000),
},
{
...testCases[4],
icon: 'status_skipped_borderless',
formattedTime: formatTime(testCases[2].execution_time * 1000),
},
{
...testCases[0],
icon: 'status_success_borderless',
formattedTime: formatTime(testCases[3].execution_time * 1000),
},
{
...testCases[1],
icon: 'status_success_borderless',
formattedTime: formatTime(testCases[4].execution_time * 1000),
},
];
export const testSuites = [
{
error_count: 0,
failed_count: 2,
name: 'rspec:osx',
skipped_count: 0,
success_count: 2,
test_cases: testCases,
total_count: 4,
total_time: 60,
},
{
error_count: 0,
failed_count: 10,
name: 'rspec:osx',
skipped_count: 0,
success_count: 50,
test_cases: [],
total_count: 60,
total_time: 0.010284,
},
];
export const testSuitesFormatted = testSuites.map(x => ({
...x,
formattedTime: formatTime(x.total_time * 1000),
}));
export const testReports = {
error_count: 0,
failed_count: 2,
skipped_count: 0,
success_count: 2,
test_suites: testSuites,
total_count: 4,
total_time: 0.010284,
};
export const testReportsWithNoSuites = {
error_count: 0,
failed_count: 2,
skipped_count: 0,
success_count: 2,
test_suites: [],
total_count: 4,
total_time: 0.010284,
};
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import * as actions from '~/pipelines/stores/test_reports/actions';
import * as types from '~/pipelines/stores/test_reports/mutation_types';
import { TEST_HOST } from '../../../helpers/test_constants';
import testAction from '../../../helpers/vuex_action_helper';
import createFlash from '~/flash';
import { testReports } from '../mock_data';
jest.mock('~/flash.js');
describe('Actions TestReports Store', () => {
let mock;
let state;
const endpoint = `${TEST_HOST}/test_reports.json`;
const defaultState = {
endpoint,
testReports: {},
selectedSuite: {},
};
beforeEach(() => {
mock = new MockAdapter(axios);
state = defaultState;
});
afterEach(() => {
mock.restore();
});
describe('fetch reports', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/test_reports.json`).replyOnce(200, testReports, {});
});
it('sets testReports and shows tests', done => {
testAction(
actions.fetchReports,
null,
state,
[{ type: types.SET_REPORTS, payload: testReports }],
[{ type: 'toggleLoading' }, { type: 'toggleLoading' }],
done,
);
});
it('should create flash on API error', done => {
testAction(
actions.fetchReports,
null,
{
endpoint: null,
},
[],
[{ type: 'toggleLoading' }, { type: 'toggleLoading' }],
() => {
expect(createFlash).toHaveBeenCalled();
done();
},
);
});
});
describe('set selected suite', () => {
const selectedSuite = testReports.test_suites[0];
it('sets selectedSuite', done => {
testAction(
actions.setSelectedSuite,
selectedSuite,
state,
[{ type: types.SET_SELECTED_SUITE, payload: selectedSuite }],
[],
done,
);
});
});
describe('remove selected suite', () => {
it('sets selectedSuite to {}', done => {
testAction(
actions.removeSelectedSuite,
{},
state,
[{ type: types.SET_SELECTED_SUITE, payload: {} }],
[],
done,
);
});
});
describe('toggles loading', () => {
it('sets isLoading to true', done => {
testAction(actions.toggleLoading, {}, state, [{ type: types.TOGGLE_LOADING }], [], done);
});
it('toggles isLoading to false', done => {
testAction(
actions.toggleLoading,
{},
{ ...state, isLoading: true },
[{ type: types.TOGGLE_LOADING }],
[],
done,
);
});
});
});
import * as getters from '~/pipelines/stores/test_reports/getters';
import { testReports, testSuitesFormatted, testCasesFormatted } from '../mock_data';
describe('Getters TestReports Store', () => {
let state;
const defaultState = {
testReports,
selectedSuite: testReports.test_suites[0],
};
const emptyState = {
testReports: {},
selectedSuite: {},
};
beforeEach(() => {
state = {
testReports,
};
});
const setupState = (testState = defaultState) => {
state = testState;
};
describe('getTestSuites', () => {
it('should return the test suites', () => {
setupState();
expect(getters.getTestSuites(state)).toEqual(testSuitesFormatted);
});
it('should return an empty array when testReports is empty', () => {
setupState(emptyState);
expect(getters.getTestSuites(state)).toEqual([]);
});
});
describe('getSuiteTests', () => {
it('should return the test cases inside the suite', () => {
setupState();
expect(getters.getSuiteTests(state)).toEqual(testCasesFormatted);
});
it('should return an empty array when testReports is empty', () => {
setupState(emptyState);
expect(getters.getSuiteTests(state)).toEqual([]);
});
});
});
import * as types from '~/pipelines/stores/test_reports/mutation_types';
import mutations from '~/pipelines/stores/test_reports/mutations';
import { testReports, testSuites } from '../mock_data';
describe('Mutations TestReports Store', () => {
let mockState;
const defaultState = {
endpoint: '',
testReports: {},
selectedSuite: {},
isLoading: false,
};
beforeEach(() => {
mockState = defaultState;
});
describe('set endpoint', () => {
it('should set endpoint', () => {
const expectedState = Object.assign({}, mockState, { endpoint: 'foo' });
mutations[types.SET_ENDPOINT](mockState, 'foo');
expect(mockState.endpoint).toEqual(expectedState.endpoint);
});
});
describe('set reports', () => {
it('should set testReports', () => {
const expectedState = Object.assign({}, mockState, { testReports });
mutations[types.SET_REPORTS](mockState, testReports);
expect(mockState.testReports).toEqual(expectedState.testReports);
});
});
describe('set selected suite', () => {
it('should set selectedSuite', () => {
const expectedState = Object.assign({}, mockState, { selectedSuite: testSuites[0] });
mutations[types.SET_SELECTED_SUITE](mockState, testSuites[0]);
expect(mockState.selectedSuite).toEqual(expectedState.selectedSuite);
});
});
describe('toggle loading', () => {
it('should set to true', () => {
const expectedState = Object.assign({}, mockState, { isLoading: true });
mutations[types.TOGGLE_LOADING](mockState);
expect(mockState.isLoading).toEqual(expectedState.isLoading);
});
it('should toggle back to false', () => {
const expectedState = Object.assign({}, mockState, { isLoading: false });
mockState.isLoading = true;
mutations[types.TOGGLE_LOADING](mockState);
expect(mockState.isLoading).toEqual(expectedState.isLoading);
});
});
});
import Vuex from 'vuex';
import TestReports from '~/pipelines/components/test_reports/test_reports.vue';
import { shallowMount } from '@vue/test-utils';
import { testReports } from './mock_data';
import * as actions from '~/pipelines/stores/test_reports/actions';
describe('Test reports app', () => {
let wrapper;
let store;
const loadingSpinner = () => wrapper.find('.js-loading-spinner');
const testsDetail = () => wrapper.find('.js-tests-detail');
const noTestsToShow = () => wrapper.find('.js-no-tests-to-show');
const createComponent = (state = {}) => {
store = new Vuex.Store({
state: {
isLoading: false,
selectedSuite: {},
testReports,
...state,
},
actions,
});
wrapper = shallowMount(TestReports, {
store,
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when loading', () => {
beforeEach(() => createComponent({ isLoading: true }));
it('shows the loading spinner', () => {
expect(noTestsToShow().exists()).toBe(false);
expect(testsDetail().exists()).toBe(false);
expect(loadingSpinner().exists()).toBe(true);
});
});
describe('when the api returns no data', () => {
beforeEach(() => createComponent({ testReports: {} }));
it('displays that there are no tests to show', () => {
const noTests = noTestsToShow();
expect(noTests.exists()).toBe(true);
expect(noTests.text()).toBe('There are no tests to show.');
});
});
describe('when the api returns data', () => {
beforeEach(() => createComponent());
it('sets testReports and shows tests', () => {
expect(wrapper.vm.testReports).toBeTruthy();
expect(wrapper.vm.showTests).toBeTruthy();
});
});
});
import Vuex from 'vuex';
import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue';
import * as getters from '~/pipelines/stores/test_reports/getters';
import { TestStatus } from '~/pipelines/constants';
import { shallowMount } from '@vue/test-utils';
import { testSuites, testCases } from './mock_data';
describe('Test reports suite table', () => {
let wrapper;
let store;
const noCasesMessage = () => wrapper.find('.js-no-test-cases');
const allCaseRows = () => wrapper.findAll('.js-case-row');
const findCaseRowAtIndex = index => wrapper.findAll('.js-case-row').at(index);
const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`);
const createComponent = (suite = testSuites[0]) => {
store = new Vuex.Store({
state: {
selectedSuite: suite,
},
getters,
});
wrapper = shallowMount(SuiteTable, {
store,
});
};
afterEach(() => {
wrapper.destroy();
});
describe('should not render', () => {
beforeEach(() => createComponent([]));
it('a table when there are no test cases', () => {
expect(noCasesMessage().exists()).toBe(true);
});
});
describe('when a test suite is supplied', () => {
beforeEach(() => createComponent());
it('renders the correct number of rows', () => {
expect(allCaseRows().length).toBe(testCases.length);
});
it('renders the failed tests first', () => {
const failedCaseNames = testCases
.filter(x => x.status === TestStatus.FAILED)
.map(x => x.name);
const skippedCaseNames = testCases
.filter(x => x.status === TestStatus.SKIPPED)
.map(x => x.name);
expect(findCaseRowAtIndex(0).text()).toContain(failedCaseNames[0]);
expect(findCaseRowAtIndex(1).text()).toContain(failedCaseNames[1]);
expect(findCaseRowAtIndex(2).text()).toContain(skippedCaseNames[0]);
});
it('renders the correct icon for each status', () => {
const failedTest = testCases.findIndex(x => x.status === TestStatus.FAILED);
const skippedTest = testCases.findIndex(x => x.status === TestStatus.SKIPPED);
const successTest = testCases.findIndex(x => x.status === TestStatus.SUCCESS);
const failedRow = findCaseRowAtIndex(failedTest);
const skippedRow = findCaseRowAtIndex(skippedTest);
const successRow = findCaseRowAtIndex(successTest);
expect(findIconForRow(failedRow, TestStatus.FAILED).exists()).toBe(true);
expect(findIconForRow(skippedRow, TestStatus.SKIPPED).exists()).toBe(true);
expect(findIconForRow(successRow, TestStatus.SUCCESS).exists()).toBe(true);
});
});
});
import Summary from '~/pipelines/components/test_reports/test_summary.vue';
import { mount } from '@vue/test-utils';
import { testSuites } from './mock_data';
describe('Test reports summary', () => {
let wrapper;
const backButton = () => wrapper.find('.js-back-button');
const totalTests = () => wrapper.find('.js-total-tests');
const failedTests = () => wrapper.find('.js-failed-tests');
const erroredTests = () => wrapper.find('.js-errored-tests');
const successRate = () => wrapper.find('.js-success-rate');
const duration = () => wrapper.find('.js-duration');
const defaultProps = {
report: testSuites[0],
showBack: false,
};
const createComponent = props => {
wrapper = mount(Summary, {
propsData: {
...defaultProps,
...props,
},
});
};
describe('should not render', () => {
beforeEach(() => {
createComponent();
});
it('a back button by default', () => {
expect(backButton().exists()).toBe(false);
});
});
describe('should render', () => {
beforeEach(() => {
createComponent();
});
it('a back button and emit on-back-click event', () => {
createComponent({
showBack: true,
});
expect(backButton().exists()).toBe(true);
});
});
describe('when a report is supplied', () => {
beforeEach(() => {
createComponent();
});
it('displays the correct total', () => {
expect(totalTests().text()).toBe('4 jobs');
});
it('displays the correct failure count', () => {
expect(failedTests().text()).toBe('2 failures');
});
it('displays the correct error count', () => {
expect(erroredTests().text()).toBe('0 errors');
});
it('calculates and displays percentages correctly', () => {
expect(successRate().text()).toBe('50% success rate');
});
it('displays the correctly formatted duration', () => {
expect(duration().text()).toBe('00:01:00');
});
});
});
import Vuex from 'vuex';
import SummaryTable from '~/pipelines/components/test_reports/test_summary_table.vue';
import * as getters from '~/pipelines/stores/test_reports/getters';
import { mount, createLocalVue } from '@vue/test-utils';
import { testReports, testReportsWithNoSuites } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Test reports summary table', () => {
let wrapper;
let store;
const allSuitesRows = () => wrapper.findAll('.js-suite-row');
const noSuitesToShow = () => wrapper.find('.js-no-tests-suites');
const defaultProps = {
testReports,
};
const createComponent = (reports = null) => {
store = new Vuex.Store({
state: {
testReports: reports || testReports,
},
getters,
});
wrapper = mount(SummaryTable, {
propsData: defaultProps,
store,
localVue,
});
};
describe('when test reports are supplied', () => {
beforeEach(() => createComponent());
it('renders the correct number of rows', () => {
expect(noSuitesToShow().exists()).toBe(false);
expect(allSuitesRows().length).toBe(testReports.test_suites.length);
});
});
describe('when there are no test suites', () => {
beforeEach(() => {
createComponent({ testReportsWithNoSuites });
});
it('displays the no suites to show message', () => {
expect(noSuitesToShow().exists()).toBe(true);
});
});
});
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