Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-11-07 06:08:10 +00:00
parent a31408ba64
commit 44c74f7b06
36 changed files with 438 additions and 2123 deletions

View file

@ -597,7 +597,6 @@ Layout/LineLength:
- 'app/services/loose_foreign_keys/cleaner_service.rb'
- 'app/services/members/destroy_service.rb'
- 'app/services/members/invitation_reminder_email_service.rb'
- 'app/services/members/update_service.rb'
- 'app/services/merge_requests/add_context_service.rb'
- 'app/services/merge_requests/assign_issues_service.rb'
- 'app/services/merge_requests/base_service.rb'
@ -5539,7 +5538,6 @@ Layout/LineLength:
- 'spec/services/members/destroy_service_spec.rb'
- 'spec/services/members/invitation_reminder_email_service_spec.rb'
- 'spec/services/members/unassign_issuables_service_spec.rb'
- 'spec/services/members/update_service_spec.rb'
- 'spec/services/merge_requests/add_context_service_spec.rb'
- 'spec/services/merge_requests/after_create_service_spec.rb'
- 'spec/services/merge_requests/assign_issues_service_spec.rb'

View file

@ -2972,7 +2972,6 @@ RSpec/ContextWording:
- 'spec/services/members/destroy_service_spec.rb'
- 'spec/services/members/groups/creator_service_spec.rb'
- 'spec/services/members/projects/creator_service_spec.rb'
- 'spec/services/members/update_service_spec.rb'
- 'spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb'
- 'spec/services/merge_requests/after_create_service_spec.rb'
- 'spec/services/merge_requests/approval_service_spec.rb'

View file

@ -309,7 +309,6 @@ Style/IfUnlessModifier:
- 'app/services/issues/update_service.rb'
- 'app/services/lfs/lock_file_service.rb'
- 'app/services/members/destroy_service.rb'
- 'app/services/members/update_service.rb'
- 'app/services/merge_requests/add_context_service.rb'
- 'app/services/merge_requests/base_service.rb'
- 'app/services/merge_requests/build_service.rb'

View file

