Commit 4c464055 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 791785af
......@@ -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"]
......
......@@ -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/**/*"
......
......@@ -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();
......
// /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 PIPELINES_TABLE = 'PIPELINES_TABLE';
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';
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');
},
});
}
};
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;
}
};
......@@ -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"
......
<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>
......@@ -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>
......
......@@ -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)"
......
......@@ -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);
}
......@@ -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;
}
......@@ -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]
......
......@@ -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?
......
- 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
......@@ -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) } }
---
title: Added Tests tab to pipeline detail that contains a UI for browsing test reports
produced by JUnit
merge_request: 18255
author:
type: added
---
title: Remove IIFEs from merge_request.js
merge_request: 19294
author: minghuan lei
type: other
---
title: Show correct total number of commit diff's changes
merge_request: 19424
author:
type: fixed
---
title: Fix api docs for deleting project cluster
merge_request: 19558
author:
type: other
---
title: Upgrade to Gitaly v1.71.0
merge_request: 19611
author:
type: changed
......@@ -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
......@@ -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
......@@ -3230,6 +3230,51 @@ type MergeRequestPermissions {
updateMergeRequest: Boolean!
}
"""
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
"""
......@@ -3313,6 +3358,11 @@ type Milestone {
"""
dueDate: Time
"""
ID of the milestone
"""
id: ID!
"""
Timestamp of the milestone start date
"""
......@@ -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
......
......@@ -7740,6 +7740,24 @@
"isDeprecated": false,
"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",
......@@ -14458,6 +14476,33 @@
"isDeprecated": false,
"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,
......@@ -15088,6 +15133,132 @@
"enumValues": null,
"possibleTypes": null
},
{
"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",
......
......@@ -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
```
......@@ -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]
......
......@@ -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 | | | ✓ | ✓ | ✓ |
......
......@@ -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**.
......
......@@ -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:
......
......@@ -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
......
......@@ -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
......
......@@ -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?
......
......@@ -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 ""
......
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);
});
});
});
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', () => {
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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' }
......
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