diff options
59 files changed, 1712 insertions, 124 deletions
diff --git a/.gitlab/ci/docs.gitlab-ci.yml b/.gitlab/ci/docs.gitlab-ci.yml index 00013d5d2ba..07375fca611 100644 --- a/.gitlab/ci/docs.gitlab-ci.yml +++ b/.gitlab/ci/docs.gitlab-ci.yml @@ -75,7 +75,7 @@ graphql-reference-verify: - .default-cache - .default-only - .default-before_script - - .only:changes-graphql + - .only:changes-code-backstage-qa - .use-pg9 stage: test needs: ["setup-test-env"] diff --git a/.gitlab/ci/global.gitlab-ci.yml b/.gitlab/ci/global.gitlab-ci.yml index f46638e0c4c..d746d8fe030 100644 --- a/.gitlab/ci/global.gitlab-ci.yml +++ b/.gitlab/ci/global.gitlab-ci.yml @@ -93,6 +93,7 @@ - "config.ru" - "{package.json,yarn.lock}" - "{,ee/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*" + - "doc/api/graphql/**/*" .backstage-patterns: &backstage-patterns - "Dangerfile" @@ -111,11 +112,6 @@ - "doc/**/*" - ".markdownlint.json" -.graphql-patterns: &graphql-patterns - - "{,ee/}app/graphql/**/*" - - "{,ee/}lib/gitlab/graphql/**/*" - - "doc/api/graphql/**/*" - .only:changes-code: only: changes: *code-patterns @@ -128,10 +124,6 @@ only: changes: *docs-patterns -.only:changes-graphql: - only: - changes: *graphql-patterns - .only:changes-code-backstage: only: changes: @@ -147,6 +139,7 @@ - "config.ru" - "{package.json,yarn.lock}" - "{,ee/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*" + - "doc/api/graphql/**/*" # Backstage changes - "Dangerfile" - "danger/**/*" @@ -170,6 +163,7 @@ - "config.ru" - "{package.json,yarn.lock}" - "{,ee/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*" + - "doc/api/graphql/**/*" # QA changes - ".dockerignore" - "qa/**/*" @@ -189,6 +183,7 @@ - "config.ru" - "{package.json,yarn.lock}" - "{,ee/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*" + - "doc/api/graphql/**/*" # Backstage changes - "Dangerfile" - "danger/**/*" diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 832e9afb6c1..df484cbb1d9 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -1.70.0 +1.71.0 diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 7223b5c0d43..b967a790fac 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -17,14 +17,7 @@ function MergeRequest(opts) { this.opts = opts != null ? opts : {}; this.submitNoteForm = this.submitNoteForm.bind(this); this.$el = $('.merge-request'); - this.$('.show-all-commits').on( - 'click', - (function(_this) { - return function() { - return _this.showAllCommits(); - }; - })(this), - ); + this.$('.show-all-commits').on('click', () => this.showAllCommits()); this.initTabs(); this.initMRBtnListeners(); diff --git a/app/assets/javascripts/pages/projects/pipelines/test_report/index.js b/app/assets/javascripts/pages/projects/pipelines/test_report/index.js new file mode 100644 index 00000000000..7e69983c2ed --- /dev/null +++ b/app/assets/javascripts/pages/projects/pipelines/test_report/index.js @@ -0,0 +1,2 @@ +// /test_report is an alias for show +import '../show/index'; diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue new file mode 100644 index 00000000000..388b300b39d --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -0,0 +1,81 @@ +<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> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue new file mode 100644 index 00000000000..28b2c706320 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -0,0 +1,108 @@ +<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> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue new file mode 100644 index 00000000000..5fc0e220a72 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue @@ -0,0 +1,116 @@ +<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> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue new file mode 100644 index 00000000000..688baa93b6d --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -0,0 +1,129 @@ +<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> diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index d27829db50c..c9655d18a04 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -1,3 +1,9 @@ export const CANCEL_REQUEST = 'CANCEL_REQUEST'; export const PIPELINES_TABLE = 'PIPELINES_TABLE'; export const LAYOUT_CHANGE_DELAY = 300; + +export const TestStatus = { + FAILED: 'failed', + SKIPPED: 'skipped', + SUCCESS: 'success', +}; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index b6f8716d37d..d8dbc3c2454 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -7,6 +7,8 @@ import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import PipelinesMediator from './pipeline_details_mediator'; import pipelineHeader from './components/header_component.vue'; import eventHub from './event_hub'; +import TestReports from './components/test_reports/test_reports.vue'; +import testReportsStore from './stores/test_reports'; Vue.use(Translate); @@ -17,7 +19,7 @@ export default () => { mediator.fetchPipeline(); - // eslint-disable-next-line + // eslint-disable-next-line no-new new Vue({ el: '#js-pipeline-graph-vue', components: { @@ -47,7 +49,7 @@ export default () => { }, }); - // eslint-disable-next-line + // eslint-disable-next-line no-new new Vue({ el: '#js-pipeline-header-vue', components: { @@ -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'); + }, + }); + } }; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js new file mode 100644 index 00000000000..71d875c1a83 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -0,0 +1,30 @@ +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 () => {}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js new file mode 100644 index 00000000000..788c1d32987 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js @@ -0,0 +1,23 @@ +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 () => {}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/index.js b/app/assets/javascripts/pipelines/stores/test_reports/index.js new file mode 100644 index 00000000000..318dff5bcb2 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/index.js @@ -0,0 +1,15 @@ +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, +}); diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js new file mode 100644 index 00000000000..832e45cf7a1 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js @@ -0,0 +1,4 @@ +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'; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js new file mode 100644 index 00000000000..349e6ec0469 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js @@ -0,0 +1,19 @@ +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 }); + }, +}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/pipelines/stores/test_reports/state.js new file mode 100644 index 00000000000..80a0c2a46a0 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/state.js @@ -0,0 +1,6 @@ +export default () => ({ + endpoint: '', + testReports: {}, + selectedSuite: {}, + isLoading: false, +}); diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js new file mode 100644 index 00000000000..c426a5f0bb5 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js @@ -0,0 +1,36 @@ +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; + } +}; diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index aa270a374ae..d59a2a891ac 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -128,7 +128,7 @@ export default { <div class="ci-status-link"> <gl-link v-if="commit.latestPipeline" - v-gl-tooltip + v-gl-tooltip.left :href="commit.latestPipeline.detailedStatus.detailsPath" :title="statusTitle" class="js-commit-pipeline" diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 610c7e8d99e..98923c79c7a 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlSkeletonLoading } from '@gitlab/ui'; import createFlash from '~/flash'; import { sprintf, __ } from '../../../locale'; import getRefMixin from '../../mixins/get_ref'; @@ -13,7 +13,7 @@ const PAGE_SIZE = 100; export default { components: { - GlLoadingIcon, + GlSkeletonLoading, TableHeader, TableRow, ParentRow, @@ -44,6 +44,15 @@ export default { }, computed: { tableCaption() { + if (this.isLoadingFiles) { + return sprintf( + __( + 'Loading files, directories, and submodules in the path %{path} for commit reference %{ref}', + ), + { path: this.path, ref: this.ref }, + ); + } + return sprintf( __('Files, directories, and submodules in the path %{path} for commit reference %{ref}'), { path: this.path, ref: this.ref }, @@ -117,12 +126,7 @@ export default { <template> <div class="tree-content-holder"> <div class="table-holder bordered-box"> - <table class="table tree-table qa-file-tree" aria-live="polite"> - <caption class="sr-only"> - {{ - tableCaption - }} - </caption> + <table :aria-label="tableCaption" class="table tree-table qa-file-tree" aria-live="polite"> <table-header v-once /> <tbody> <parent-row v-show="showParentRow" :commit-ref="ref" :path="path" /> @@ -141,9 +145,15 @@ export default { :lfs-oid="entry.lfsOid" /> </template> + <template v-if="isLoadingFiles"> + <tr v-for="i in 5" :key="i" aria-hidden="true"> + <td><gl-skeleton-loading :lines="1" class="h-auto" /></td> + <td><gl-skeleton-loading :lines="1" class="h-auto" /></td> + <td><gl-skeleton-loading :lines="1" class="ml-auto h-auto w-50" /></td> + </tr> + </template> </tbody> </table> - <gl-loading-icon v-show="isLoadingFiles" class="my-3" size="md" /> </div> </div> </template> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 81ae5143082..9f5d929b008 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -124,13 +124,18 @@ export default { </template> </td> <td class="d-none d-sm-table-cell tree-commit"> - <gl-link v-if="commit" :href="commit.commitPath" class="str-truncated-100 tree-commit-link"> + <gl-link + v-if="commit" + :href="commit.commitPath" + :title="commit.message" + class="str-truncated-100 tree-commit-link" + > {{ commit.message }} </gl-link> <gl-skeleton-loading v-else :lines="1" class="h-auto" /> </td> <td class="tree-time-ago text-right"> - <timeago-tooltip v-if="commit" :time="commit.committedDate" tooltip-placement="bottom" /> + <timeago-tooltip v-if="commit" :time="commit.committedDate" /> <gl-skeleton-loading v-else :lines="1" class="ml-auto h-auto w-50" /> </td> </tr> diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue index 8bcad7ac765..43935cf31d5 100644 --- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue +++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue @@ -32,7 +32,7 @@ export default { </script> <template> <time - v-gl-tooltip="{ placement: tooltipPlacement }" + v-gl-tooltip.viewport="{ placement: tooltipPlacement }" :class="cssClass" :title="tooltipTitle(time)" v-text="timeFormated(time)" diff --git a/app/assets/stylesheets/framework/vue_transitions.scss b/app/assets/stylesheets/framework/vue_transitions.scss index e3bdc0b0199..a082cd25abe 100644 --- a/app/assets/stylesheets/framework/vue_transitions.scss +++ b/app/assets/stylesheets/framework/vue_transitions.scss @@ -11,3 +11,27 @@ .fade-leave-to { 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); +} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 1b2af932733..132476f832c 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -1082,3 +1082,25 @@ button.mini-pipeline-graph-dropdown-toggle { .legend-success { 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; +} diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 6770ff37d91..4d35353d5f5 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -12,6 +12,7 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action do push_frontend_feature_flag(:hide_dismissed_vulnerabilities) + push_frontend_feature_flag(:junit_pipeline_view) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show] diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 6eff2ea2e3a..a0273fe0e5a 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -7,8 +7,15 @@ class PrometheusService < MonitoringService prop_accessor :api_url boolean_accessor :manual_configuration + # We need to allow the self-monitoring project to connect to the internal + # Prometheus instance. + # Since the internal Prometheus instance is usually a localhost URL, we need + # to allow localhost URLs when the following conditions are true: + # 1. project is the self-monitoring project. + # 2. api_url is the internal Prometheus URL. with_options presence: true, if: :manual_configuration? do - validates :api_url, public_url: true + validates :api_url, public_url: true, unless: proc { |object| object.allow_local_api_url? } + validates :api_url, url: true, if: proc { |object| object.allow_local_api_url? } end before_save :synchronize_service_state @@ -82,12 +89,28 @@ class PrometheusService < MonitoringService project.clusters.enabled.any? { |cluster| cluster.application_prometheus_available? } end + def allow_local_api_url? + self_monitoring_project? && internal_prometheus_url? + end + private + def self_monitoring_project? + project && project.id == current_settings.instance_administration_project_id + end + + def internal_prometheus_url? + api_url.present? && api_url == ::Gitlab::Prometheus::Internal.uri + end + def should_return_client? api_url.present? && manual_configuration? && active? && valid? end + def current_settings + Gitlab::CurrentSettings.current_application_settings + end + def synchronize_service_state self.active = prometheus_available? || manual_configuration? diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 8c3518e3a29..f9408184cb6 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,3 +1,5 @@ +- test_reports_enabled = Feature.enabled?(:junit_pipeline_view) + .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs %li.js-pipeline-tab-link @@ -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 = _('Failed Jobs') %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 .tab-content @@ -71,4 +78,7 @@ %pre.build-trace.build-trace-rounded %code.bash.js-build-output = build_summary(build) + + #js-tab-tests.tab-pane + #js-pipeline-tests-detail = render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 2b2133b8296..f0b3ab24ea0 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -20,4 +20,5 @@ - else = 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) } } diff --git a/changelogs/unreleased/24792-artifact-based-view-for-junit-xml.yml b/changelogs/unreleased/24792-artifact-based-view-for-junit-xml.yml new file mode 100644 index 00000000000..9deb1f23d2a --- /dev/null +++ b/changelogs/unreleased/24792-artifact-based-view-for-junit-xml.yml @@ -0,0 +1,6 @@ +--- +title: Added Tests tab to pipeline detail that contains a UI for browsing test reports + produced by JUnit +merge_request: 18255 +author: +type: added diff --git a/changelogs/unreleased/34606-Remove-IIFEs-from-merge_request-js.yml b/changelogs/unreleased/34606-Remove-IIFEs-from-merge_request-js.yml new file mode 100644 index 00000000000..9cb44979f3e --- /dev/null +++ b/changelogs/unreleased/34606-Remove-IIFEs-from-merge_request-js.yml @@ -0,0 +1,5 @@ +--- +title: Remove IIFEs from merge_request.js +merge_request: 19294 +author: minghuan lei +type: other diff --git a/changelogs/unreleased/7816-commits-diff-total-api.yml b/changelogs/unreleased/7816-commits-diff-total-api.yml new file mode 100644 index 00000000000..95b8afda682 --- /dev/null +++ b/changelogs/unreleased/7816-commits-diff-total-api.yml @@ -0,0 +1,5 @@ +--- +title: Show correct total number of commit diff's changes +merge_request: 19424 +author: +type: fixed diff --git a/changelogs/unreleased/dz-fix-clusters-api-doc-2.yml b/changelogs/unreleased/dz-fix-clusters-api-doc-2.yml new file mode 100644 index 00000000000..ca8842190cf --- /dev/null +++ b/changelogs/unreleased/dz-fix-clusters-api-doc-2.yml @@ -0,0 +1,5 @@ +--- +title: Fix api docs for deleting project cluster +merge_request: 19558 +author: +type: other diff --git a/changelogs/unreleased/gitaly-version-v1.71.0.yml b/changelogs/unreleased/gitaly-version-v1.71.0.yml new file mode 100644 index 00000000000..306153ff4d7 --- /dev/null +++ b/changelogs/unreleased/gitaly-version-v1.71.0.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade to Gitaly v1.71.0 +merge_request: 19611 +author: +type: changed diff --git a/db/migrate/20180215181245_users_name_lower_index.rb b/db/migrate/20180215181245_users_name_lower_index.rb index 3b80601a727..fa1a115a78a 100644 --- a/db/migrate/20180215181245_users_name_lower_index.rb +++ b/db/migrate/20180215181245_users_name_lower_index.rb @@ -20,10 +20,6 @@ class UsersNameLowerIndex < ActiveRecord::Migration[4.2] def down return unless Gitlab::Database.postgresql? - if supports_drop_index_concurrently? - execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME}" - else - execute "DROP INDEX IF EXISTS #{INDEX_NAME}" - end + execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME}" end end diff --git a/db/migrate/20180504195842_project_name_lower_index.rb b/db/migrate/20180504195842_project_name_lower_index.rb index 3fe90c3fbb1..fa74330d5d9 100644 --- a/db/migrate/20180504195842_project_name_lower_index.rb +++ b/db/migrate/20180504195842_project_name_lower_index.rb @@ -22,11 +22,7 @@ class ProjectNameLowerIndex < ActiveRecord::Migration[4.2] return unless Gitlab::Database.postgresql? disable_statement_timeout do - if supports_drop_index_concurrently? - execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME}" - else - execute "DROP INDEX IF EXISTS #{INDEX_NAME}" - end + execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME}" end end end diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index cbc974a69cf..a74ce3491d3 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -3231,6 +3231,51 @@ type MergeRequestPermissions { } """ +Autogenerated input type of MergeRequestSetMilestone +""" +input MergeRequestSetMilestoneInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The iid of the merge request to mutate + """ + iid: String! + + """ + The milestone to assign to the merge request. + """ + milestoneId: ID + + """ + The project the merge request to mutate is in + """ + projectPath: ID! +} + +""" +Autogenerated return type of MergeRequestSetMilestone +""" +type MergeRequestSetMilestonePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The merge request after mutation + """ + mergeRequest: MergeRequest +} + +""" Autogenerated input type of MergeRequestSetWip """ input MergeRequestSetWipInput { @@ -3314,6 +3359,11 @@ type Milestone { dueDate: Time """ + ID of the milestone + """ + id: ID! + + """ Timestamp of the milestone start date """ startDate: Time @@ -3360,6 +3410,7 @@ type Mutation { destroyNote(input: DestroyNoteInput!): DestroyNotePayload epicSetSubscription(input: EpicSetSubscriptionInput!): EpicSetSubscriptionPayload epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload + mergeRequestSetMilestone(input: MergeRequestSetMilestoneInput!): MergeRequestSetMilestonePayload mergeRequestSetWip(input: MergeRequestSetWipInput!): MergeRequestSetWipPayload removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload toggleAwardEmoji(input: ToggleAwardEmojiInput!): ToggleAwardEmojiPayload diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index fd0ce344eab..5f48eb01f72 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -7741,6 +7741,24 @@ "deprecationReason": null }, { + "name": "id", + "description": "ID of the milestone", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "startDate", "description": "Timestamp of the milestone start date", "args": [ @@ -14459,6 +14477,33 @@ "deprecationReason": null }, { + "name": "mergeRequestSetMilestone", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetMilestoneInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequestSetMilestonePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "mergeRequestSetWip", "description": null, "args": [ @@ -15090,6 +15135,132 @@ }, { "kind": "OBJECT", + "name": "MergeRequestSetMilestonePayload", + "description": "Autogenerated return type of MergeRequestSetMilestone", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequest", + "description": "The merge request after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequest", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetMilestoneInput", + "description": "Autogenerated input type of MergeRequestSetMilestone", + "fields": null, + "inputFields": [ + { + "name": "projectPath", + "description": "The project the merge request to mutate is in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "iid", + "description": "The iid of the merge request to mutate", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "milestoneId", + "description": "The milestone to assign to the merge request.\n", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", "name": "MergeRequestSetWipPayload", "description": "Autogenerated return type of MergeRequestSetWip", "fields": [ diff --git a/doc/api/project_clusters.md b/doc/api/project_clusters.md index 49e0e01e6cf..1aa225d30ab 100644 --- a/doc/api/project_clusters.md +++ b/doc/api/project_clusters.md @@ -382,5 +382,5 @@ Parameters: Example request: ```bash -curl --header 'Private-Token: <your_access_token>' https://gitlab.example.com/api/v4/projects/26/clusters/23' +curl --request DELETE --header 'Private-Token: <your_access_token>' https://gitlab.example.com/api/v4/projects/26/clusters/23 ``` diff --git a/doc/development/architecture.md b/doc/development/architecture.md index ccedb96d27d..b579f812d99 100644 --- a/doc/development/architecture.md +++ b/doc/development/architecture.md @@ -59,10 +59,10 @@ graph TB Unicorn --> Gitaly Sidekiq --> Redis Sidekiq --> PgBouncer + Sidekiq --> Gitaly GitLabWorkhorse[GitLab Workhorse] --> Unicorn GitLabWorkhorse --> Redis GitLabWorkhorse --> Gitaly - Gitaly --> Redis NGINX --> GitLabWorkhorse NGINX -- TCP 8090 --> GitLabPages[GitLab Pages] NGINX --> Grafana[Grafana] diff --git a/doc/user/permissions.md b/doc/user/permissions.md index 90874eca2eb..2a6b241751b 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -55,7 +55,7 @@ The following table depicts the various user permission levels in a project. | View project code | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | Pull project code | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | View GitLab Pages protected by [access control](project/pages/introduction.md#gitlab-pages-access-control-core) | ✓ | ✓ | ✓ | ✓ | ✓ | -| View wiki pages | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | +| View wiki pages | ✓ | ✓ | ✓ | ✓ | ✓ | | See a list of jobs | ✓ (*3*) | ✓ | ✓ | ✓ | ✓ | | See a job log | ✓ (*3*) | ✓ | ✓ | ✓ | ✓ | | Download and browse job artifacts | ✓ (*3*) | ✓ | ✓ | ✓ | ✓ | @@ -73,7 +73,7 @@ The following table depicts the various user permission levels in a project. | See a commit status | | ✓ | ✓ | ✓ | ✓ | | See a container registry | | ✓ | ✓ | ✓ | ✓ | | See environments | | ✓ | ✓ | ✓ | ✓ | -| See a list of merge requests | | ✓ | ✓ | ✓ | ✓ | +| See a list of merge requests | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | View project statistics | | ✓ | ✓ | ✓ | ✓ | | View Error Tracking list | | ✓ | ✓ | ✓ | ✓ | | Pull from [Conan repository](packages/conan_repository/index.md), [Maven repository](packages/maven_repository/index.md), or [NPM registry](packages/npm_registry/index.md) **(PREMIUM)** | | ✓ | ✓ | ✓ | ✓ | @@ -83,7 +83,7 @@ The following table depicts the various user permission levels in a project. | Push to non-protected branches | | | ✓ | ✓ | ✓ | | Force push to non-protected branches | | | ✓ | ✓ | ✓ | | Remove non-protected branches | | | ✓ | ✓ | ✓ | -| Create new merge request | | | ✓ | ✓ | ✓ | +| Create new merge request | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ | | Assign merge requests | | | ✓ | ✓ | ✓ | | Label merge requests | | | ✓ | ✓ | ✓ | | Lock merge request threads | | | ✓ | ✓ | ✓ | diff --git a/doc/user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md b/doc/user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md index c9b504dc6ee..1d64e843e46 100644 --- a/doc/user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md +++ b/doc/user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md @@ -5,7 +5,8 @@ description: "Automatic Let's Encrypt SSL certificates for GitLab Pages." # GitLab Pages integration with Let's Encrypt -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/28996) in GitLab 12.1. +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/28996) in GitLab 12.1. For versions earlier than GitLab 12.1, see the [manual Let's Encrypt instructions](../lets_encrypt_for_gitlab_pages.md). +This feature is in **beta** and may still have bugs. See all the related issues linked from this [issue's description](https://gitlab.com/gitlab-org/gitlab-foss/issues/28996) for more information. The GitLab Pages integration with Let's Encrypt (LE) allows you to use LE certificates for your Pages website with custom domains @@ -16,19 +17,11 @@ GitLab does it for you, out-of-the-box. open source Certificate Authority. CAUTION: **Caution:** -This feature is in **beta** and might present bugs and UX issues -such as [#64870](https://gitlab.com/gitlab-org/gitlab-foss/issues/64870). -See all the related issues linked from this [issue's description](https://gitlab.com/gitlab-org/gitlab-foss/issues/28996) -for more information. - -CAUTION: **Caution:** -This feature covers only certificates for **custom domains**, -not the wildcard certificate required to run [Pages daemon](../../../../administration/pages/index.md) **(CORE ONLY)**. -Wildcard certificate generation is tracked in [this issue](https://gitlab.com/gitlab-org/omnibus-gitlab/issues/3342). +This feature covers only certificates for **custom domains**, not the wildcard certificate required to run [Pages daemon](../../../../administration/pages/index.md) **(CORE ONLY)**. Wildcard certificate generation is tracked in [this issue](https://gitlab.com/gitlab-org/omnibus-gitlab/issues/3342). ## Requirements -Before you can enable automatic provisioning of a SSL certificate for your domain, make sure you have: +Before you can enable automatic provisioning of an SSL certificate for your domain, make sure you have: - Created a [project](../getting_started_part_two.md) in GitLab containing your website's source code. @@ -36,7 +29,7 @@ Before you can enable automatic provisioning of a SSL certificate for your domai pointing it to your Pages website. - [Added your domain to your Pages project](index.md#1-add-a-custom-domain-to-pages) and verified your ownership. -- Have your website up and running, accessible through your custom domain. +- Verified your website is up and running, accessible through your custom domain. NOTE: **Note:** GitLab's Let's Encrypt integration is enabled and available on GitLab.com. @@ -45,7 +38,7 @@ For **self-managed** GitLab instances, make sure your administrator has ## Enabling Let's Encrypt integration for your custom domain -Once you've met the requirements, to enable Let's Encrypt integration: +Once you've met the requirements, enable Let's Encrypt integration: 1. Navigate to your project's **Settings > Pages**. 1. Find your domain and click **Details**. diff --git a/doc/user/project/protected_branches.md b/doc/user/project/protected_branches.md index 0aea7f76b30..8ce575222b9 100644 --- a/doc/user/project/protected_branches.md +++ b/doc/user/project/protected_branches.md @@ -118,10 +118,11 @@ all matching branches: When a protected branch or wildcard protected branches are set to [**No one** is **Allowed to push**](#using-the-allowed-to-merge-and-allowed-to-push-settings), -Developers (and users with higher [permission levels](../permissions.md)) are allowed -to create a new protected branch, but only via the UI or through the API (to avoid -creating protected branches accidentally from the command line or from a Git -client application). +Developers (and users with higher [permission levels](../permissions.md)) are +allowed to create a new protected branch as long as they are +[**Allowed to merge**](#using-the-allowed-to-merge-and-allowed-to-push-settings). +This can only be done via the UI or through the API (to avoid creating protected +branches accidentally from the command line or from a Git client application). To create a new branch through the user interface: diff --git a/lib/api/commits.rb b/lib/api/commits.rb index ffff40141de..3b9ac602c56 100644 --- a/lib/api/commits.rb +++ b/lib/api/commits.rb @@ -169,7 +169,7 @@ module API not_found! 'Commit' unless commit - raw_diffs = ::Kaminari.paginate_array(commit.raw_diffs.to_a) + raw_diffs = ::Kaminari.paginate_array(commit.diffs(expanded: true).diffs.to_a) present paginate(raw_diffs), with: Entities::Diff end diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index ae29546cdac..6bada921ad4 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -108,9 +108,7 @@ module Gitlab 'in the body of your migration class' end - if supports_drop_index_concurrently? - options = options.merge({ algorithm: :concurrently }) - end + options = options.merge({ algorithm: :concurrently }) unless index_exists?(table_name, column_name, options) Rails.logger.warn "Index not removed because it does not exist (this may be due to an aborted migration or similar): table_name: #{table_name}, column_name: #{column_name}" # rubocop:disable Gitlab/RailsLogger @@ -136,9 +134,7 @@ module Gitlab 'in the body of your migration class' end - if supports_drop_index_concurrently? - options = options.merge({ algorithm: :concurrently }) - end + options = options.merge({ algorithm: :concurrently }) unless index_exists_by_name?(table_name, index_name) Rails.logger.warn "Index not removed because it does not exist (this may be due to an aborted migration or similar): table_name: #{table_name}, index_name: #{index_name}" # rubocop:disable Gitlab/RailsLogger @@ -150,13 +146,6 @@ module Gitlab end end - # Only available on Postgresql >= 9.2 - def supports_drop_index_concurrently? - version = select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i - - version >= 90200 - end - # Adds a foreign key with only minimal locking on the tables involved. # # This method only requires minimal locking diff --git a/lib/gitlab/database_importers/self_monitoring/project/create_service.rb b/lib/gitlab/database_importers/self_monitoring/project/create_service.rb index 4677e984305..8cd9694b741 100644 --- a/lib/gitlab/database_importers/self_monitoring/project/create_service.rb +++ b/lib/gitlab/database_importers/self_monitoring/project/create_service.rb @@ -21,7 +21,6 @@ module Gitlab :create_project, :save_project_id, :add_group_members, - :add_to_whitelist, :add_prometheus_manual_configuration def initialize @@ -126,28 +125,6 @@ module Gitlab end end - def add_to_whitelist(result) - return success(result) unless prometheus_enabled? - return success(result) unless prometheus_listen_address.present? - - uri = parse_url(internal_prometheus_listen_address_uri) - return error(_('Prometheus listen_address in config/gitlab.yml is not a valid URI')) unless uri - - application_settings.add_to_outbound_local_requests_whitelist([uri.normalized_host]) - response = application_settings.save - - if response - # Expire the Gitlab::CurrentSettings cache after updating the whitelist. - # This happens automatically in an after_commit hook, but in migrations, - # the after_commit hook only runs at the end of the migration. - Gitlab::CurrentSettings.expire_current_application_settings - success(result) - else - log_error("Could not add prometheus URL to whitelist, errors: %{errors}" % { errors: application_settings.errors.full_messages }) - error(_('Could not add prometheus URL to whitelist')) - end - end - def add_prometheus_manual_configuration(result) return success(result) unless prometheus_enabled? return success(result) unless prometheus_listen_address.present? diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 05eb287274a..c08a72813d6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3266,6 +3266,9 @@ msgstr "" msgid "CiVariable|Validation failed" msgstr "" +msgid "Class" +msgstr "" + msgid "Classification Label (optional)" msgstr "" @@ -4632,9 +4635,6 @@ msgstr "" msgid "Could not add admins as members" msgstr "" -msgid "Could not add prometheus URL to whitelist" -msgstr "" - msgid "Could not authorize chat nickname. Try again!" msgstr "" @@ -5876,6 +5876,9 @@ msgstr "" msgid "Due date" msgstr "" +msgid "Duration" +msgstr "" + msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below." msgstr "" @@ -10013,6 +10016,9 @@ msgstr "" msgid "Loading contribution stats for group members" msgstr "" +msgid "Loading files, directories, and submodules in the path %{path} for commit reference %{ref}" +msgstr "" + msgid "Loading functions timed out. Please reload the page to try again." msgstr "" @@ -11738,6 +11744,9 @@ msgstr "" msgid "Part of merge request changes" msgstr "" +msgid "Passed" +msgstr "" + msgid "Password" msgstr "" @@ -13289,9 +13298,6 @@ msgstr "" msgid "ProjectsNew|Want to house several dependent projects under the same namespace? %{link_start}Create a group.%{link_end}" msgstr "" -msgid "Prometheus listen_address in config/gitlab.yml is not a valid URI" -msgstr "" - msgid "PrometheusAlerts|%{count} alerts applied" msgstr "" @@ -15504,6 +15510,9 @@ msgstr "" msgid "Skip this for now" msgstr "" +msgid "Skipped" +msgstr "" + msgid "Slack application" msgstr "" @@ -16305,6 +16314,12 @@ msgstr "" msgid "Suggestions:" msgstr "" +msgid "Suite" +msgstr "" + +msgid "Summary" +msgstr "" + msgid "Sunday" msgstr "" @@ -16533,6 +16548,39 @@ msgstr "" msgid "TestHooks|Ensure the wiki is enabled and has pages." 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." msgstr "" @@ -17718,6 +17766,9 @@ msgstr "" msgid "Total: %{total}" msgstr "" +msgid "Trace" +msgstr "" + msgid "Tracing" msgstr "" diff --git a/spec/frontend/pipelines/test_reports/mock_data.js b/spec/frontend/pipelines/test_reports/mock_data.js new file mode 100644 index 00000000000..b0f22bc63fb --- /dev/null +++ b/spec/frontend/pipelines/test_reports/mock_data.js @@ -0,0 +1,123 @@ +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, +}; diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js new file mode 100644 index 00000000000..c1721e12234 --- /dev/null +++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js @@ -0,0 +1,109 @@ +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, + ); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/stores/getters_spec.js b/spec/frontend/pipelines/test_reports/stores/getters_spec.js new file mode 100644 index 00000000000..e630a005409 --- /dev/null +++ b/spec/frontend/pipelines/test_reports/stores/getters_spec.js @@ -0,0 +1,54 @@ +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([]); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js new file mode 100644 index 00000000000..ad5b7f91163 --- /dev/null +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -0,0 +1,63 @@ +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); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js new file mode 100644 index 00000000000..4d6422745a9 --- /dev/null +++ b/spec/frontend/pipelines/test_reports/test_reports_spec.js @@ -0,0 +1,64 @@ +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(); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js new file mode 100644 index 00000000000..b4305719ea8 --- /dev/null +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -0,0 +1,77 @@ +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); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_summary_spec.js b/spec/frontend/pipelines/test_reports/test_summary_spec.js new file mode 100644 index 00000000000..19a7755dbdc --- /dev/null +++ b/spec/frontend/pipelines/test_reports/test_summary_spec.js @@ -0,0 +1,78 @@ +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'); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js new file mode 100644 index 00000000000..e7599d5cdbc --- /dev/null +++ b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js @@ -0,0 +1,54 @@ +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); + }); + }); +}); diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index 827927e6d9a..0c5a7370fca 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlSkeletonLoading } from '@gitlab/ui'; import Table from '~/repository/components/table/index.vue'; let vm; @@ -35,7 +35,7 @@ describe('Repository table component', () => { vm.setData({ ref }); - expect(vm.find('caption').text()).toEqual( + expect(vm.find('.table').attributes('aria-label')).toEqual( `Files, directories, and submodules in the path ${path} for commit reference ${ref}`, ); }); @@ -45,7 +45,7 @@ describe('Repository table component', () => { vm.setData({ isLoadingFiles: true }); - expect(vm.find(GlLoadingIcon).isVisible()).toBe(true); + expect(vm.find(GlSkeletonLoading).exists()).toBe(true); }); describe('normalizeData', () => { diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 49f92f14559..449eee7a371 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -142,7 +142,6 @@ describe Gitlab::Database::MigrationHelpers do allow(model).to receive(:transaction_open?).and_return(false) allow(model).to receive(:index_exists?).and_return(true) allow(model).to receive(:disable_statement_timeout).and_call_original - allow(model).to receive(:supports_drop_index_concurrently?).and_return(true) end describe 'by column name' do diff --git a/spec/lib/gitlab/database_importers/self_monitoring/project/create_service_spec.rb b/spec/lib/gitlab/database_importers/self_monitoring/project/create_service_spec.rb index aab6fbcbbd1..5b1a17e734d 100644 --- a/spec/lib/gitlab/database_importers/self_monitoring/project/create_service_spec.rb +++ b/spec/lib/gitlab/database_importers/self_monitoring/project/create_service_spec.rb @@ -164,15 +164,6 @@ describe Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService do end it_behaves_like 'has prometheus service', 'http://localhost:9090' - - it 'does not overwrite the existing whitelist' do - application_setting.outbound_local_requests_whitelist = ['example.com'] - - expect(result[:status]).to eq(:success) - expect(application_setting.outbound_local_requests_whitelist).to contain_exactly( - 'example.com', 'localhost' - ) - end end context 'with non default prometheus address' do diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb index e5ac6ca65d6..bc22818ede7 100644 --- a/spec/models/project_services/prometheus_service_spec.rb +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -65,6 +65,37 @@ describe PrometheusService, :use_clean_rails_memory_store_caching do end end end + + context 'with self-monitoring project and internal Prometheus' do + before do + service.api_url = 'http://localhost:9090' + + stub_application_setting(instance_administration_project_id: project.id) + stub_config(prometheus: { enable: true, listen_address: 'localhost:9090' }) + end + + it 'allows self-monitoring project to connect to internal Prometheus' do + aggregate_failures do + ['127.0.0.1', '192.168.2.3'].each do |url| + allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)]) + + expect(service.can_query?).to be true + end + end + end + + it 'does not allow self-monitoring project to connect to other local URLs' do + service.api_url = 'http://localhost:8000' + + aggregate_failures do + ['127.0.0.1', '192.168.2.3'].each do |url| + allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)]) + + expect(service.can_query?).to be false + end + end + end + end end end diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index fa24958a79f..9cea4866c4c 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -1089,6 +1089,20 @@ describe API::Commits do expect(json_response.first.keys).to include 'diff' end + context 'when hard limits are lower than the number of files' do + before do + allow(Commit).to receive(:max_diff_options).and_return(max_files: 1) + end + + it 'respects the limit' do + get api(route, current_user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response.size).to be <= 1 + end + end + context 'when ref does not exist' do let(:commit_id) { 'unknown' } |