@ -73,6 +73,7 @@ export default {
<template>
<gl-modal
data-testid="test-case-details-modal"
:modal-id="modalId"
:title="testCase.classname"
:action-primary="$options.modalCloseButton"

View file

@ -2,12 +2,10 @@ import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
export const components = {
CodequalityIssueBody: () => import('../codequality_report/components/codequality_issue_body.vue'),
TestIssueBody: () => import('../grouped_test_report/components/test_issue_body.vue'),
};
export const componentNames = {
CodequalityIssueBody: 'CodequalityIssueBody',
TestIssueBody: 'TestIssueBody',
};
export const iconComponents = {

View file

@ -1,74 +0,0 @@
<script>
import { GlModal, GlLink, GlSprintf } from '@gitlab/ui';
import CodeBlock from '~/vue_shared/components/code_block.vue';
import { fieldTypes } from '../../constants';
export default {
components: {
CodeBlock,
GlModal,
GlLink,
GlSprintf,
},
props: {
visible: {
type: Boolean,
required: true,
},
title: {
type: String,
required: true,
},
modalData: {
type: Object,
required: true,
},
},
computed: {
filteredModalData() {
// Filter out the properties that don't have a value
return Object.fromEntries(
Object.entries(this.modalData).filter((data) => Boolean(data[1].value)),
);
},
},
fieldTypes,
};
</script>
<template>
<gl-modal
:visible="visible"
modal-id="modal-mrwidget-reports"
:title="title"
:hide-footer="true"
@hide="$emit('hide')"
>
<div v-for="(field, key, index) in filteredModalData" :key="index" class="row gl-mt-3 gl-mb-3">
<strong class="col-sm-3 text-right"> {{ field.text }}: </strong>
<div class="col-sm-9">
<code-block v-if="field.type === $options.fieldTypes.codeBlock" :code="field.value" />
<gl-link
v-else-if="field.type === $options.fieldTypes.link"
:href="field.value.path"
target="_blank"
>
{{ field.value.text }}
</gl-link>
<gl-sprintf
v-else-if="field.type === $options.fieldTypes.seconds"
:message="__('%{value} s')"
>
<template #value>{{ field.value }}</template>
</gl-sprintf>
<template v-else-if="field.type === $options.fieldTypes.text">
{{ field.value }}
</template>
</div>
</div>
</gl-modal>
</template>

View file

@ -1,64 +0,0 @@
<script>
import { GlBadge, GlButton } from '@gitlab/ui';
import { mapActions } from 'vuex';
import { sprintf, n__ } from '~/locale';
import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
import { STATUS_NEUTRAL } from '../../constants';
export default {
name: 'TestIssueBody',
components: {
GlBadge,
GlButton,
IssueStatusIcon,
},
props: {
issue: {
type: Object,
required: true,
},
},
computed: {
recentFailureMessage() {
return sprintf(
n__(
'Reports|Failed %{count} time in %{base_branch} in the last 14 days',
'Reports|Failed %{count} times in %{base_branch} in the last 14 days',
this.issue.recent_failures?.count,
),
this.issue.recent_failures,
);
},
showRecentFailures() {
return this.issue.recent_failures?.count && this.issue.recent_failures?.base_branch;
},
status() {
return this.issue.status || STATUS_NEUTRAL;
},
},
methods: {
...mapActions(['openModal']),
},
};
</script>
<template>
<div class="gl-display-flex gl-mt-2 gl-mb-2">
<issue-status-icon :status="status" :status-icon-size="24" class="gl-mr-3" />
<gl-button
button-text-classes="gl-white-space-normal! gl-word-break-all gl-text-left"
variant="link"
data-testid="test-issue-body-description"
@click="openModal({ issue })"
>
<gl-badge
v-if="showRecentFailures"
variant="warning"
class="gl-mr-2"
data-testid="test-issue-body-recent-failures"
>
{{ recentFailureMessage }}
</gl-badge>
{{ issue.name }}
</gl-button>
</div>
</template>

View file

@ -1,204 +0,0 @@
<script>
import { GlButton, GlIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import api from '~/api';
import { sprintf, s__ } from '~/locale';
import GroupedIssuesList from '../components/grouped_issues_list.vue';
import { componentNames } from '../components/issue_body';
import ReportSection from '../components/report_section.vue';
import SummaryRow from '../components/summary_row.vue';
import Modal from './components/modal.vue';
import createStore from './store';
import {
summaryTextBuilder,
reportTextBuilder,
statusIcon,
recentFailuresTextBuilder,
} from './store/utils';
export default {
name: 'GroupedTestReportsApp',
store: createStore(),
components: {
ReportSection,
SummaryRow,
GroupedIssuesList,
Modal,
GlButton,
GlIcon,
},
props: {
endpoint: {
type: String,
required: true,
},
pipelinePath: {
type: String,
required: false,
default: '',
},
headBlobPath: {
type: String,
required: true,
},
},
componentNames,
computed: {
...mapState(['reports', 'isLoading', 'hasError', 'summary']),
...mapState({
modalTitle: (state) => state.modal.title || '',
modalData: (state) => state.modal.data || {},
modalOpen: (state) => state.modal.open || false,
}),
...mapGetters(['summaryStatus']),
groupedSummaryText() {
if (this.isLoading) {
return s__('Reports|Test summary results are being parsed');
}
if (this.hasError) {
return s__('Reports|Test summary failed loading results');
}
return summaryTextBuilder(s__('Reports|Test summary'), this.summary);
},
testTabURL() {
return `${this.pipelinePath}/test_report`;
},
showViewFullReport() {
return this.pipelinePath.length;
},
},
created() {
this.setPaths({
endpoint: this.endpoint,
headBlobPath: this.headBlobPath,
});
this.fetchReports();
},
methods: {
...mapActions(['setPaths', 'fetchReports', 'closeModal']),
handleToggleEvent() {
api.trackRedisHllUserEvent(this.$options.expandEvent);
},
reportText(report) {
const { name, summary } = report || {};
if (report.status === 'error') {
return sprintf(s__('Reports|An error occurred while loading %{name} results'), { name });
}
if (!report.name) {
return s__('Reports|An error occurred while loading report');
}
return reportTextBuilder(name, summary);
},
hasRecentFailures(summary) {
return summary?.recentlyFailed > 0;
},
recentFailuresText(summary) {
return recentFailuresTextBuilder(summary);
},
getReportIcon(report) {
return statusIcon(report.status);
},
shouldRenderIssuesList(report) {
return (
report.existing_failures.length > 0 ||
report.new_failures.length > 0 ||
report.resolved_failures.length > 0 ||
report.existing_errors.length > 0 ||
report.new_errors.length > 0 ||
report.resolved_errors.length > 0
);
},
unresolvedIssues(report) {
return [
...report.new_failures,
...report.new_errors,
...report.existing_failures,
...report.existing_errors,
];
},
resolvedIssues(report) {
return report.resolved_failures.concat(report.resolved_errors);
},
},
expandEvent: 'i_testing_summary_widget_total',
};
</script>
<template>
<report-section
:status="summaryStatus"
:success-text="groupedSummaryText"
:loading-text="groupedSummaryText"
:error-text="groupedSummaryText"
:has-issues="reports.length > 0"
:should-emit-toggle-event="true"
class="mr-widget-section grouped-security-reports mr-report"
@toggleEvent.once="handleToggleEvent"
>
<template v-if="showViewFullReport" #action-buttons>
<gl-button
:href="testTabURL"
target="_blank"
icon="external-link"
data-testid="group-test-reports-full-link"
class="gl-mr-3"
>
{{ s__('ciReport|View full report') }}
</gl-button>
</template>
<template v-if="hasRecentFailures(summary)" #sub-heading>
{{ recentFailuresText(summary) }}
</template>
<template #body>
<div class="mr-widget-grouped-section report-block">
<template v-for="(report, i) in reports">
<summary-row
:key="`summary-row-${i}`"
:status-icon="getReportIcon(report)"
nested-summary
>
<template #summary>
<div class="gl-display-inline-flex gl-flex-direction-column">
<div>{{ reportText(report) }}</div>
<div v-if="report.suite_errors">
<div v-if="report.suite_errors.head">
<gl-icon name="warning" class="gl-mx-2 gl-text-orange-500" />
{{ s__('Reports|Head report parsing error:') }}
{{ report.suite_errors.head }}
</div>
<div v-if="report.suite_errors.base">
<gl-icon name="warning" class="gl-mx-2 gl-text-orange-500" />
{{ s__('Reports|Base report parsing error:') }}
{{ report.suite_errors.base }}
</div>
</div>
<div v-if="hasRecentFailures(report.summary)">
{{ recentFailuresText(report.summary) }}
</div>
</div>
</template>
</summary-row>
<grouped-issues-list
v-if="shouldRenderIssuesList(report)"
:key="`issues-list-${i}`"
:unresolved-issues="unresolvedIssues(report)"
:resolved-issues="resolvedIssues(report)"
:component="$options.componentNames.TestIssueBody"
:nested-level="2"
/>
</template>
<modal
:visible="modalOpen"
:title="modalTitle"
:modal-data="modalData"
@hide="closeModal"
/>
</div>
</template>
</report-section>
</template>

View file

@ -1,82 +0,0 @@
import Visibility from 'visibilityjs';
import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
import * as types from './mutation_types';
export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths);
export const requestReports = ({ commit }) => commit(types.REQUEST_REPORTS);
let eTagPoll;
export const clearEtagPoll = () => {
eTagPoll = null;
};
export const stopPolling = () => {
if (eTagPoll) eTagPoll.stop();
};
export const restartPolling = () => {
if (eTagPoll) eTagPoll.restart();
};
/**
* We need to poll the reports endpoint while they are being parsed in the Backend.
* This can take up to one minute.
*
* Poll.js will handle etag response.
* While http status code is 204, it means it's parsing, and we'll keep polling
* When http status code is 200, it means parsing is done, we can show the results & stop polling
* When http status code is 500, it means parsing went wrong and we stop polling
*/
export const fetchReports = ({ state, dispatch }) => {
dispatch('requestReports');
eTagPoll = new Poll({
resource: {
getReports(endpoint) {
return axios.get(endpoint);
},
},
data: state.endpoint,
method: 'getReports',
successCallback: ({ data, status }) =>
dispatch('receiveReportsSuccess', {
data,
status,
}),
errorCallback: () => dispatch('receiveReportsError'),
});
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
} else {
axios
.get(state.endpoint)
.then(({ data, status }) => dispatch('receiveReportsSuccess', { data, status }))
.catch(() => dispatch('receiveReportsError'));
}
Visibility.change(() => {
if (!Visibility.hidden()) {
dispatch('restartPolling');
} else {
dispatch('stopPolling');
}
});
};
export const receiveReportsSuccess = ({ commit }, response) => {
// With 204 we keep polling and don't update the state
if (response.status === httpStatusCodes.OK) {
commit(types.RECEIVE_REPORTS_SUCCESS, response.data);
}
};
export const receiveReportsError = ({ commit }) => commit(types.RECEIVE_REPORTS_ERROR);
export const openModal = ({ commit }, payload) => commit(types.SET_ISSUE_MODAL_DATA, payload);
export const closeModal = ({ commit }, payload) => commit(types.RESET_ISSUE_MODAL_DATA, payload);

View file

@ -1,13 +0,0 @@
import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../../constants';
export const summaryStatus = (state) => {
if (state.isLoading) {
return LOADING;
}
if (state.hasError || state.status === STATUS_FAILED) {
return ERROR;
}
return SUCCESS;
};

View file

@ -1,17 +0,0 @@
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export const getStoreConfig = () => ({
actions,
mutations,
getters,
state: state(),
});
export default () => new Vuex.Store(getStoreConfig());

View file

@ -1,7 +0,0 @@
export const SET_PATHS = 'SET_PATHS';
export const REQUEST_REPORTS = 'REQUEST_REPORTS';
export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS';
export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR';
export const SET_ISSUE_MODAL_DATA = 'SET_ISSUE_MODAL_DATA';
export const RESET_ISSUE_MODAL_DATA = 'RESET_ISSUE_MODAL_DATA';

View file

@ -1,79 +0,0 @@
import * as types from './mutation_types';
import { countRecentlyFailedTests, formatFilePath } from './utils';
export default {
[types.SET_PATHS](state, { endpoint, headBlobPath }) {
state.endpoint = endpoint;
state.headBlobPath = headBlobPath;
},
[types.REQUEST_REPORTS](state) {
state.isLoading = true;
},
[types.RECEIVE_REPORTS_SUCCESS](state, response) {
state.hasError = response.suites.some((suite) => suite.status === 'error');
state.isLoading = false;
state.summary.total = response.summary.total;
state.summary.resolved = response.summary.resolved;
state.summary.failed = response.summary.failed;
state.summary.errored = response.summary.errored;
state.summary.recentlyFailed = countRecentlyFailedTests(response.suites);
state.status = response.status;
state.reports = response.suites;
state.reports.forEach((report, i) => {
if (!state.reports[i].summary) return;
state.reports[i].summary.recentlyFailed = countRecentlyFailedTests(report);
});
},
[types.RECEIVE_REPORTS_ERROR](state) {
state.isLoading = false;
state.hasError = true;
state.reports = [];
state.summary = {
total: 0,
resolved: 0,
failed: 0,
errored: 0,
recentlyFailed: 0,
};
state.status = null;
},
[types.SET_ISSUE_MODAL_DATA](state, payload) {
const { issue } = payload;
state.modal.title = issue.name;
Object.keys(issue).forEach((key) => {
if (Object.prototype.hasOwnProperty.call(state.modal.data, key)) {
state.modal.data[key] = {
...state.modal.data[key],
value: issue[key],
};
}
});
if (issue.file) {
state.modal.data.filename.value = {
text: issue.file,
path: `${state.headBlobPath}/${formatFilePath(issue.file)}`,
};
}
state.modal.open = true;
},
[types.RESET_ISSUE_MODAL_DATA](state) {
state.modal.open = false;
// Resetting modal data
state.modal.title = null;
Object.keys(state.modal.data).forEach((key) => {
state.modal.data[key] = {
...state.modal.data[key],
value: null,
};
});
},
};

View file

@ -1,71 +0,0 @@
import { s__ } from '~/locale';
import { fieldTypes } from '../../constants';
export default () => ({
endpoint: null,
isLoading: false,
hasError: false,
status: null,
summary: {
total: 0,
resolved: 0,
failed: 0,
errored: 0,
},
/**
* Each report will have the following format:
* {
* name: {String},
* summary: {
* total: {Number},
* resolved: {Number},
* failed: {Number},
* errored: {Number},
* },
* new_failures: {Array.<Object>},
* resolved_failures: {Array.<Object>},
* existing_failures: {Array.<Object>},
* new_errors: {Array.<Object>},
* resolved_errors: {Array.<Object>},
* existing_errors: {Array.<Object>},
* }
*/
reports: [],
modal: {
title: null,
open: false,
data: {
classname: {
value: null,
text: s__('Reports|Classname'),
type: fieldTypes.text,
},
filename: {
value: null,
text: s__('Reports|Filename'),
type: fieldTypes.link,
},
execution_time: {
value: null,
text: s__('Reports|Execution time'),
type: fieldTypes.seconds,
},
failure: {
value: null,
text: s__('Reports|Failure'),
type: fieldTypes.codeBlock,
},
system_output: {
value: null,
text: s__('Reports|System output'),
type: fieldTypes.codeBlock,
},
},
},
});

View file

@ -1,111 +0,0 @@
import { sprintf, n__, s__, __ } from '~/locale';
import {
STATUS_FAILED,
STATUS_SUCCESS,
ICON_WARNING,
ICON_SUCCESS,
ICON_NOTFOUND,
} from '../../constants';
const textBuilder = (results) => {
const { failed, errored, resolved, total } = results;
const failedOrErrored = (failed || 0) + (errored || 0);
const failedString = failed ? n__('%d failed', '%d failed', failed) : null;
const erroredString = errored ? n__('%d error', '%d errors', errored) : null;
const combinedString =
failed && errored ? `${failedString}, ${erroredString}` : failedString || erroredString;
const resolvedString = resolved
? n__('%d fixed test result', '%d fixed test results', resolved)
: null;
const totalString = total ? n__('out of %d total test', 'out of %d total tests', total) : null;
let resultsString = s__('Reports|no changed test results');
if (failedOrErrored) {
if (resolved) {
resultsString = sprintf(s__('Reports|%{combinedString} and %{resolvedString}'), {
combinedString,
resolvedString,
});
} else {
resultsString = combinedString;
}
} else if (resolved) {
resultsString = resolvedString;
}
return `${resultsString} ${totalString}`;
};
export const summaryTextBuilder = (name = '', results = {}) => {
const resultsString = textBuilder(results);
return sprintf(__('%{name} contained %{resultsString}'), { name, resultsString });
};
export const reportTextBuilder = (name = '', results = {}) => {
const resultsString = textBuilder(results);
return sprintf(__('%{name} found %{resultsString}'), { name, resultsString });
};
export const recentFailuresTextBuilder = (summary = {}) => {
const { failed, recentlyFailed } = summary;
if (!failed || !recentlyFailed) return '';
if (failed < 2) {
return sprintf(
s__(
'Reports|%{recentlyFailed} out of %{failed} failed test has failed more than once in the last 14 days',
),
{ recentlyFailed, failed },
);
}
return sprintf(
n__(
'Reports|%{recentlyFailed} out of %{failed} failed tests has failed more than once in the last 14 days',
'Reports|%{recentlyFailed} out of %{failed} failed tests have failed more than once in the last 14 days',
recentlyFailed,
),
{ recentlyFailed, failed },
);
};
export const countRecentlyFailedTests = (subject) => {
// handle either a single report or an array of reports
const reports = !subject.length ? [subject] : subject;
return reports
.map((report) => {
return (
[report.new_failures, report.existing_failures, report.resolved_failures]
// only count tests which have failed more than once
.map(
(failureArray) =>
failureArray.filter((failure) => failure.recent_failures?.count > 1).length,
)
.reduce((total, count) => total + count, 0)
);
})
.reduce((total, count) => total + count, 0);
};
export const statusIcon = (status) => {
if (status === STATUS_FAILED) {
return ICON_WARNING;
}
if (status === STATUS_SUCCESS) {
return ICON_SUCCESS;
}
return ICON_NOTFOUND;
};
/**
* Removes `./` from the beginning of a file path so it can be appended onto a blob path
* @param {String} file
* @returns {String} - formatted value
*/
export const formatFilePath = (file) => {
return file.replace(/^\.?\/*/, '');
};

View file

@ -82,8 +82,6 @@ export default {
MrWidgetAutoMergeFailed: AutoMergeFailed,
MrWidgetRebase: RebaseState,
SourceBranchRemovalStatus,
GroupedTestReportsApp: () =>
import('../reports/grouped_test_report/grouped_test_reports_app.vue'),
MrWidgetApprovals,
SecurityReportsApp: () => import('~/vue_shared/security_reports/security_reports_app.vue'),
MergeChecksFailed: () => import('./components/states/merge_checks_failed.vue'),
@ -183,9 +181,6 @@ export default {
shouldRenderTestReport() {
return Boolean(this.mr?.testResultsPath);
},
shouldRenderRefactoredTestReport() {
return window.gon?.features?.refactorMrWidgetTestSummary;
},
mergeError() {
let { mergeError } = this.mr;
@ -519,7 +514,7 @@ export default {
}
},
registerTestReportExtension() {
if (this.shouldRenderTestReport && this.shouldRenderRefactoredTestReport) {
if (this.shouldRenderTestReport) {
registerExtension(testReportExtension);
}
},
@ -596,14 +591,6 @@ export default {
:mr-iid="mr.iid"
/>
<grouped-test-reports-app
v-if="shouldRenderTestReport && !shouldRenderRefactoredTestReport"
class="js-reports-container"
:endpoint="mr.testResultsPath"
:head-blob-path="mr.headBlobPath"
:pipeline-path="mr.pipeline.path"
/>
<div class="mr-widget-section" data-qa-selector="mr_widget_content">
<component :is="componentName" :mr="mr" :service="service" />
<ready-to-merge

View file

@ -34,7 +34,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action only: [:show] do
push_frontend_feature_flag(:merge_request_widget_graphql, project)
push_frontend_feature_flag(:core_security_mr_widget_counts, project)
push_frontend_feature_flag(:refactor_mr_widget_test_summary, project)
push_frontend_feature_flag(:issue_assignees_widget, @project)
push_frontend_feature_flag(:realtime_labels, project)
push_frontend_feature_flag(:refactor_security_extension, @project)

View file

@ -2,40 +2,84 @@
module Members
class UpdateService < Members::BaseService
# returns the updated member
def execute(member, permission: :update)
raise Gitlab::Access::AccessDeniedError unless can?(current_user, action_member_permission(permission, member), member)
raise Gitlab::Access::AccessDeniedError if prevent_upgrade_to_owner?(member) || prevent_downgrade_from_owner?(member)
# @param members [Member, Array<Member>]
# returns the updated member(s)
def execute(members, permission: :update)
members = Array.wrap(members)
return success(member: member) if update_results_in_no_change?(member)
old_access_level = member.human_access
old_expiry = member.expires_at
member.attributes = params
return success(member: member) unless member.changed?
if member.save
after_execute(action: permission, old_access_level: old_access_level, old_expiry: old_expiry, member: member)
# Deletes only confidential issues todos for guests
enqueue_delete_todos(member) if downgrading_to_guest?
old_access_level_expiry_map = members.to_h do |member|
[member.id, { human_access: member.human_access, expires_at: member.expires_at }]
end
if member.errors.any?
error(member.errors.full_messages.to_sentence, pass_back: { member: member })
if Feature.enabled?(:bulk_update_membership_roles, current_user)
multiple_members_update(members, permission, old_access_level_expiry_map)
else
success(member: member)
single_member_update(members.first, permission, old_access_level_expiry_map)
end
prepare_response(members)
end
private
def update_results_in_no_change?(member)
return false if params[:expires_at]&.to_date != member.expires_at
return false if params[:access_level] != member.access_level
def single_member_update(member, permission, old_access_level_expiry_map)
raise Gitlab::Access::AccessDeniedError unless has_update_permissions?(member, permission)
true
member.attributes = params
return success(member: member) unless member.changed?
post_update(member, permission, old_access_level_expiry_map) if member.save
end
def multiple_members_update(members, permission, old_access_level_expiry_map)
begin
updated_members =
Member.transaction do
# Using `next` with `filter_map` avoids the `post_update` call for the member that resulted in no change
members.filter_map do |member|
raise Gitlab::Access::AccessDeniedError unless has_update_permissions?(member, permission)
member.attributes = params
next unless member.changed?
member.save!
member
end
end
rescue ActiveRecord::RecordInvalid
return
end
updated_members.each { |member| post_update(member, permission, old_access_level_expiry_map) }
end
def post_update(member, permission, old_access_level_expiry_map)
old_access_level = old_access_level_expiry_map[member.id][:human_access]
old_expiry = old_access_level_expiry_map[member.id][:expires_at]
after_execute(action: permission, old_access_level: old_access_level, old_expiry: old_expiry, member: member)
enqueue_delete_todos(member) if downgrading_to_guest? # Deletes only confidential issues todos for guests
end
def prepare_response(members)
errored_member = members.detect { |member| member.errors.any? }
if errored_member.present?
return error(errored_member.errors.full_messages.to_sentence, pass_back: { member: errored_member })
end
# TODO: Remove the :member key when removing the bulk_update_membership_roles FF and update where it's used.
# https://gitlab.com/gitlab-org/gitlab/-/issues/373257
if members.one?
success(member: members.first)
else
success(members: members)
end
end
def has_update_permissions?(member, permission)
can?(current_user, action_member_permission(permission, member), member) &&
!prevent_upgrade_to_owner?(member) &&
!prevent_downgrade_from_owner?(member)
end
def downgrading_to_guest?

View file

@ -1,8 +1,8 @@
---
name: refactor_mr_widget_test_summary
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/83631
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/358208
milestone: '15.0'
name: bulk_update_membership_roles
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/96745
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/373257
milestone: '15.6'
type: development
group: group::pipeline insights
group: group::workspace
default_enabled: false

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
all_changed_files = helper.all_changed_files
if all_changed_files.detect { |file| file == 'Gemfile' || file == 'Gemfile.lock' }
markdown <<~MSG
## Rubygems
This merge request adds, or changes a Rubygems dependency. Please review the [Gemfile guidelines](https://docs.gitlab.com/ee/development/gemfile.html).
MSG
end

View file

@ -230,6 +230,8 @@ Supported GitHub branch protection rules are mapped to GitLab branch protection
[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/376683) in GitLab 15.6.
- GitHub rule **Require signed commits** for the project's default branch is mapped to the **Reject unsigned commits** GitLab push rule. Requires GitLab Premium or higher.
[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/370949) in GitLab 15.5.
- GitHub rule **Allow force pushes - Everyone** is mapped to the [**Allowed to force push** branch protection rule](../protected_branches.md#allow-force-push-on-a-protected-branch). [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/370943) in GitLab 15.6.
- GitHub rule **Allow force pushes - Specify who can force push** is proposed in issue [370945](https://gitlab.com/gitlab-org/gitlab/-/issues/370945).
- Support for GitHub rule **Require status checks to pass before merging** was proposed in issue [370948](https://gitlab.com/gitlab-org/gitlab/-/issues/370948). However, this rule cannot be translated during project import into GitLab due to technical difficulties.
You can still create [status checks](../merge_requests/status_checks.md) in GitLab yourself.

View file

@ -43,10 +43,14 @@ module Gitlab
end
def allow_force_push?
if ProtectedBranch.protected?(project, protected_branch.id)
ProtectedBranch.allow_force_push?(project, protected_branch.id) && protected_branch.allow_force_pushes
return false unless protected_branch.allow_force_pushes
if protected_on_gitlab?
ProtectedBranch.allow_force_push?(project, protected_branch.id)
elsif default_branch?
!default_branch_protection.any?
else
protected_branch.allow_force_pushes
true
end
end

View file

@ -248,21 +248,11 @@ msgid_plural "%d epics"
msgstr[0] ""
msgstr[1] ""
msgid "%d error"
msgid_plural "%d errors"
msgstr[0] ""
msgstr[1] ""
msgid "%d exporter"
msgid_plural "%d exporters"
msgstr[0] ""
msgstr[1] ""
msgid "%d failed"
msgid_plural "%d failed"
msgstr[0] ""
msgstr[1] ""
msgid "%d failed security job"
msgid_plural "%d failed security jobs"
msgstr[0] ""
@ -273,11 +263,6 @@ msgid_plural "%d files"
msgstr[0] ""
msgstr[1] ""
msgid "%d fixed test result"
msgid_plural "%d fixed test results"
msgstr[0] ""
msgstr[1] ""
msgid "%d fork"
msgid_plural "%d forks"
msgstr[0] ""
@ -857,12 +842,6 @@ msgstr ""
msgid "%{name} (Busy)"
msgstr ""
msgid "%{name} contained %{resultsString}"
msgstr ""
msgid "%{name} found %{resultsString}"
msgstr ""
msgid "%{name} is already being used for another emoji"
msgstr ""
@ -1193,9 +1172,6 @@ msgstr ""
msgid "%{value} is not included in the list"
msgstr ""
msgid "%{value} s"
msgstr ""
msgid "%{verb} %{time_spent_value} spent time."
msgstr ""
@ -34159,18 +34135,12 @@ msgstr ""
msgid "Reports|Base report parsing error:"
msgstr ""
msgid "Reports|Classname"
msgstr ""
msgid "Reports|Copy failed test names to run locally"
msgstr ""
msgid "Reports|Copy failed tests"
msgstr ""
msgid "Reports|Execution time"
msgstr ""
msgid "Reports|Failed %{count} time in %{baseBranch} in the last 14 days"
msgid_plural "Reports|Failed %{count} times in %{baseBranch} in the last 14 days"
msgstr[0] ""
@ -34181,12 +34151,6 @@ msgid_plural "Reports|Failed %{count} times in %{base_branch} in the last 14 day
msgstr[0] ""
msgstr[1] ""
msgid "Reports|Failure"
msgstr ""
msgid "Reports|Filename"
msgstr ""
msgid "Reports|Fixed"
msgstr ""
@ -34229,21 +34193,12 @@ msgstr ""
msgid "Reports|Severity"
msgstr ""
msgid "Reports|System output"
msgstr ""
msgid "Reports|Test summary"
msgstr ""
msgid "Reports|Test summary failed loading results"
msgstr ""
msgid "Reports|Test summary failed to load results"
msgstr ""
msgid "Reports|Test summary results are being parsed"
msgstr ""
msgid "Reports|Test summary results are loading"
msgstr ""
@ -34259,9 +34214,6 @@ msgstr ""
msgid "Reports|metrics report"
msgstr ""
msgid "Reports|no changed test results"
msgstr ""
msgid "Repositories"
msgstr ""
@ -48971,11 +48923,6 @@ msgstr ""
msgid "organizations can only be added to root groups"
msgstr ""
msgid "out of %d total test"
msgid_plural "out of %d total tests"
msgstr[0] ""
msgstr[1] ""
msgid "packages"
msgstr ""

View file

@ -14,15 +14,13 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
let(:merge_request_in_only_mwps_project) { create(:merge_request, source_project: project_only_mwps) }
def click_expand_button
find('[data-testid="report-section-expand-button"]').click
find('[data-testid="toggle-button"]').click
end
before do
project.add_maintainer(user)
project_only_mwps.add_maintainer(user)
sign_in(user)
stub_feature_flags(refactor_mr_widget_test_summary: false)
end
context 'new merge request', :sidekiq_might_not_need_inline do
@ -530,7 +528,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
end
it 'shows parsing status' do
expect(page).to have_content('Test summary results are being parsed')
expect(page).to have_content('Test summary results are loading')
end
end
@ -545,7 +543,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
end
it 'shows parsed results' do
expect(page).to have_content('Test summary contained')
expect(page).to have_content('Test summary:')
end
end
@ -559,7 +557,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
end
it 'shows the error state' do
expect(page).to have_content('Test summary failed loading results')
expect(page).to have_content('Test summary failed to load results')
end
end
@ -606,13 +604,13 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
end
it 'shows test reports summary which includes the new failure' do
within(".js-reports-container") do
within('[data-testid="widget-extension"]') do
click_expand_button
expect(page).to have_content('Test summary contained 1 failed out of 2 total tests')
within(".js-report-section-container") do
expect(page).to have_content('rspec found no changed test results out of 1 total test')
expect(page).to have_content('junit found 1 failed out of 1 total test')
expect(page).to have_content('Test summary: 1 failed, 2 total tests')
within('[data-testid="widget-extension-collapsed-section"]') do
expect(page).to have_content('rspec: no changed test results, 1 total test')
expect(page).to have_content('junit: 1 failed, 1 total test')
expect(page).to have_content('New')
expect(page).to have_content('addTest')
end
@ -621,15 +619,15 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
context 'when user clicks the new failure' do
it 'shows the test report detail' do
within(".js-reports-container") do
within('[data-testid="widget-extension"]') do
click_expand_button
within(".js-report-section-container") do
click_button 'addTest'
within('[data-testid="widget-extension-collapsed-section"]') do
click_link 'addTest'
end
end
within("#modal-mrwidget-reports") do
within('[data-testid="test-case-details-modal"]') do
expect(page).to have_content('addTest')
expect(page).to have_content('6.66')
expect(page).to have_content(sample_java_failed_message.gsub(/\s+/, ' ').strip)
@ -654,13 +652,13 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
end
it 'shows test reports summary which includes the existing failure' do
within(".js-reports-container") do
within('[data-testid="widget-extension"]') do
click_expand_button
expect(page).to have_content('Test summary contained 1 failed out of 2 total tests')
within(".js-report-section-container") do
expect(page).to have_content('rspec found 1 failed out of 1 total test')
expect(page).to have_content('junit found no changed test results out of 1 total test')
expect(page).to have_content('Test summary: 1 failed, 2 total tests')
within('[data-testid="widget-extension-collapsed-section"]') do
expect(page).to have_content('rspec: 1 failed, 1 total test')
expect(page).to have_content('junit: no changed test results, 1 total test')
expect(page).to have_content('Test#sum when a is 1 and b is 3 returns summary')
end
end
@ -668,15 +666,15 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
context 'when user clicks the existing failure' do
it 'shows test report detail of it' do
within(".js-reports-container") do
within('[data-testid="widget-extension"]') do
click_expand_button
within(".js-report-section-container") do
click_button 'Test#sum when a is 1 and b is 3 returns summary'
within('[data-testid="widget-extension-collapsed-section"]') do
click_link 'Test#sum when a is 1 and b is 3 returns summary'
end
end
within("#modal-mrwidget-reports") do
within('[data-testid="test-case-details-modal"]') do
expect(page).to have_content('Test#sum when a is 1 and b is 3 returns summary')
expect(page).to have_content('2.22')
expect(page).to have_content(sample_rspec_failed_message.gsub(/\s+/, ' ').strip)
@ -701,13 +699,14 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
end
it 'shows test reports summary which includes the resolved failure' do
within(".js-reports-container") do
within('[data-testid="widget-extension"]') do
click_expand_button
expect(page).to have_content('Test summary contained 1 fixed test result out of 2 total tests')
within(".js-report-section-container") do
expect(page).to have_content('rspec found no changed test results out of 1 total test')
expect(page).to have_content('junit found 1 fixed test result out of 1 total test')
expect(page).to have_content('Test summary: 1 fixed test result, 2 total tests')
within('[data-testid="widget-extension-collapsed-section"]') do
expect(page).to have_content('rspec: no changed test results, 1 total test')
expect(page).to have_content('junit: 1 fixed test result, 1 total test')
expect(page).to have_content('Fixed')
expect(page).to have_content('addTest')
end
end
@ -715,15 +714,15 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
context 'when user clicks the resolved failure' do
it 'shows test report detail of it' do
within(".js-reports-container") do
within('[data-testid="widget-extension"]') do
click_expand_button
within(".js-report-section-container") do
click_button 'addTest'
within('[data-testid="widget-extension-collapsed-section"]') do
click_link 'addTest'
end
end
within("#modal-mrwidget-reports") do
within('[data-testid="test-case-details-modal"]') do
expect(page).to have_content('addTest')
expect(page).to have_content('5.55')
end
@ -747,13 +746,13 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
end
it 'shows test reports summary which includes the new error' do
within(".js-reports-container") do
within('[data-testid="widget-extension"]') do
click_expand_button
expect(page).to have_content('Test summary contained 1 error out of 2 total tests')
within(".js-report-section-container") do
expect(page).to have_content('rspec found no changed test results out of 1 total test')
expect(page).to have_content('junit found 1 error out of 1 total test')
expect(page).to have_content('Test summary: 1 error, 2 total tests')
within('[data-testid="widget-extension-collapsed-section"]') do
expect(page).to have_content('rspec: no changed test results, 1 total test')
expect(page).to have_content('junit: 1 error, 1 total test')
expect(page).to have_content('New')
expect(page).to have_content('addTest')
end
@ -762,15 +761,15 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
context 'when user clicks the new error' do
it 'shows the test report detail' do
within(".js-reports-container") do
within('[data-testid="widget-extension"]') do
click_expand_button
within(".js-report-section-container") do
click_button 'addTest'
within('[data-testid="widget-extension-collapsed-section"]') do
click_link 'addTest'
end
end
within("#modal-mrwidget-reports") do
within('[data-testid="test-case-details-modal"]') do
expect(page).to have_content('addTest')
expect(page).to have_content('8.88')
end
@ -794,13 +793,13 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
end
it 'shows test reports summary which includes the existing error' do
within(".js-reports-container") do
within('[data-testid="widget-extension"]') do
click_expand_button
expect(page).to have_content('Test summary contained 1 error out of 2 total tests')
within(".js-report-section-container") do
expect(page).to have_content('rspec found 1 error out of 1 total test')
expect(page).to have_content('junit found no changed test results out of 1 total test')
expect(page).to have_content('Test summary: 1 error, 2 total tests')
within('[data-testid="widget-extension-collapsed-section"]') do
expect(page).to have_content('rspec: 1 error, 1 total test')
expect(page).to have_content('junit: no changed test results, 1 total test')
expect(page).to have_content('Test#sum when a is 4 and b is 4 returns summary')
end
end
@ -808,15 +807,15 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
context 'when user clicks the existing error' do
it 'shows test report detail of it' do
within(".js-reports-container") do
within('[data-testid="widget-extension"]') do
click_expand_button
within(".js-report-section-container") do
click_button 'Test#sum when a is 4 and b is 4 returns summary'
within('[data-testid="widget-extension-collapsed-section"]') do
click_link 'Test#sum when a is 4 and b is 4 returns summary'
end
end
within("#modal-mrwidget-reports") do
within('[data-testid="test-case-details-modal"]') do
expect(page).to have_content('Test#sum when a is 4 and b is 4 returns summary')
expect(page).to have_content('4.44')
end
@ -840,13 +839,14 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
end
it 'shows test reports summary which includes the resolved error' do
within(".js-reports-container") do
within('[data-testid="widget-extension"]') do
click_expand_button
expect(page).to have_content('Test summary contained 1 fixed test result out of 2 total tests')
within(".js-report-section-container") do
expect(page).to have_content('rspec found no changed test results out of 1 total test')
expect(page).to have_content('junit found 1 fixed test result out of 1 total test')
expect(page).to have_content('Test summary: 1 fixed test result, 2 total tests')
within('[data-testid="widget-extension-collapsed-section"]') do
expect(page).to have_content('rspec: no changed test results, 1 total test')
expect(page).to have_content('junit: 1 fixed test result, 1 total test')
expect(page).to have_content('Fixed')
expect(page).to have_content('addTest')
end
end
@ -854,15 +854,15 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
context 'when user clicks the resolved error' do
it 'shows test report detail of it' do
within(".js-reports-container") do
within('[data-testid="widget-extension"]') do
click_expand_button
within(".js-report-section-container") do
click_button 'addTest'
within('[data-testid="widget-extension-collapsed-section"]') do
click_link 'addTest'
end
end
within("#modal-mrwidget-reports") do
within('[data-testid="test-case-details-modal"]') do
expect(page).to have_content('addTest')
expect(page).to have_content('5.55')
end
@ -894,13 +894,13 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
end
it 'shows test reports summary which includes the resolved failure' do
within(".js-reports-container") do
within('[data-testid="widget-extension"]') do
click_expand_button
expect(page).to have_content('Test summary contained 20 failed out of 20 total tests')
within(".js-report-section-container") do
expect(page).to have_content('rspec found 10 failed out of 10 total tests')
expect(page).to have_content('junit found 10 failed out of 10 total tests')
expect(page).to have_content('Test summary: 20 failed, 20 total tests')
within('[data-testid="widget-extension-collapsed-section"]') do
expect(page).to have_content('rspec: 10 failed, 10 total tests')
expect(page).to have_content('junit: 10 failed, 10 total tests')
expect(page).to have_content('Test#sum when a is 1 and b is 3 returns summary', count: 2)
end

View file

@ -13,7 +13,7 @@ Object {
exports[`Grouped Issues List with data renders a report item with the correct props 1`] = `
Object {
"component": "TestIssueBody",
"component": "CodequalityIssueBody",
"iconComponent": "IssueStatusIcon",
"isNew": false,
"issue": Object {

View file

@ -74,7 +74,7 @@ describe('Grouped Issues List', () => {
createComponent({
propsData: {
resolvedIssues: [{ name: 'foo' }],
component: 'TestIssueBody',
component: 'CodequalityIssueBody',
},
stubs: {
ReportItem,

View file

@ -10,7 +10,7 @@ describe('ReportItem', () => {
const wrapper = shallowMount(ReportItem, {
propsData: {
issue: { foo: 'bar' },
component: componentNames.TestIssueBody,
component: componentNames.CodequalityIssueBody,
status: STATUS_SUCCESS,
showReportSectionStatusIcon: false,
},
@ -23,7 +23,7 @@ describe('ReportItem', () => {
const wrapper = shallowMount(ReportItem, {
propsData: {
issue: { foo: 'bar' },
component: componentNames.TestIssueBody,
component: componentNames.CodequalityIssueBody,
status: STATUS_SUCCESS,
},
});

View file

@ -1,68 +0,0 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ReportsModal from '~/reports/grouped_test_report/components/modal.vue';
import state from '~/reports/grouped_test_report/store/state';
import CodeBlock from '~/vue_shared/components/code_block.vue';
const StubbedGlModal = { template: '<div><slot></slot></div>', name: 'GlModal', props: ['title'] };
describe('Grouped Test Reports Modal', () => {
const modalDataStructure = state().modal.data;
const title = 'Test#sum when a is 1 and b is 2 returns summary';
// populate data
modalDataStructure.execution_time.value = 0.009411;
modalDataStructure.system_output.value = 'Failure/Error: is_expected.to eq(3)\n\n';
modalDataStructure.filename.value = {
text: 'link',
path: '/file/path',
};
let wrapper;
beforeEach(() => {
wrapper = extendedWrapper(
shallowMount(ReportsModal, {
propsData: {
title,
modalData: modalDataStructure,
visible: true,
},
stubs: { GlModal: StubbedGlModal, GlSprintf },
}),
);
});
afterEach(() => {
wrapper.destroy();
});
it('renders code block', () => {
expect(wrapper.findComponent(CodeBlock).props().code).toEqual(
modalDataStructure.system_output.value,
);
});
it('renders link', () => {
const link = wrapper.findComponent(GlLink);
expect(link.attributes().href).toEqual(modalDataStructure.filename.value.path);
expect(link.text()).toEqual(modalDataStructure.filename.value.text);
});
it('renders seconds', () => {
expect(wrapper.text()).toContain(`${modalDataStructure.execution_time.value} s`);
});
it('render title', () => {
expect(wrapper.findComponent(StubbedGlModal).props().title).toEqual(title);
});
it('re-emits hide event', () => {
wrapper.findComponent(StubbedGlModal).vm.$emit('hide');
expect(wrapper.emitted().hide).toEqual([[]]);
});
});

View file

@ -1,96 +0,0 @@
import { GlBadge, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
import TestIssueBody from '~/reports/grouped_test_report/components/test_issue_body.vue';
import { failedIssue, successIssue } from '../../mock_data/mock_data';
Vue.use(Vuex);
describe('Test issue body', () => {
let wrapper;
let store;
const findDescription = () => wrapper.findByTestId('test-issue-body-description');
const findStatusIcon = () => wrapper.findComponent(IssueStatusIcon);
const findBadge = () => wrapper.findComponent(GlBadge);
const actionSpies = {
openModal: jest.fn(),
};
const createComponent = ({ issue = failedIssue } = {}) => {
store = new Vuex.Store({
actions: actionSpies,
});
wrapper = extendedWrapper(
shallowMount(TestIssueBody, {
store,
propsData: {
issue,
},
stubs: {
GlBadge,
GlButton,
IssueStatusIcon,
},
}),
);
};
afterEach(() => {
wrapper.destroy();
});
describe('when issue has failed status', () => {
beforeEach(() => {
createComponent();
});
it('renders issue name', () => {
expect(findDescription().text()).toContain(failedIssue.name);
});
it('renders failed status icon', () => {
expect(findStatusIcon().props('status')).toBe('failed');
});
describe('when issue has recent failures', () => {
it('renders recent failures badge', () => {
expect(findBadge().exists()).toBe(true);
});
});
});
describe('when issue has success status', () => {
beforeEach(() => {
createComponent({ issue: successIssue });
});
it('does not render recent failures', () => {
expect(findBadge().exists()).toBe(false);
});
it('renders issue name', () => {
expect(findDescription().text()).toBe(successIssue.name);
});
it('renders success status icon', () => {
expect(findStatusIcon().props('status')).toBe('success');
});
});
describe('when clicking on an issue', () => {
it('calls openModal action', () => {
createComponent();
wrapper.findComponent(GlButton).trigger('click');
expect(actionSpies.openModal).toHaveBeenCalledWith(expect.any(Object), {
issue: failedIssue,
});
});
});
});

View file

@ -1,355 +0,0 @@
import { mount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import Api from '~/api';
import GroupedTestReportsApp from '~/reports/grouped_test_report/grouped_test_reports_app.vue';
import { getStoreConfig } from '~/reports/grouped_test_report/store';
import { failedReport } from '../mock_data/mock_data';
import mixedResultsTestReports from '../mock_data/new_and_fixed_failures_report.json';
import newErrorsTestReports from '../mock_data/new_errors_report.json';
import newFailedTestReports from '../mock_data/new_failures_report.json';
import successTestReports from '../mock_data/no_failures_report.json';
import recentFailuresTestReports from '../mock_data/recent_failures_report.json';
import resolvedFailures from '../mock_data/resolved_failures.json';
jest.mock('~/api.js');
Vue.use(Vuex);
describe('Grouped test reports app', () => {
const endpoint = 'endpoint.json';
const headBlobPath = '/blob/path';
const pipelinePath = '/path/to/pipeline';
let wrapper;
let mockStore;
const mountComponent = ({ props = { pipelinePath } } = {}) => {
wrapper = mount(GroupedTestReportsApp, {
store: mockStore,
propsData: {
endpoint,
headBlobPath,
pipelinePath,
...props,
},
});
};
const setReports = (reports) => {
mockStore.state.status = reports.status;
mockStore.state.summary = reports.summary;
mockStore.state.reports = reports.suites;
};
const findHeader = () => wrapper.find('[data-testid="report-section-code-text"]');
const findExpandButton = () => wrapper.find('[data-testid="report-section-expand-button"]');
const findFullTestReportLink = () => wrapper.find('[data-testid="group-test-reports-full-link"]');
const findSummaryDescription = () => wrapper.find('[data-testid="summary-row-description"]');
const findIssueListUnresolvedHeading = () => wrapper.find('[data-testid="unresolvedHeading"]');
const findIssueListResolvedHeading = () => wrapper.find('[data-testid="resolvedHeading"]');
const findIssueDescription = () => wrapper.find('[data-testid="test-issue-body-description"]');
const findIssueRecentFailures = () =>
wrapper.find('[data-testid="test-issue-body-recent-failures"]');
const findAllIssueDescriptions = () =>
wrapper.findAll('[data-testid="test-issue-body-description"]');
beforeEach(() => {
mockStore = new Vuex.Store({
...getStoreConfig(),
actions: {
fetchReports: () => {},
setPaths: () => {},
},
});
mountComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('with success result', () => {
beforeEach(() => {
setReports(successTestReports);
mountComponent();
});
it('renders success summary text', () => {
expect(findHeader().text()).toBe(
'Test summary contained no changed test results out of 11 total tests',
);
});
});
describe('`View full report` button', () => {
it('should render the full test report link', () => {
const fullTestReportLink = findFullTestReportLink();
expect(fullTestReportLink.exists()).toBe(true);
expect(pipelinePath).not.toBe('');
expect(fullTestReportLink.attributes('href')).toBe(`${pipelinePath}/test_report`);
});
describe('Without a pipelinePath', () => {
beforeEach(() => {
mountComponent({
props: { pipelinePath: '' },
});
});
it('should not render the full test report link', () => {
expect(findFullTestReportLink().exists()).toBe(false);
});
});
});
describe('`Expand` button', () => {
beforeEach(() => {
setReports(newFailedTestReports);
});
it('tracks service ping metric', () => {
mountComponent();
findExpandButton().trigger('click');
expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1);
expect(Api.trackRedisHllUserEvent).toHaveBeenCalledWith(wrapper.vm.$options.expandEvent);
});
it('only tracks the first expansion', () => {
mountComponent();
const expandButton = findExpandButton();
expandButton.trigger('click');
expandButton.trigger('click');
expandButton.trigger('click');
expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1);
});
});
describe('with new failed result', () => {
beforeEach(() => {
setReports(newFailedTestReports);
mountComponent();
});
it('renders New heading', () => {
expect(findIssueListUnresolvedHeading().text()).toBe('New');
});
it('renders failed summary text', () => {
expect(findHeader().text()).toBe('Test summary contained 2 failed out of 11 total tests');
});
it('renders failed test suite', () => {
expect(findSummaryDescription().text()).toContain(
'rspec:pg found 2 failed out of 8 total tests',
);
});
it('renders failed issue in list', () => {
expect(findIssueDescription().text()).toContain(
'Test#sum when a is 1 and b is 2 returns summary',
);
});
});
describe('with new error result', () => {
beforeEach(() => {
setReports(newErrorsTestReports);
mountComponent();
});
it('renders New heading', () => {
expect(findIssueListUnresolvedHeading().text()).toBe('New');
});
it('renders error summary text', () => {
expect(findHeader().text()).toBe('Test summary contained 2 errors out of 11 total tests');
});
it('renders error test suite', () => {
expect(findSummaryDescription().text()).toContain(
'karma found 2 errors out of 3 total tests',
);
});
it('renders error issue in list', () => {
expect(findIssueDescription().text()).toContain(
'Test#sum when a is 1 and b is 2 returns summary',
);
});
});
describe('with mixed results', () => {
beforeEach(() => {
setReports(mixedResultsTestReports);
mountComponent();
});
it('renders New and Fixed headings', () => {
expect(findIssueListUnresolvedHeading().text()).toBe('New');
expect(findIssueListResolvedHeading().text()).toBe('Fixed');
});
it('renders summary text', () => {
expect(findHeader().text()).toBe(
'Test summary contained 2 failed and 2 fixed test results out of 11 total tests',
);
});
it('renders failed test suite', () => {
expect(findSummaryDescription().text()).toContain(
'rspec:pg found 1 failed and 2 fixed test results out of 8 total tests',
);
});
it('renders failed issue in list', () => {
expect(findIssueDescription().text()).toContain(
'Test#subtract when a is 2 and b is 1 returns correct result',
);
});
});
describe('with resolved failures and resolved errors', () => {
beforeEach(() => {
setReports(resolvedFailures);
mountComponent();
});
it('renders Fixed heading', () => {
expect(findIssueListResolvedHeading().text()).toBe('Fixed');
});
it('renders summary text', () => {
expect(findHeader().text()).toBe(
'Test summary contained 4 fixed test results out of 11 total tests',
);
});
it('renders resolved test suite', () => {
expect(findSummaryDescription().text()).toContain(
'rspec:pg found 4 fixed test results out of 8 total tests',
);
});
it('renders resolved failures', () => {
expect(findIssueDescription().text()).toContain(
resolvedFailures.suites[0].resolved_failures[0].name,
);
});
it('renders resolved errors', () => {
expect(findAllIssueDescriptions().at(2).text()).toContain(
resolvedFailures.suites[0].resolved_errors[0].name,
);
});
});
describe('recent failures counts', () => {
describe('with recent failures counts', () => {
beforeEach(() => {
setReports(recentFailuresTestReports);
mountComponent();
});
it('renders the recently failed tests summary', () => {
expect(findHeader().text()).toContain(
'2 out of 3 failed tests have failed more than once in the last 14 days',
);
});
it('renders the recently failed count on the test suite', () => {
expect(findSummaryDescription().text()).toContain(
'1 out of 2 failed tests has failed more than once in the last 14 days',
);
});
it('renders the recent failures count on the test case', () => {
expect(findIssueRecentFailures().text()).toBe('Failed 8 times in main in the last 14 days');
});
});
describe('without recent failures counts', () => {
beforeEach(() => {
setReports(mixedResultsTestReports);
mountComponent();
});
it('does not render the recently failed tests summary', () => {
expect(findHeader().text()).not.toContain('failed more than once in the last 14 days');
});
it('does not render the recently failed count on the test suite', () => {
expect(findSummaryDescription().text()).not.toContain(
'failed more than once in the last 14 days',
);
});
it('does not render the recent failures count on the test case', () => {
expect(findIssueDescription().text()).not.toContain('in the last 14 days');
});
});
});
describe('with a report that failed to load', () => {
beforeEach(() => {
setReports(failedReport);
mountComponent();
});
it('renders an error status for the report', () => {
const { name } = failedReport.suites[0];
expect(findSummaryDescription().text()).toContain(
`An error occurred while loading ${name} result`,
);
});
});
describe('with a report parsing errors', () => {
beforeEach(() => {
const reports = failedReport;
reports.suites[0].suite_errors = {
head: 'JUnit XML parsing failed: 2:24: FATAL: attributes construct error',
base: 'JUnit data parsing failed: string not matched',
};
setReports(reports);
mountComponent();
});
it('renders the error messages', () => {
expect(findSummaryDescription().text()).toContain(
'JUnit XML parsing failed: 2:24: FATAL: attributes construct error',
);
expect(findSummaryDescription().text()).toContain(
'JUnit data parsing failed: string not matched',
);
});
});
describe('with error', () => {
beforeEach(() => {
mockStore.state.isLoading = false;
mockStore.state.hasError = true;
mountComponent();
});
it('renders loading state', () => {
expect(findHeader().text()).toBe('Test summary failed loading results');
});
});
describe('while loading', () => {
beforeEach(() => {
mockStore.state.isLoading = true;
mountComponent();
});
it('renders loading state', () => {
expect(findHeader().text()).toBe('Test summary results are being parsed');
});
});
});

View file

@ -1,168 +0,0 @@
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils';
import {
setPaths,
requestReports,
fetchReports,
stopPolling,
clearEtagPoll,
receiveReportsSuccess,
receiveReportsError,
openModal,
closeModal,
} from '~/reports/grouped_test_report/store/actions';
import * as types from '~/reports/grouped_test_report/store/mutation_types';
import state from '~/reports/grouped_test_report/store/state';
describe('Reports Store Actions', () => {
let mockedState;
beforeEach(() => {
mockedState = state();
});
describe('setPaths', () => {
it('should commit SET_PATHS mutation', () => {
return testAction(
setPaths,
{ endpoint: 'endpoint.json', headBlobPath: '/blob/path' },
mockedState,
[
{
type: types.SET_PATHS,
payload: { endpoint: 'endpoint.json', headBlobPath: '/blob/path' },
},
],
[],
);
});
});
describe('requestReports', () => {
it('should commit REQUEST_REPORTS mutation', () => {
return testAction(requestReports, null, mockedState, [{ type: types.REQUEST_REPORTS }], []);
});
});
describe('fetchReports', () => {
let mock;
beforeEach(() => {
mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
stopPolling();
clearEtagPoll();
});
describe('success', () => {
it('dispatches requestReports and receiveReportsSuccess', () => {
mock
.onGet(`${TEST_HOST}/endpoint.json`)
.replyOnce(200, { summary: {}, suites: [{ name: 'rspec' }] });
return testAction(
fetchReports,
null,
mockedState,
[],
[
{
type: 'requestReports',
},
{
payload: { data: { summary: {}, suites: [{ name: 'rspec' }] }, status: 200 },
type: 'receiveReportsSuccess',
},
],
);
});
});
describe('error', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
});
it('dispatches requestReports and receiveReportsError', () => {
return testAction(
fetchReports,
null,
mockedState,
[],
[
{
type: 'requestReports',
},
{
type: 'receiveReportsError',
},
],
);
});
});
});
describe('receiveReportsSuccess', () => {
it('should commit RECEIVE_REPORTS_SUCCESS mutation with 200', () => {
return testAction(
receiveReportsSuccess,
{ data: { summary: {} }, status: 200 },
mockedState,
[{ type: types.RECEIVE_REPORTS_SUCCESS, payload: { summary: {} } }],
[],
);
});
it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', () => {
return testAction(
receiveReportsSuccess,
{ data: { summary: {} }, status: 204 },
mockedState,
[],
[],
);
});
});
describe('receiveReportsError', () => {
it('should commit RECEIVE_REPORTS_ERROR mutation', () => {
return testAction(
receiveReportsError,
null,
mockedState,
[{ type: types.RECEIVE_REPORTS_ERROR }],
[],
);
});
});
describe('openModal', () => {
it('should commit SET_ISSUE_MODAL_DATA', () => {
return testAction(
openModal,
{ name: 'foo' },
mockedState,
[{ type: types.SET_ISSUE_MODAL_DATA, payload: { name: 'foo' } }],
[],
);
});
});
describe('closeModal', () => {
it('should commit RESET_ISSUE_MODAL_DATA', () => {
return testAction(
closeModal,
{},
mockedState,
[{ type: types.RESET_ISSUE_MODAL_DATA, payload: {} }],
[],
);
});
});
});

View file

@ -1,162 +0,0 @@
import * as types from '~/reports/grouped_test_report/store/mutation_types';
import mutations from '~/reports/grouped_test_report/store/mutations';
import state from '~/reports/grouped_test_report/store/state';
import { failedIssue } from '../../mock_data/mock_data';
describe('Reports Store Mutations', () => {
let stateCopy;
beforeEach(() => {
stateCopy = state();
});
describe('SET_PATHS', () => {
it('should set endpoint', () => {
mutations[types.SET_PATHS](stateCopy, {
endpoint: 'endpoint.json',
headBlobPath: '/blob/path',
});
expect(stateCopy.endpoint).toEqual('endpoint.json');
expect(stateCopy.headBlobPath).toEqual('/blob/path');
});
});
describe('REQUEST_REPORTS', () => {
it('should set isLoading to true', () => {
mutations[types.REQUEST_REPORTS](stateCopy);
expect(stateCopy.isLoading).toEqual(true);
});
});
describe('RECEIVE_REPORTS_SUCCESS', () => {
const mockedResponse = {
summary: {
total: 14,
resolved: 0,
failed: 7,
},
suites: [
{
name: 'build:linux',
summary: {
total: 2,
resolved: 0,
failed: 1,
},
new_failures: [
{
name: 'StringHelper#concatenate when a is git and b is lab returns summary',
execution_time: 0.0092435,
system_output: "Failure/Error: is_expected.to eq('gitlab')",
recent_failures: {
count: 4,
base_branch: 'main',
},
},
],
resolved_failures: [
{
name: 'StringHelper#concatenate when a is git and b is lab returns summary',
execution_time: 0.009235,
system_output: "Failure/Error: is_expected.to eq('gitlab')",
},
],
existing_failures: [
{
name: 'StringHelper#concatenate when a is git and b is lab returns summary',
execution_time: 1232.08,
system_output: "Failure/Error: is_expected.to eq('gitlab')",
},
],
},
],
};
beforeEach(() => {
mutations[types.RECEIVE_REPORTS_SUCCESS](stateCopy, mockedResponse);
});
it('should reset isLoading', () => {
expect(stateCopy.isLoading).toEqual(false);
});
it('should reset hasError', () => {
expect(stateCopy.hasError).toEqual(false);
});
it('should set summary counts', () => {
expect(stateCopy.summary.total).toEqual(mockedResponse.summary.total);
expect(stateCopy.summary.resolved).toEqual(mockedResponse.summary.resolved);
expect(stateCopy.summary.failed).toEqual(mockedResponse.summary.failed);
expect(stateCopy.summary.recentlyFailed).toEqual(1);
});
it('should set reports', () => {
expect(stateCopy.reports).toEqual(mockedResponse.suites);
});
});
describe('RECEIVE_REPORTS_ERROR', () => {
beforeEach(() => {
mutations[types.RECEIVE_REPORTS_ERROR](stateCopy);
});
it('should reset isLoading', () => {
expect(stateCopy.isLoading).toEqual(false);
});
it('should set hasError to true', () => {
expect(stateCopy.hasError).toEqual(true);
});
it('should reset reports', () => {
expect(stateCopy.reports).toEqual([]);
});
});
describe('SET_ISSUE_MODAL_DATA', () => {
beforeEach(() => {
mutations[types.SET_ISSUE_MODAL_DATA](stateCopy, {
issue: failedIssue,
});
});
it('should set modal title', () => {
expect(stateCopy.modal.title).toEqual(failedIssue.name);
});
it('should set modal data', () => {
expect(stateCopy.modal.data.execution_time.value).toEqual(failedIssue.execution_time);
expect(stateCopy.modal.data.system_output.value).toEqual(failedIssue.system_output);
});
it('should open modal', () => {
expect(stateCopy.modal.open).toEqual(true);
});
});
describe('RESET_ISSUE_MODAL_DATA', () => {
beforeEach(() => {
mutations[types.SET_ISSUE_MODAL_DATA](stateCopy, {
issue: failedIssue,
});
mutations[types.RESET_ISSUE_MODAL_DATA](stateCopy);
});
it('should reset modal title', () => {
expect(stateCopy.modal.title).toEqual(null);
});
it('should reset modal data', () => {
expect(stateCopy.modal.data.execution_time.value).toEqual(null);
expect(stateCopy.modal.data.system_output.value).toEqual(null);
});
it('should close modal', () => {
expect(stateCopy.modal.open).toEqual(false);
});
});
});

View file

@ -1,255 +0,0 @@
import {
STATUS_FAILED,
STATUS_SUCCESS,
ICON_WARNING,
ICON_SUCCESS,
ICON_NOTFOUND,
} from '~/reports/constants';
import * as utils from '~/reports/grouped_test_report/store/utils';
describe('Reports store utils', () => {
describe('summaryTextbuilder', () => {
it('should render text for no changed results in multiple tests', () => {
const name = 'Test summary';
const data = { total: 10 };
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe('Test summary contained no changed test results out of 10 total tests');
});
it('should render text for no changed results in one test', () => {
const name = 'Test summary';
const data = { total: 1 };
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe('Test summary contained no changed test results out of 1 total test');
});
it('should render text for multiple failed results', () => {
const name = 'Test summary';
const data = { failed: 3, total: 10 };
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe('Test summary contained 3 failed out of 10 total tests');
});
it('should render text for multiple errored results', () => {
const name = 'Test summary';
const data = { errored: 7, total: 10 };
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe('Test summary contained 7 errors out of 10 total tests');
});
it('should render text for multiple fixed results', () => {
const name = 'Test summary';
const data = { resolved: 4, total: 10 };
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe('Test summary contained 4 fixed test results out of 10 total tests');
});
it('should render text for multiple fixed, and multiple failed results', () => {
const name = 'Test summary';
const data = { failed: 3, resolved: 4, total: 10 };
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe(
'Test summary contained 3 failed and 4 fixed test results out of 10 total tests',
);
});
it('should render text for a singular fixed, and a singular failed result', () => {
const name = 'Test summary';
const data = { failed: 1, resolved: 1, total: 10 };
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe(
'Test summary contained 1 failed and 1 fixed test result out of 10 total tests',
);
});
it('should render text for singular failed, errored, and fixed results', () => {
const name = 'Test summary';
const data = { failed: 1, errored: 1, resolved: 1, total: 10 };
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe(
'Test summary contained 1 failed, 1 error and 1 fixed test result out of 10 total tests',
);
});
it('should render text for multiple failed, errored, and fixed results', () => {
const name = 'Test summary';
const data = { failed: 2, errored: 3, resolved: 4, total: 10 };
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe(
'Test summary contained 2 failed, 3 errors and 4 fixed test results out of 10 total tests',
);
});
});
describe('reportTextBuilder', () => {
it('should render text for no changed results in multiple tests', () => {
const name = 'Rspec';
const data = { total: 10 };
const result = utils.reportTextBuilder(name, data);
expect(result).toBe('Rspec found no changed test results out of 10 total tests');
});
it('should render text for no changed results in one test', () => {
const name = 'Rspec';
const data = { total: 1 };
const result = utils.reportTextBuilder(name, data);
expect(result).toBe('Rspec found no changed test results out of 1 total test');
});
it('should render text for multiple failed results', () => {
const name = 'Rspec';
const data = { failed: 3, total: 10 };
const result = utils.reportTextBuilder(name, data);
expect(result).toBe('Rspec found 3 failed out of 10 total tests');
});
it('should render text for multiple errored results', () => {
const name = 'Rspec';
const data = { errored: 7, total: 10 };
const result = utils.reportTextBuilder(name, data);
expect(result).toBe('Rspec found 7 errors out of 10 total tests');
});
it('should render text for multiple fixed results', () => {
const name = 'Rspec';
const data = { resolved: 4, total: 10 };
const result = utils.reportTextBuilder(name, data);
expect(result).toBe('Rspec found 4 fixed test results out of 10 total tests');
});
it('should render text for multiple fixed, and multiple failed results', () => {
const name = 'Rspec';
const data = { failed: 3, resolved: 4, total: 10 };
const result = utils.reportTextBuilder(name, data);
expect(result).toBe('Rspec found 3 failed and 4 fixed test results out of 10 total tests');
});
it('should render text for a singular fixed, and a singular failed result', () => {
const name = 'Rspec';
const data = { failed: 1, resolved: 1, total: 10 };
const result = utils.reportTextBuilder(name, data);
expect(result).toBe('Rspec found 1 failed and 1 fixed test result out of 10 total tests');
});
it('should render text for singular failed, errored, and fixed results', () => {
const name = 'Rspec';
const data = { failed: 1, errored: 1, resolved: 1, total: 10 };
const result = utils.reportTextBuilder(name, data);
expect(result).toBe(
'Rspec found 1 failed, 1 error and 1 fixed test result out of 10 total tests',
);
});
it('should render text for multiple failed, errored, and fixed results', () => {
const name = 'Rspec';
const data = { failed: 2, errored: 3, resolved: 4, total: 10 };
const result = utils.reportTextBuilder(name, data);
expect(result).toBe(
'Rspec found 2 failed, 3 errors and 4 fixed test results out of 10 total tests',
);
});
});
describe('recentFailuresTextBuilder', () => {
it.each`
recentlyFailed | failed | expected
${0} | ${1} | ${''}
${1} | ${1} | ${'1 out of 1 failed test has failed more than once in the last 14 days'}
${1} | ${2} | ${'1 out of 2 failed tests has failed more than once in the last 14 days'}
${2} | ${3} | ${'2 out of 3 failed tests have failed more than once in the last 14 days'}
`(
'should render summary for $recentlyFailed out of $failed failures',
({ recentlyFailed, failed, expected }) => {
const result = utils.recentFailuresTextBuilder({ recentlyFailed, failed });
expect(result).toBe(expected);
},
);
});
describe('countRecentlyFailedTests', () => {
it('counts tests with more than one recent failure in a report', () => {
const report = {
new_failures: [{ recent_failures: { count: 2 } }],
existing_failures: [{ recent_failures: { count: 1 } }],
resolved_failures: [{ recent_failures: { count: 20 } }, { recent_failures: { count: 5 } }],
};
const result = utils.countRecentlyFailedTests(report);
expect(result).toBe(3);
});
it('counts tests with more than one recent failure in an array of reports', () => {
const reports = [
{
new_failures: [{ recent_failures: { count: 2 } }],
existing_failures: [
{ recent_failures: { count: 20 } },
{ recent_failures: { count: 5 } },
],
resolved_failures: [{ recent_failures: { count: 2 } }],
},
{
new_failures: [{ recent_failures: { count: 8 } }, { recent_failures: { count: 14 } }],
existing_failures: [{ recent_failures: { count: 1 } }],
resolved_failures: [{ recent_failures: { count: 7 } }, { recent_failures: { count: 5 } }],
},
];
const result = utils.countRecentlyFailedTests(reports);
expect(result).toBe(8);
});
});
describe('statusIcon', () => {
describe('with failed status', () => {
it('returns ICON_WARNING', () => {
expect(utils.statusIcon(STATUS_FAILED)).toEqual(ICON_WARNING);
});
});
describe('with success status', () => {
it('returns ICON_SUCCESS', () => {
expect(utils.statusIcon(STATUS_SUCCESS)).toEqual(ICON_SUCCESS);
});
});
describe('without a status', () => {
it('returns ICON_NOTFOUND', () => {
expect(utils.statusIcon()).toEqual(ICON_NOTFOUND);
});
});
});
describe('formatFilePath', () => {
it.each`
file | expected
${'./test.js'} | ${'test.js'}
${'/test.js'} | ${'test.js'}
${'.//////////////test.js'} | ${'test.js'}
${'test.js'} | ${'test.js'}
${'mock/path./test.js'} | ${'mock/path./test.js'}
${'./mock/path./test.js'} | ${'mock/path./test.js'}
`('should format $file to be $expected', ({ file, expected }) => {
expect(utils.formatFilePath(file)).toBe(expected);
});
});
});

View file

@ -6,14 +6,14 @@ RSpec.describe Gitlab::GithubImport::Importer::ProtectedBranchImporter do
subject(:importer) { described_class.new(github_protected_branch, project, client) }
let(:branch_name) { 'protection' }
let(:allow_force_pushes_on_github) { true }
let(:allow_force_pushes_on_github) { false }
let(:require_code_owner_reviews_on_github) { false }
let(:required_conversation_resolution) { false }
let(:required_signatures) { false }
let(:required_pull_request_reviews) { false }
let(:expected_push_access_level) { Gitlab::Access::MAINTAINER }
let(:expected_merge_access_level) { Gitlab::Access::MAINTAINER }
let(:expected_allow_force_push) { true }
let(:expected_allow_force_push) { false }
let(:expected_code_owner_approval_required) { false }
let(:github_protected_branch) do
Gitlab::GithubImport::Representation::ProtectedBranch.new(
@ -102,6 +102,7 @@ RSpec.describe Gitlab::GithubImport::Importer::ProtectedBranchImporter do
end
context 'when branch is not protected on GitLab' do
let(:allow_force_pushes_on_github) { true }
let(:expected_allow_force_push) { true }
it_behaves_like 'create branch protection by the strictest ruleset'
@ -112,6 +113,30 @@ RSpec.describe Gitlab::GithubImport::Importer::ProtectedBranchImporter do
allow(project).to receive(:default_branch).and_return(branch_name)
end
context 'when "allow force pushes - everyone" rule is enabled' do
let(:allow_force_pushes_on_github) { true }
context 'when there is any default branch protection' do
before do
stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_FULL)
end
let(:expected_allow_force_push) { false }
it_behaves_like 'create branch protection by the strictest ruleset'
end
context 'when there is no default branch protection' do
before do
stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_NONE)
end
let(:expected_allow_force_push) { allow_force_pushes_on_github }
it_behaves_like 'create branch protection by the strictest ruleset'
end
end
context 'when required_conversation_resolution rule is enabled' do
let(:required_conversation_resolution) { true }

View file

@ -3,23 +3,34 @@
require 'spec_helper'
RSpec.describe Members::UpdateService do
let(:project) { create(:project, :public) }
let(:group) { create(:group, :public) }
let(:current_user) { create(:user) }
let(:member_user) { create(:user) }
let(:permission) { :update }
let(:member) { source.members_and_requesters.find_by!(user_id: member_user.id) }
let(:access_level) { Gitlab::Access::MAINTAINER }
let(:params) do
{ access_level: access_level }
let_it_be(:project) { create(:project, :public) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:current_user) { create(:user) }
let_it_be(:member_user1) { create(:user) }
let_it_be(:member_user2) { create(:user) }
let_it_be(:member_users) { [member_user1, member_user2] }
let_it_be(:permission) { :update }
let_it_be(:access_level) { Gitlab::Access::MAINTAINER }
let(:members) { source.members_and_requesters.where(user_id: member_users).to_a }
let(:update_service) { described_class.new(current_user, params) }
let(:params) { { access_level: access_level } }
let(:updated_members) do
result = subject
Array.wrap(result[:members] || result[:member])
end
before do
project.add_developer(member_user)
group.add_developer(member_user)
end
member_users.first.tap do |member_user|
expires_at = 10.days.from_now
project.add_member(member_user, Gitlab::Access::DEVELOPER, expires_at: expires_at)
group.add_member(member_user, Gitlab::Access::DEVELOPER, expires_at: expires_at)
end
subject { described_class.new(current_user, params).execute(member, permission: permission) }
member_users[1..].each do |member_user|
project.add_developer(member_user)
group.add_developer(member_user)
end
end
shared_examples 'a service raising Gitlab::Access::AccessDeniedError' do
it 'raises Gitlab::Access::AccessDeniedError' do
@ -28,209 +39,326 @@ RSpec.describe Members::UpdateService do
end
end
shared_examples 'a service updating a member' do
it 'updates the member' do
expect(TodosDestroyer::EntityLeaveWorker).not_to receive(:perform_in).with(Todo::WAIT_FOR_DELETE, member.user_id, member.source_id, source.class.name)
shared_examples 'current user cannot update the given members' do
it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
let_it_be(:source) { project }
end
updated_member = subject.fetch(:member)
it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
let_it_be(:source) { group }
end
end
expect(updated_member).to be_valid
expect(updated_member.access_level).to eq(access_level)
shared_examples 'returns error status when params are invalid' do
let_it_be(:params) { { expires_at: 2.days.ago } }
specify do
expect(subject[:status]).to eq(:error)
end
end
shared_examples 'a service updating members' do
it 'updates the members' do
new_access_levels = updated_members.map(&:access_level)
expect(updated_members).not_to be_empty
expect(updated_members).to all(be_valid)
expect(new_access_levels).to all(be access_level)
end
it 'returns success status' do
result = subject.fetch(:status)
expect(result).to eq(:success)
expect(subject.fetch(:status)).to eq(:success)
end
context 'when member is downgraded to guest' do
it 'invokes after_execute with correct args' do
members.each do |member|
expect(update_service).to receive(:after_execute).with(
action: permission,
old_access_level: member.human_access,
old_expiry: member.expires_at,
member: member
)
end
subject
end
it 'authorization update callback is triggered' do
expect(members).to all(receive(:refresh_member_authorized_projects).once)
subject
end
it 'does not enqueues todos for deletion' do
members.each do |member|
expect(TodosDestroyer::EntityLeaveWorker)
.not_to receive(:perform_in).with(Todo::WAIT_FOR_DELETE, member.user_id, member.source_id, source.class.name)
end
subject
end
context 'when members are downgraded to guest' do
shared_examples 'schedules to delete confidential todos' do
it do
expect(TodosDestroyer::EntityLeaveWorker).to receive(:perform_in).with(Todo::WAIT_FOR_DELETE, member.user_id, member.source_id, source.class.name).once
members.each do |member|
expect(TodosDestroyer::EntityLeaveWorker)
.to receive(:perform_in)
.with(Todo::WAIT_FOR_DELETE, member.user_id, member.source_id, source.class.name).once
end
updated_member = subject.fetch(:member)
expect(updated_member).to be_valid
expect(updated_member.access_level).to eq(Gitlab::Access::GUEST)
new_access_levels = updated_members.map(&:access_level)
expect(updated_members).to all(be_valid)
expect(new_access_levels).to all(be Gitlab::Access::GUEST)
end
end
context 'with Gitlab::Access::GUEST level as a string' do
let(:params) { { access_level: Gitlab::Access::GUEST.to_s } }
let_it_be(:params) { { access_level: Gitlab::Access::GUEST.to_s } }
it_behaves_like 'schedules to delete confidential todos'
end
context 'with Gitlab::Access::GUEST level as an integer' do
let(:params) { { access_level: Gitlab::Access::GUEST } }
let_it_be(:params) { { access_level: Gitlab::Access::GUEST } }
it_behaves_like 'schedules to delete confidential todos'
end
end
context 'when access_level is invalid' do
let(:params) { { access_level: 'invalid' } }
let_it_be(:params) { { access_level: 'invalid' } }
it 'raises an error' do
expect { described_class.new(current_user, params) }.to raise_error(ArgumentError, 'invalid value for Integer(): "invalid"')
expect { described_class.new(current_user, params) }
.to raise_error(ArgumentError, 'invalid value for Integer(): "invalid"')
end
end
context 'when member is not valid' do
let(:params) { { expires_at: 2.days.ago } }
context 'when members update results in no change' do
let(:params) { { access_level: members.first.access_level } }
it 'returns error status' do
result = subject
it 'does not invoke update! and post_update' do
expect(update_service).not_to receive(:save!)
expect(update_service).not_to receive(:post_update)
expect(result[:status]).to eq(:error)
expect(subject[:status]).to eq(:success)
end
it 'authorization update callback is not triggered' do
members.each { |member| expect(member).not_to receive(:refresh_member_authorized_projects) }
subject
end
end
end
context 'when current user cannot update the given member' do
it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
let(:source) { project }
end
it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
let(:source) { group }
end
end
context 'when current user can update the given member' do
before do
project.add_maintainer(current_user)
group.add_owner(current_user)
end
it_behaves_like 'a service updating a member' do
let(:source) { project }
end
it_behaves_like 'a service updating a member' do
let(:source) { group }
end
end
context 'in a project' do
shared_examples 'updating a project' do
let_it_be(:group_project) { create(:project, group: create(:group)) }
let_it_be(:source) { group_project }
let(:source) { group_project }
before do
member_users.each { |member_user| group_project.add_developer(member_user) }
end
context 'a project maintainer' do
context 'as a project maintainer' do
before do
group_project.add_maintainer(current_user)
end
context 'cannot update a member to OWNER' do
before do
group_project.add_developer(member_user)
end
it_behaves_like 'a service updating members'
context 'when member update results in an error' do
it_behaves_like 'a service returning an error'
end
context 'and updating members to OWNER' do
it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
let(:access_level) { Gitlab::Access::OWNER }
let_it_be(:access_level) { Gitlab::Access::OWNER }
end
end
context 'cannot update themselves to OWNER' do
let(:member) { source.members_and_requesters.find_by!(user_id: current_user.id) }
before do
group_project.add_developer(member_user)
end
context 'and updating themselves to OWNER' do
let(:members) { source.members_and_requesters.find_by!(user_id: current_user.id) }
it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
let(:access_level) { Gitlab::Access::OWNER }
let_it_be(:access_level) { Gitlab::Access::OWNER }
end
end
context 'cannot downgrade a member from OWNER' do
context 'and downgrading members from OWNER' do
before do
group_project.add_owner(member_user)
member_users.each { |member_user| group_project.add_owner(member_user) }
end
it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
let(:access_level) { Gitlab::Access::MAINTAINER }
let_it_be(:access_level) { Gitlab::Access::MAINTAINER }
end
end
end
context 'owners' do
context 'when current_user is considered an owner in the project via inheritance' do
before do
# so that `current_user` is considered an `OWNER` in the project via inheritance.
group_project.group.add_owner(current_user)
end
context 'can update a member to OWNER' do
context 'and can update members to OWNER' do
before do
group_project.add_developer(member_user)
member_users.each { |member_user| group_project.add_developer(member_user) }
end
it_behaves_like 'a service updating a member' do
let(:access_level) { Gitlab::Access::OWNER }
it_behaves_like 'a service updating members' do
let_it_be(:access_level) { Gitlab::Access::OWNER }
end
end
context 'can downgrade a member from OWNER' do
context 'and can downgrade members from OWNER' do
before do
group_project.add_owner(member_user)
member_users.each { |member_user| group_project.add_owner(member_user) }
end
it_behaves_like 'a service updating a member' do
let(:access_level) { Gitlab::Access::MAINTAINER }
it_behaves_like 'a service updating members' do
let_it_be(:access_level) { Gitlab::Access::MAINTAINER }
end
end
end
end
context 'authorization updates' do
let_it_be(:user) { create(:user) }
shared_examples 'updating a group' do
let_it_be(:source) { group }
shared_examples 'manages authorization updates' do
context 'access level changes' do
let(:params) do
{ access_level: Gitlab::Access::MAINTAINER }
end
before do
group.add_owner(current_user)
end
it 'authorization update callback is triggered' do
expect(member).to receive(:refresh_member_authorized_projects).once
it_behaves_like 'a service updating members'
described_class.new(current_user, params).execute(member, permission: permission)
end
context 'when member update results in an error' do
it_behaves_like 'a service returning an error'
end
context 'when group members expiration date is updated' do
let_it_be(:params) { { expires_at: 20.days.from_now } }
let(:notification_service) { instance_double(NotificationService) }
before do
allow(NotificationService).to receive(:new).and_return(notification_service)
end
context 'no attribute changes' do
let(:params) do
{ access_level: Gitlab::Access::DEVELOPER }
it 'emails the users that their group membership expiry has changed' do
members.each do |member|
expect(notification_service).to receive(:updated_group_member_expiration).with(member)
end
it 'authorization update callback is not triggered' do
expect(member).not_to receive(:refresh_member_authorized_projects)
subject
end
end
end
described_class.new(current_user, params).execute(member, permission: permission)
context 'when :bulk_update_membership_roles feature flag is disabled' do
let(:member) { source.members_and_requesters.find_by!(user_id: member_user1.id) }
let(:members) { [member] }
subject { update_service.execute(member, permission: permission) }
shared_examples 'a service returning an error' do
before do
allow(member).to receive(:save) do
member.errors.add(:user_id)
member.errors.add(:access_level)
end
.and_return(false)
end
it_behaves_like 'returns error status when params are invalid'
it 'returns the error' do
response = subject
expect(response[:status]).to eq(:error)
expect(response[:message]).to eq('User is invalid and Access level is invalid')
end
end
context 'group member' do
let(:source) { group }
before do
group.add_owner(current_user)
end
include_examples 'manages authorization updates'
before do
stub_feature_flags(bulk_update_membership_roles: false)
end
context 'project member' do
let(:source) { project }
it_behaves_like 'current user cannot update the given members'
it_behaves_like 'updating a project'
it_behaves_like 'updating a group'
end
subject { update_service.execute(members, permission: permission) }
shared_examples 'a service returning an error' do
it_behaves_like 'returns error status when params are invalid'
context 'when a member update results in invalid record' do
let(:member2) { members.second }
before do
project.add_maintainer(current_user)
allow(member2).to receive(:save!) do
member2.errors.add(:user_id)
member2.errors.add(:access_level)
end.and_raise(ActiveRecord::RecordInvalid)
end
include_examples 'manages authorization updates'
it 'returns the error' do
response = subject
expect(response[:status]).to eq(:error)
expect(response[:message]).to eq('User is invalid and Access level is invalid')
end
it 'rollbacks back the entire update' do
old_access_levels = members.pluck(:access_level)
subject
expect(members.each(&:reset).pluck(:access_level)).to eq(old_access_levels)
end
end
end
it_behaves_like 'current user cannot update the given members'
it_behaves_like 'updating a project'
it_behaves_like 'updating a group'
context 'with a single member' do
let(:member) { create(:group_member, group: group) }
let(:members) { member }
before do
group.add_owner(current_user)
end
it 'returns the correct response' do
expect(subject[:member]).to eq(member)
end
end
context 'when current user is an admin', :enable_admin_mode do
let_it_be(:current_user) { create(:admin) }
let_it_be(:source) { group }
context 'when all owners are being downgraded' do
before do
member_users.each { |member_user| group.add_owner(member_user) }
end
it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError'
end
context 'when all blocked owners are being downgraded' do
before do
member_users.each do |member_user|
group.add_owner(member_user)
member_user.block
end
end
it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError'
end
end
end

View file

@ -10079,7 +10079,6 @@
- './spec/services/members/request_access_service_spec.rb'
- './spec/services/members/standard_member_builder_spec.rb'
- './spec/services/members/unassign_issuables_service_spec.rb'
- './spec/services/members/update_service_spec.rb'
- './spec/services/merge_requests/add_context_service_spec.rb'
- './spec/services/merge_requests/add_spent_time_service_spec.rb'
- './spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb'