Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-06-02 15:09:42 +00:00
parent e9570ea27e
commit f5f72042cb
88 changed files with 2044 additions and 1120 deletions

View File

@ -1653,24 +1653,6 @@ Layout/ArgumentAlignment:
- 'qa/qa/specs/features/api/4_verify/api_variable_inheritance_with_forward_pipeline_variables_spec.rb'
- 'qa/qa/specs/features/browser_ui/1_manage/integrations/jenkins/jenkins_build_status_spec.rb'
- 'qa/qa/specs/features/browser_ui/1_manage/integrations/jira/jira_basic_integration_spec.rb'
- 'qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb'
- 'qa/qa/specs/features/browser_ui/3_create/pages/new_static_page_spec.rb'
- 'qa/qa/specs/features/browser_ui/3_create/project_wiki/project_based_content_creation_spec.rb'
- 'qa/qa/specs/features/browser_ui/3_create/project_wiki/project_based_content_manipulation_spec.rb'
- 'qa/qa/specs/features/browser_ui/3_create/project_wiki/project_based_directory_management_spec.rb'
- 'qa/qa/specs/features/browser_ui/3_create/project_wiki/project_based_list_spec.rb'
- 'qa/qa/specs/features/browser_ui/3_create/repository/push_http_private_token_spec.rb'
- 'qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_spec.rb'
- 'qa/qa/specs/features/browser_ui/3_create/repository/push_over_ssh_spec.rb'
- 'qa/qa/specs/features/browser_ui/3_create/source_editor/source_editor_toolbar_spec.rb'
- 'qa/qa/specs/features/browser_ui/3_create/web_ide/add_new_directory_in_web_ide_spec.rb'
- 'qa/qa/specs/features/browser_ui/3_create/web_ide_old/add_file_template_spec.rb'
- 'qa/qa/specs/features/browser_ui/4_verify/pipeline/pipeline_with_image_pull_policy_spec.rb'
- 'qa/qa/specs/features/browser_ui/5_package/package_registry/maven/maven_project_level_spec.rb'
- 'qa/qa/specs/features/browser_ui/5_package/package_registry/nuget/nuget_project_level_spec.rb'
- 'qa/qa/specs/features/browser_ui/5_package/package_registry/rubygems_registry_spec.rb'
- 'qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb'
- 'qa/qa/specs/features/ee/api/12_systems/geo/geo_nodes_spec.rb'
- 'qa/qa/specs/features/ee/api/1_manage/integrations/group_webhook_events_spec.rb'
- 'qa/qa/specs/features/ee/browser_ui/10_govern/change_vulnerability_status_spec.rb'
- 'qa/qa/specs/features/ee/browser_ui/10_govern/create_merge_request_with_secure_spec.rb'

View File

@ -3301,13 +3301,11 @@ RSpec/MissingFeatureCategory:
- 'spec/lib/gitlab/ci/status/pipeline/factory_spec.rb'
- 'spec/lib/gitlab/ci/status/preparing_spec.rb'
- 'spec/lib/gitlab/ci/status/running_spec.rb'
- 'spec/lib/gitlab/ci/status/scheduled_spec.rb'
- 'spec/lib/gitlab/ci/status/skipped_spec.rb'
- 'spec/lib/gitlab/ci/status/stage/common_spec.rb'
- 'spec/lib/gitlab/ci/status/stage/factory_spec.rb'
- 'spec/lib/gitlab/ci/status/stage/play_manual_spec.rb'
- 'spec/lib/gitlab/ci/status/success_spec.rb'
- 'spec/lib/gitlab/ci/status/success_warning_spec.rb'
- 'spec/lib/gitlab/ci/status/waiting_for_resource_spec.rb'
- 'spec/lib/gitlab/ci/tags/bulk_insert_spec.rb'
- 'spec/lib/gitlab/ci/templates/AWS/deploy_ecs_gitlab_ci_yaml_spec.rb'

View File

@ -1 +1 @@
42c93313900baabb0621ef2fb95feae8050ca418
81496efc0d26dba7799d1392c80b06bce943cc29

View File

@ -1,4 +1,4 @@
import { sortBy, cloneDeep, find } from 'lodash';
import { sortBy, cloneDeep, find, inRange } from 'lodash';
import {
TYPENAME_BOARD,
TYPENAME_ITERATION,
@ -12,7 +12,7 @@ import {
AssigneeFilterType,
MilestoneFilterType,
boardQuery,
} from './constants';
} from 'ee_else_ce/boards/constants';
export function getMilestone() {
return null;
@ -30,6 +30,17 @@ export function updateListPosition(listObj) {
return { ...listObj, position };
}
export function calculateNewPosition(listPosition, initialPosition, targetPosition) {
if (
listPosition === null ||
!(inRange(listPosition, initialPosition, targetPosition) || listPosition === targetPosition)
) {
return listPosition;
}
const offset = initialPosition < targetPosition ? -1 : 1;
return listPosition + offset;
}
export function formatBoardLists(lists) {
return lists.nodes.reduce((map, list) => {
return {

View File

@ -1,12 +1,19 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { sortBy } from 'lodash';
import produce from 'immer';
import Draggable from 'vuedraggable';
import { mapState, mapActions } from 'vuex';
import eventHub from '~/boards/eventhub';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
import { defaultSortableOptions } from '~/sortable/constants';
import { DraggableItemTypes, flashAnimationDuration } from 'ee_else_ce/boards/constants';
import {
DraggableItemTypes,
flashAnimationDuration,
listsQuery,
updateListQueries,
} from 'ee_else_ce/boards/constants';
import { calculateNewPosition } from 'ee_else_ce/boards/boards_util';
import BoardColumn from './board_column.vue';
export default {
@ -20,7 +27,15 @@ export default {
EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
GlAlert,
},
inject: ['canAdminList', 'isIssueBoard', 'isEpicBoard', 'disabled', 'isApolloBoard'],
inject: [
'boardType',
'canAdminList',
'isIssueBoard',
'isEpicBoard',
'disabled',
'issuableType',
'isApolloBoard',
],
props: {
boardId: {
type: String,
@ -117,6 +132,83 @@ export default {
this.highlightedLists = this.highlightedLists.filter((id) => id !== listId);
}, flashAnimationDuration);
},
updateListPosition({
item: {
dataset: { listId: movedListId, draggableItemType },
},
newIndex,
to: { children },
}) {
if (!this.isApolloBoard) {
this.moveList({
item: {
dataset: { listId: movedListId, draggableItemType },
},
newIndex,
to: { children },
});
return;
}
if (draggableItemType !== DraggableItemTypes.list) {
return;
}
const displacedListId = children[newIndex].dataset.listId;
if (movedListId === displacedListId) {
return;
}
const initialPosition = this.boardListsById[movedListId].position;
const targetPosition = this.boardListsById[displacedListId].position;
try {
this.$apollo.mutate({
mutation: updateListQueries[this.issuableType].mutation,
variables: {
listId: movedListId,
position: targetPosition,
},
update: (store) => {
const sourceData = store.readQuery({
query: listsQuery[this.issuableType].query,
variables: this.listQueryVariables,
});
const data = produce(sourceData, (draftData) => {
// for current list, new position is already set by Apollo via automatic update
const affectedNodes = draftData[this.boardType].board.lists.nodes.filter(
(node) => node.id !== movedListId,
);
affectedNodes.forEach((node) => {
// eslint-disable-next-line no-param-reassign
node.position = calculateNewPosition(
node.position,
initialPosition,
targetPosition,
);
});
});
store.writeQuery({
query: listsQuery[this.issuableType].query,
variables: this.listQueryVariables,
data,
});
},
optimisticResponse: {
updateBoardList: {
__typename: 'UpdateBoardListPayload',
errors: [],
list: {
...this.boardListsApollo[movedListId],
position: targetPosition,
},
},
},
});
} catch {
// handle error
}
},
},
};
</script>
@ -136,7 +228,7 @@ export default {
ref="list"
v-bind="draggableOptions"
class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap gl-overflow-x-auto"
@end="moveList"
@end="updateListPosition"
>
<board-column
v-for="(list, index) in boardListsToUse"
@ -173,6 +265,7 @@ export default {
:filters="filterParams"
:highlighted-lists="highlightedLists"
@setActiveList="$emit('setActiveList', $event)"
@move-list="updateListPosition"
>
<board-add-new-column
v-if="addColumnFormVisible"

View File

@ -74,66 +74,10 @@ export default {
<template>
<div>
<h2 class="gl-font-size-h2 gl-my-5">
{{ s__('Runners|Details') }}
{{ __('(optional)') }}
{{ s__('Runners|Tags') }}
</h2>
<gl-skeleton-loader v-if="loading" :lines="9" />
<gl-skeleton-loader v-if="loading" :lines="12" />
<template v-else-if="model">
<gl-form-group :label="s__('Runners|Runner description')" label-for="runner-description">
<gl-form-input id="runner-description" v-model="model.description" name="description" />
</gl-form-group>
<runner-maintenance-note-field v-model="model.maintenanceNote" class="gl-mt-5" />
</template>
<hr aria-hidden="true" />
<h2 class="gl-font-size-h2 gl-my-5">
{{ s__('Runners|Configuration') }}
{{ __('(optional)') }}
</h2>
<gl-skeleton-loader v-if="loading" :lines="27" />
<template v-else-if="model">
<div class="gl-mb-5">
<gl-form-checkbox v-model="model.paused" name="paused">
{{ __('Paused') }}
<template #help>
{{ s__('Runners|Stop the runner from accepting new jobs.') }}
</template>
</gl-form-checkbox>
<gl-form-checkbox
v-model="model.accessLevel"
name="protected"
:value="$options.ACCESS_LEVEL_REF_PROTECTED"
:unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED"
>
{{ __('Protected') }}
<template #help>
{{ s__('Runners|Use the runner on pipelines for protected branches only.') }}
</template>
</gl-form-checkbox>
<gl-form-checkbox v-model="model.runUntagged" name="run-untagged">
{{ __('Run untagged jobs') }}
<template #help>
{{ s__('Runners|Use the runner for jobs without tags in addition to tagged jobs.') }}
</template>
</gl-form-checkbox>
<gl-form-checkbox v-if="canBeLockedToProject" v-model="model.locked" name="locked">
{{ __('Lock to current projects') }} <gl-icon name="lock" />
<template #help>
{{
s__(
'Runners|Use the runner for the currently assigned projects only. Only administrators can change the assigned projects.',
)
}}
</template>
</gl-form-checkbox>
</div>
<gl-form-group :label="__('Tags')" label-for="runner-tags">
<template #description>
<gl-sprintf
@ -164,6 +108,69 @@ export default {
</template>
<gl-form-input id="runner-tags" v-model="model.tagList" name="tags" />
</gl-form-group>
<gl-form-checkbox v-model="model.runUntagged" name="run-untagged">
{{ __('Run untagged jobs') }}
<template #help>
{{ s__('Runners|Use the runner for jobs without tags in addition to tagged jobs.') }}
</template>
</gl-form-checkbox>
</template>
<hr aria-hidden="true" />
<h2 class="gl-font-size-h2 gl-my-5">
{{ s__('Runners|Details') }}
{{ __('(optional)') }}
</h2>
<gl-skeleton-loader v-if="loading" :lines="15" />
<template v-else-if="model">
<gl-form-group :label="s__('Runners|Runner description')" label-for="runner-description">
<gl-form-input id="runner-description" v-model="model.description" name="description" />
</gl-form-group>
<runner-maintenance-note-field v-model="model.maintenanceNote" class="gl-mt-5" />
</template>
<hr aria-hidden="true" />
<h2 class="gl-font-size-h2 gl-my-5">
{{ s__('Runners|Configuration') }}
{{ __('(optional)') }}
</h2>
<gl-skeleton-loader v-if="loading" :lines="15" />
<template v-else-if="model">
<div class="gl-mb-5">
<gl-form-checkbox v-model="model.paused" name="paused">
{{ __('Paused') }}
<template #help>
{{ s__('Runners|Stop the runner from accepting new jobs.') }}
</template>
</gl-form-checkbox>
<gl-form-checkbox
v-model="model.accessLevel"
name="protected"
:value="$options.ACCESS_LEVEL_REF_PROTECTED"
:unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED"
>
{{ __('Protected') }}
<template #help>
{{ s__('Runners|Use the runner on pipelines for protected branches only.') }}
</template>
</gl-form-checkbox>
<gl-form-checkbox v-if="canBeLockedToProject" v-model="model.locked" name="locked">
{{ __('Lock to current projects') }} <gl-icon name="lock" />
<template #help>
{{
s__(
'Runners|Use the runner for the currently assigned projects only. Only administrators can change the assigned projects.',
)
}}
</template>
</gl-form-checkbox>
</div>
<gl-form-group
:label="__('Maximum job timeout')"

View File

@ -5,6 +5,16 @@ import FILTERED_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-
import { GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
import {
I18N_GET_STARTED,
I18N_RUNNERS_ARE_AGENTS,
I18N_CREATE_RUNNER_LINK,
I18N_STILL_USING_REGISTRATION_TOKENS,
I18N_CONTACT_ADMIN_TO_REGISTER,
I18N_FOLLOW_REGISTRATION_INSTRUCTIONS,
I18N_NO_RESULTS,
I18N_EDIT_YOUR_SEARCH,
} from '~/ci/runner/constants';
export default {
components: {
@ -38,9 +48,8 @@ export default {
shouldShowCreateRunnerWorkflow() {
// create_runner_workflow_for_admin or create_runner_workflow_for_namespace
return (
this.newRunnerPath &&
(this.glFeatures?.createRunnerWorkflowForAdmin ||
this.glFeatures?.createRunnerWorkflowForNamespace)
this.glFeatures?.createRunnerWorkflowForAdmin ||
this.glFeatures?.createRunnerWorkflowForNamespace
);
},
},
@ -48,35 +57,59 @@ export default {
svgHeight: 145,
EMPTY_STATE_SVG_URL,
FILTERED_SVG_URL,
I18N_GET_STARTED,
I18N_RUNNERS_ARE_AGENTS,
I18N_CREATE_RUNNER_LINK,
I18N_STILL_USING_REGISTRATION_TOKENS,
I18N_CONTACT_ADMIN_TO_REGISTER,
I18N_FOLLOW_REGISTRATION_INSTRUCTIONS,
I18N_NO_RESULTS,
I18N_EDIT_YOUR_SEARCH,
};
</script>
<template>
<gl-empty-state
v-if="isSearchFiltered"
:title="s__('Runners|No results found')"
:title="$options.I18N_NO_RESULTS"
:svg-path="$options.FILTERED_SVG_URL"
:svg-height="$options.svgHeight"
:description="s__('Runners|Edit your search and try again')"
:description="$options.I18N_EDIT_YOUR_SEARCH"
/>
<gl-empty-state
v-else
:title="s__('Runners|Get started with runners')"
:title="$options.I18N_GET_STARTED"
:svg-path="$options.EMPTY_STATE_SVG_URL"
:svg-height="$options.svgHeight"
>
<template v-if="registrationToken" #description>
<gl-sprintf
:message="
s__(
'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.',
)
"
>
<template v-if="shouldShowCreateRunnerWorkflow" #link="{ content }">
<gl-link :href="newRunnerPath">{{ content }}</gl-link>
<template #description>
{{ $options.I18N_RUNNERS_ARE_AGENTS }}
<template v-if="shouldShowCreateRunnerWorkflow">
<gl-sprintf v-if="newRunnerPath" :message="$options.I18N_CREATE_RUNNER_LINK">
<template #link="{ content }">
<gl-link :href="newRunnerPath">{{ content }}</gl-link>
</template>
</gl-sprintf>
<template v-if="registrationToken">
<br />
<gl-link v-gl-modal="$options.modalId">{{
$options.I18N_STILL_USING_REGISTRATION_TOKENS
}}</gl-link>
<runner-instructions-modal
:modal-id="$options.modalId"
:registration-token="registrationToken"
/>
</template>
<template v-else #link="{ content }">
<template v-if="!newRunnerPath && !registrationToken">
{{ $options.I18N_CONTACT_ADMIN_TO_REGISTER }}
</template>
</template>
<gl-sprintf
v-else-if="registrationToken"
:message="$options.I18N_FOLLOW_REGISTRATION_INSTRUCTIONS"
>
<template #link="{ content }">
<gl-link v-gl-modal="$options.modalId">{{ content }}</gl-link>
<runner-instructions-modal
:modal-id="$options.modalId"
@ -84,13 +117,9 @@ export default {
/>
</template>
</gl-sprintf>
</template>
<template v-else #description>
{{
s__(
'Runners|Runners are the agents that run your CI/CD jobs. To register new runners, please contact your administrator.',
)
}}
<template v-else>
{{ $options.I18N_CONTACT_ADMIN_TO_REGISTER }}
</template>
</template>
</gl-empty-state>
</template>

View File

@ -102,6 +102,26 @@ export const I18N_CREATED_AT_BY_LABEL = s__('Runners|Created %{timeAgo} by %{ava
export const I18N_SHOW_ONLY_INHERITED = s__('Runners|Show only inherited');
export const I18N_ADMIN = s__('Runners|Administrator');
// No runners registered
export const I18N_GET_STARTED = s__('Runners|Get started with runners');
export const I18N_RUNNERS_ARE_AGENTS = s__(
'Runners|Runners are the agents that run your CI/CD jobs.',
);
export const I18N_CREATE_RUNNER_LINK = s__(
'Runners|%{linkStart}Create a new runner%{linkEnd} to get started.',
);
export const I18N_STILL_USING_REGISTRATION_TOKENS = s__('Runners|Still using registration tokens?');
export const I18N_CONTACT_ADMIN_TO_REGISTER = s__(
'Runners|To register new runners, contact your administrator.',
);
export const I18N_FOLLOW_REGISTRATION_INSTRUCTIONS = s__(
'Runners|Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.',
);
// No runners found
export const I18N_NO_RESULTS = s__('Runners|No results found');
export const I18N_EDIT_YOUR_SEARCH = s__('Runners|Edit your search and try again');
// Runner details
export const JOBS_ROUTE_PATH = '/jobs'; // vue-router route path

View File

@ -41,7 +41,7 @@ export default {
viewType: {
type: String,
required: false,
default: 'child',
default: 'root',
},
canCreatePipelineInTargetProject: {
type: Boolean,

View File

@ -8,8 +8,10 @@ import {
GlButton,
GlFormCheckbox,
GlLoadingIcon,
GlModal,
GlSprintf,
} from '@gitlab/ui';
import { last } from 'lodash';
import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __, s__ } from '~/locale';
import FileSha from '~/packages_and_registries/package_registry/components/details/file_sha.vue';
@ -22,10 +24,22 @@ import {
GRAPHQL_PACKAGE_FILES_PAGE_SIZE,
REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION,
SELECT_PACKAGE_FILE_TRACKING_ACTION,
DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
DELETE_PACKAGE_FILE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
TRACKING_LABEL_PACKAGE_ASSET,
TRACKING_ACTION_EXPAND_PACKAGE_ASSET,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
DELETE_PACKAGE_FILES_ERROR_MESSAGE,
DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
DELETE_PACKAGE_FILES_TRACKING_ACTION,
DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT,
DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT,
} from '~/packages_and_registries/package_registry/constants';
import getPackageFilesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql';
import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql';
export default {
name: 'PackageFiles',
@ -38,22 +52,25 @@ export default {
GlFormCheckbox,
GlButton,
GlLoadingIcon,
GlModal,
GlSprintf,
FileIcon,
TimeAgoTooltip,
FileSha,
},
mixins: [Tracking.mixin()],
trackingActions: {
DELETE_PACKAGE_FILE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
},
props: {
canDelete: {
type: Boolean,
required: false,
default: false,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
packageId: {
type: String,
required: true,
@ -62,6 +79,10 @@ export default {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
},
apollo: {
packageFiles: {
@ -73,7 +94,7 @@ export default {
return this.queryVariables;
},
update(data) {
return data.package?.packageFiles?.nodes || [];
return data.package?.packageFiles ?? {};
},
error() {
this.fetchPackageFilesError = true;
@ -83,29 +104,33 @@ export default {
data() {
return {
fetchPackageFilesError: false,
packageFiles: [],
filesToDelete: [],
packageFiles: {},
mutationLoading: false,
selectedReferences: [],
};
},
computed: {
files() {
return this.packageFiles?.nodes ?? [];
},
areFilesSelected() {
return this.selectedReferences.length > 0;
},
areAllFilesSelected() {
return this.packageFiles.length > 0 && this.packageFiles.every(this.isSelected);
return this.files.length > 0 && this.files.every(this.isSelected);
},
filesTableRows() {
return this.packageFiles.map((pf) => ({
return this.files.map((pf) => ({
...pf,
size: this.formatSize(pf.size),
pipeline: last(pf.pipelines),
}));
},
hasSelectedSomeFiles() {
return this.areFilesSelected && !this.areAllFilesSelected;
},
loading() {
return this.$apollo.queries.packageFiles.loading || this.isLoading;
isLoading() {
return this.$apollo.queries.packageFiles.loading || this.mutationLoading;
},
filesTableHeaderFields() {
return [
@ -148,6 +173,29 @@ export default {
category: packageTypeToTrackCategory(this.packageType),
};
},
refetchQueriesData() {
return [
{
query: getPackageFilesQuery,
variables: this.queryVariables,
},
];
},
modalAction() {
return this.hasOneItem(this.filesToDelete)
? this.$options.modal.fileDeletePrimaryAction
: this.$options.modal.filesDeletePrimaryAction;
},
modalTitle() {
return this.hasOneItem(this.filesToDelete)
? this.$options.i18n.deleteFileModalTitle
: this.$options.i18n.deleteFilesModalTitle;
},
modalDescription() {
return this.hasOneItem(this.filesToDelete)
? this.$options.i18n.deleteFileModalContent
: this.$options.i18n.deleteFilesModalContent;
},
},
methods: {
formatSize(size) {
@ -170,15 +218,97 @@ export default {
},
handleFileDeleteSelected() {
this.track(REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION);
this.$emit('delete-files', this.selectedReferences);
this.handleFileDelete(this.selectedReferences);
},
async deletePackageFiles(ids) {
this.mutationLoading = true;
try {
const { data } = await this.$apollo.mutate({
mutation: destroyPackageFilesMutation,
variables: {
projectPath: this.projectPath,
ids,
},
awaitRefetchQueries: true,
refetchQueries: this.refetchQueriesData,
});
if (data?.destroyPackageFiles?.errors[0]) {
throw data.destroyPackageFiles.errors[0];
}
createAlert({
message: this.hasOneItem(ids)
? DELETE_PACKAGE_FILE_SUCCESS_MESSAGE
: DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
variant: VARIANT_SUCCESS,
});
} catch (error) {
createAlert({
message: this.hasOneItem(ids)
? DELETE_PACKAGE_FILE_ERROR_MESSAGE
: DELETE_PACKAGE_FILES_ERROR_MESSAGE,
variant: VARIANT_WARNING,
captureError: true,
error,
});
} finally {
this.mutationLoading = false;
this.filesToDelete = [];
this.selectedReferences = [];
}
},
handleFileDelete(files) {
this.track(REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION);
if (files.length === this.files.length && !this.packageFiles?.pageInfo?.hasNextPage) {
this.$emit(
'delete-all-files',
this.hasOneItem(files)
? DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT
: DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT,
);
} else {
this.filesToDelete = files;
this.$refs.deleteFilesModal.show();
}
},
hasOneItem(items) {
return items.length === 1;
},
confirmFilesDelete() {
if (this.hasOneItem(this.filesToDelete)) {
this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION);
} else {
this.track(DELETE_PACKAGE_FILES_TRACKING_ACTION);
}
this.deletePackageFiles(this.filesToDelete.map((file) => file.id));
},
},
i18n: {
deleteFile: __('Delete asset'),
deleteFile: s__('PackageRegistry|Delete asset'),
deleteFileModalTitle: s__('PackageRegistry|Delete package asset'),
deleteFileModalContent: s__(
'PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?',
),
deleteFilesModalTitle: s__('PackageRegistry|Delete %{count} assets'),
deleteFilesModalContent: s__(
'PackageRegistry|You are about to delete %{count} assets. This operation is irreversible.',
),
deleteSelected: s__('PackageRegistry|Delete selected'),
moreActionsText: __('More actions'),
fetchPackageFilesErrorMessage: FETCH_PACKAGE_FILES_ERROR_MESSAGE,
},
modal: {
fileDeletePrimaryAction: {
text: __('Delete'),
attributes: { variant: 'danger', category: 'primary' },
},
filesDeletePrimaryAction: {
text: s__('PackageRegistry|Permanently delete assets'),
attributes: { variant: 'danger', category: 'primary' },
},
cancelAction: {
text: __('Cancel'),
},
},
};
</script>
@ -188,7 +318,7 @@ export default {
<h3 class="gl-font-lg gl-mt-5">{{ __('Assets') }}</h3>
<gl-button
v-if="!fetchPackageFilesError && canDelete"
:disabled="loading || !areFilesSelected"
:disabled="isLoading || !areFilesSelected"
category="secondary"
variant="danger"
data-testid="delete-selected"
@ -206,7 +336,7 @@ export default {
</gl-alert>
<gl-table
v-else
:busy="loading"
:busy="isLoading"
:fields="filesTableHeaderFields"
:items="filesTableRows"
show-empty
@ -255,7 +385,7 @@ export default {
:href="item.downloadPath"
class="gl-text-gray-500"
data-testid="download-link"
@click="$emit('download-file')"
@click="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)"
>
<file-icon
:file-name="item.fileName"
@ -279,7 +409,7 @@ export default {
no-caret
right
>
<gl-dropdown-item data-testid="delete-file" @click="$emit('delete-files', [item])">
<gl-dropdown-item data-testid="delete-file" @click="handleFileDelete([item])">
{{ $options.i18n.deleteFile }}
</gl-dropdown-item>
</gl-dropdown>
@ -300,5 +430,34 @@ export default {
</div>
</template>
</gl-table>
<gl-modal
ref="deleteFilesModal"
size="sm"
modal-id="delete-files-modal"
:action-primary="modalAction"
:action-cancel="$options.modal.cancelAction"
data-testid="delete-files-modal"
@primary="confirmFilesDelete"
@canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)"
>
<template #modal-title>
<gl-sprintf :message="modalTitle">
<template #count>
{{ filesToDelete.length }}
</template>
</gl-sprintf>
</template>
<gl-sprintf :message="modalDescription">
<template #filename>
<strong>{{ filesToDelete[0].fileName }}</strong>
</template>
<template #count>
{{ filesToDelete.length }}
</template>
</gl-sprintf>
</gl-modal>
</div>
</template>

View File

@ -47,9 +47,6 @@ query getPackageDetails($id: PackagesPackageID!) {
}
}
packageFiles(first: 100) {
pageInfo {
hasNextPage
}
nodes {
id
size

View File

@ -2,6 +2,9 @@ query getPackageFiles($id: PackagesPackageID!, $first: Int) {
package(id: $id) {
id
packageFiles(first: $first) {
pageInfo {
hasNextPage
}
nodes {
id
fileMd5

View File

@ -11,7 +11,7 @@ import {
GlTabs,
GlSprintf,
} from '@gitlab/ui';
import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
import { createAlert } from '~/alert';
import { TYPENAME_PACKAGES_PACKAGE } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
@ -33,27 +33,15 @@ import {
DELETE_PACKAGE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
DELETE_PACKAGE_FILE_TRACKING_ACTION,
DELETE_PACKAGE_FILES_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
REQUEST_FORWARDING_HELP_PAGE_PATH,
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
SHOW_DELETE_SUCCESS_ALERT,
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
DELETE_PACKAGE_FILES_ERROR_MESSAGE,
DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT,
DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
DELETE_MODAL_TITLE,
DELETE_MODAL_CONTENT,
DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT,
DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT,
GRAPHQL_PAGE_SIZE,
} from '~/packages_and_registries/package_registry/constants';
import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql';
import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql';
import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql';
import Tracking from '~/tracking';
@ -92,10 +80,6 @@ export default {
DELETE_PACKAGE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
DELETE_PACKAGE_FILE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
},
data() {
return {
@ -158,9 +142,6 @@ export default {
isLoading() {
return this.$apollo.queries.packageEntity.loading;
},
packageFilesMutationLoading() {
return this.mutationLoading;
},
isValidPackage() {
return this.isLoading || Boolean(this.packageEntity.name);
},
@ -196,14 +177,6 @@ export default {
PACKAGE_TYPE_PYPI,
].includes(this.packageType);
},
refetchQueriesData() {
return [
{
query: getPackageDetails,
variables: this.queryVariables,
},
];
},
refetchVersionsQueryData() {
return [
{
@ -230,71 +203,9 @@ export default {
window.location.replace(`${returnTo}?${modalQuery}`);
},
async deletePackageFiles(ids) {
this.mutationLoading = true;
try {
const { data } = await this.$apollo.mutate({
mutation: destroyPackageFilesMutation,
variables: {
projectPath: this.projectPath,
ids,
},
awaitRefetchQueries: true,
refetchQueries: this.refetchQueriesData,
});
if (data?.destroyPackageFiles?.errors[0]) {
throw data.destroyPackageFiles.errors[0];
}
createAlert({
message: this.isLastItem(ids)
? DELETE_PACKAGE_FILE_SUCCESS_MESSAGE
: DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
variant: VARIANT_SUCCESS,
});
} catch (error) {
createAlert({
message: this.isLastItem(ids)
? DELETE_PACKAGE_FILE_ERROR_MESSAGE
: DELETE_PACKAGE_FILES_ERROR_MESSAGE,
variant: VARIANT_WARNING,
captureError: true,
error,
});
}
this.mutationLoading = false;
},
handleFileDelete(files) {
this.track(REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION);
if (
files.length === this.packageFiles.length &&
!this.packageEntity.packageFiles?.pageInfo?.hasNextPage
) {
if (this.isLastItem(files)) {
this.deletePackageModalContent = DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT;
} else {
this.deletePackageModalContent = DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT;
}
this.$refs.deleteModal.show();
} else {
this.filesToDelete = files;
if (this.isLastItem(files)) {
this.$refs.deleteFileModal.show();
} else if (files.length > 1) {
this.$refs.deleteFilesModal.show();
}
}
},
isLastItem(items) {
return items.length === 1;
},
confirmFilesDelete() {
if (this.isLastItem(this.filesToDelete)) {
this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION);
} else {
this.track(DELETE_PACKAGE_FILES_TRACKING_ACTION);
}
this.deletePackageFiles(this.filesToDelete.map((file) => file.id));
this.filesToDelete = [];
handleAllFilesDelete(content) {
this.deletePackageModalContent = content;
this.$refs.deleteModal.show();
},
resetDeleteModalContent() {
this.deletePackageModalContent = DELETE_MODAL_CONTENT;
@ -302,10 +213,6 @@ export default {
},
i18n: {
DELETE_MODAL_TITLE,
deleteFileModalTitle: s__(`PackageRegistry|Delete package asset`),
deleteFileModalContent: s__(
`PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`,
),
otherVersionsTabTitle: s__('PackageRegistry|Other versions'),
},
links: {
@ -374,11 +281,10 @@ export default {
<package-files
v-if="showFiles"
:can-delete="packageEntity.canDestroy"
:is-loading="packageFilesMutationLoading"
:package-id="packageEntity.id"
:package-type="packageType"
@download-file="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)"
@delete-files="handleFileDelete"
:project-path="projectPath"
@delete-all-files="handleAllFilesDelete"
/>
</div>
</gl-tab>
@ -471,51 +377,5 @@ export default {
</gl-modal>
</template>
</delete-packages>
<gl-modal
ref="deleteFileModal"
size="sm"
modal-id="delete-file-modal"
:action-primary="$options.modal.fileDeletePrimaryAction"
:action-cancel="$options.modal.cancelAction"
data-testid="delete-file-modal"
@primary="confirmFilesDelete"
@canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)"
>
<template #modal-title>{{ $options.i18n.deleteFileModalTitle }}</template>
<gl-sprintf v-if="isLastItem(filesToDelete)" :message="$options.i18n.deleteFileModalContent">
<template #filename>
<strong>{{ filesToDelete[0].fileName }}</strong>
</template>
</gl-sprintf>
</gl-modal>
<gl-modal
ref="deleteFilesModal"
size="sm"
modal-id="delete-files-modal"
:action-primary="$options.modal.filesDeletePrimaryAction"
:action-cancel="$options.modal.cancelAction"
data-testid="delete-files-modal"
@primary="confirmFilesDelete"
@canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)"
>
<template #modal-title>{{
n__(
`PackageRegistry|Delete 1 asset`,
`PackageRegistry|Delete %d assets`,
filesToDelete.length,
)
}}</template>
<span v-if="filesToDelete.length > 0">
{{
n__(
`PackageRegistry|You are about to delete 1 asset. This operation is irreversible.`,
`PackageRegistry|You are about to delete %d assets. This operation is irreversible.`,
filesToDelete.length,
)
}}
</span>
</gl-modal>
</div>
</template>

View File

@ -1,30 +1,61 @@
<script>
import { GlBadge, GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import {
GlAlert,
GlBadge,
GlButton,
GlIcon,
GlLink,
GlLoadingIcon,
GlModal,
GlModalDirective,
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { __, s__, sprintf } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants';
import {
LOAD_FAILURE,
POST_FAILURE,
DELETE_FAILURE,
DEFAULT,
BUTTON_TOOLTIP_RETRY,
BUTTON_TOOLTIP_CANCEL,
} from '../constants';
import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql';
import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql';
import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql';
import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql';
import TimeAgo from './pipelines_list/time_ago.vue';
import { getQueryHeaders } from './graph/utils';
const DELETE_MODAL_ID = 'pipeline-delete-modal';
const POLL_INTERVAL = 10000;
export default {
name: 'PipelineDetailsHeader',
BUTTON_TOOLTIP_RETRY,
BUTTON_TOOLTIP_CANCEL,
pipelineCancel: 'pipelineCancel',
pipelineRetry: 'pipelineRetry',
finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'],
components: {
CiBadgeLink,
ClipboardButton,
GlAlert,
GlBadge,
GlButton,
GlIcon,
GlLink,
GlLoadingIcon,
GlModal,
GlSprintf,
TimeAgo,
},
directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
SafeHtml,
},
@ -51,6 +82,12 @@ export default {
),
stuckBadgeText: s__('Pipelines|stuck'),
stuckBadgeTooltip: s__('Pipelines|This pipeline is stuck'),
computeCreditsTooltip: s__('Pipelines|Total amount of compute credits used for the pipeline'),
totalJobsTooltip: s__('Pipelines|Total number of jobs for the pipeline'),
retryPipelineText: __('Retry'),
cancelPipelineText: __('Cancel pipeline'),
deletePipelineText: __('Delete'),
clipboardTooltip: __('Copy commit SHA'),
},
errorTexts: {
[LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'),
@ -58,6 +95,22 @@ export default {
[DELETE_FAILURE]: __('An error occurred while deleting the pipeline.'),
[DEFAULT]: __('An unknown error occurred.'),
},
modal: {
id: DELETE_MODAL_ID,
title: __('Delete pipeline'),
deleteConfirmationText: __(
'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.',
),
actionPrimary: {
text: __('Delete pipeline'),
attributes: {
variant: 'danger',
},
},
actionCancel: {
text: __('Cancel'),
},
},
inject: {
graphqlResourceEtag: {
default: '',
@ -224,141 +277,306 @@ export default {
queuedDuration: this.pipeline?.queuedDuration || 0,
});
},
canRetryPipeline() {
const { retryable, userPermissions } = this.pipeline;
return retryable && userPermissions.updatePipeline;
},
canCancelPipeline() {
const { cancelable, userPermissions } = this.pipeline;
return cancelable && userPermissions.updatePipeline;
},
},
methods: {
reportFailure(errorType, errorMessages = []) {
this.failureType = errorType;
this.failureMessages = errorMessages;
},
async postPipelineAction(name, mutation) {
try {
const {
data: {
[name]: { errors },
},
} = await this.$apollo.mutate({
mutation,
variables: { id: this.pipeline.id },
});
if (errors.length > 0) {
this.isRetrying = false;
this.reportFailure(POST_FAILURE, errors);
} else {
await this.$apollo.queries.pipeline.refetch();
if (!this.isFinished) {
this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL);
}
}
} catch {
this.isRetrying = false;
this.reportFailure(POST_FAILURE);
}
},
cancelPipeline() {
this.isCanceling = true;
this.postPipelineAction(this.$options.pipelineCancel, cancelPipelineMutation);
},
retryPipeline() {
this.isRetrying = true;
this.postPipelineAction(this.$options.pipelineRetry, retryPipelineMutation);
},
async deletePipeline() {
this.isDeleting = true;
this.$apollo.queries.pipeline.stopPolling();
try {
const {
data: {
pipelineDestroy: { errors },
},
} = await this.$apollo.mutate({
mutation: deletePipelineMutation,
variables: {
id: this.pipeline.id,
},
});
if (errors.length > 0) {
this.reportFailure(DELETE_FAILURE, errors);
this.isDeleting = false;
} else {
redirectTo(setUrlFragment(this.paths.pipelinesPath, 'delete_success')); // eslint-disable-line import/no-deprecated
}
} catch {
this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL);
this.reportFailure(DELETE_FAILURE);
this.isDeleting = false;
}
},
},
};
</script>
<template>
<div class="gl-mt-3">
<div class="gl-my-4">
<gl-alert v-if="hasError" :title="failure.text" :variant="failure.variant" :dismissible="false">
<div v-for="(failureMessage, index) in failureMessages" :key="`failure-message-${index}`">
{{ failureMessage }}
</div>
</gl-alert>
<gl-loading-icon v-if="loading" class="gl-text-left" size="lg" />
<template v-else>
<h3 v-if="name" class="gl-mt-0 gl-mb-2" data-testid="pipeline-name">{{ name }}</h3>
<div v-else class="gl-display-flex gl-justify-content-space-between">
<div>
<ci-badge-link :status="detailedStatus" />
<div class="gl-ml-2 gl-mb-2 gl-display-inline-block gl-h-6">
<gl-sprintf :message="triggeredText">
<template #link="{ content }">
<gl-link
:href="userPath"
class="gl-text-gray-900 gl-font-weight-bold"
target="_blank"
>
{{ content }}
</gl-link>
</template>
</gl-sprintf>
<gl-link
:href="commitPath"
class="gl-bg-blue-50 gl-rounded-base gl-px-2 gl-mx-2"
data-testid="commit-link"
<h3 v-if="name" class="gl-mt-0 gl-mb-2" data-testid="pipeline-name">{{ name }}</h3>
<div>
<ci-badge-link :status="detailedStatus" />
<div class="gl-ml-2 gl-mb-2 gl-display-inline-block gl-h-6">
<gl-sprintf :message="triggeredText">
<template #link="{ content }">
<gl-link
:href="userPath"
class="gl-text-gray-900 gl-font-weight-bold"
target="_blank"
>
{{ content }}
</gl-link>
</template>
</gl-sprintf>
<gl-link
:href="commitPath"
class="gl-bg-blue-50 gl-rounded-base gl-px-2 gl-mx-2"
data-testid="commit-link"
>
{{ shortId }}
</gl-link>
<clipboard-button
:text="shortId"
category="tertiary"
:title="$options.i18n.clipboardTooltip"
size="small"
/>
<time-ago
v-if="isFinished"
:pipeline="pipeline"
class="gl-display-inline gl-mb-0"
:display-calendar-icon="false"
font-size="gl-font-md"
/>
</div>
</div>
<div v-safe-html="refText" class="gl-mb-2" data-testid="pipeline-ref-text"></div>
<div>
<gl-badge
v-if="badges.schedule"
v-gl-tooltip
:title="$options.i18n.scheduleBadgeTooltip"
variant="info"
size="sm"
>
{{ shortId }}
</gl-link>
<clipboard-button
:text="shortId"
category="tertiary"
:title="__('Copy commit SHA')"
size="small"
/>
<time-ago
{{ $options.i18n.scheduleBadgeText }}
</gl-badge>
<gl-badge
v-if="badges.child"
v-gl-tooltip
:title="$options.i18n.childBadgeTooltip"
variant="info"
size="sm"
>
<gl-sprintf :message="$options.i18n.childBadgeText">
<template #link="{ content }">
<gl-link :href="paths.triggeredByPath" target="_blank">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</gl-badge>
<gl-badge
v-if="badges.latest"
v-gl-tooltip
:title="$options.i18n.latestBadgeTooltip"
variant="success"
size="sm"
>
{{ $options.i18n.latestBadgeText }}
</gl-badge>
<gl-badge
v-if="badges.mergeTrainPipeline"
v-gl-tooltip
:title="$options.i18n.mergeTrainBadgeTooltip"
variant="info"
size="sm"
>
{{ $options.i18n.mergeTrainBadgeText }}
</gl-badge>
<gl-badge
v-if="badges.invalid"
v-gl-tooltip
:title="yamlErrors"
variant="danger"
size="sm"
>
{{ $options.i18n.invalidBadgeText }}
</gl-badge>
<gl-badge
v-if="badges.failed"
v-gl-tooltip
:title="failureReason"
variant="danger"
size="sm"
>
{{ $options.i18n.failedBadgeText }}
</gl-badge>
<gl-badge
v-if="badges.autoDevops"
v-gl-tooltip
:title="$options.i18n.autoDevopsBadgeTooltip"
variant="info"
size="sm"
>
{{ $options.i18n.autoDevopsBadgeText }}
</gl-badge>
<gl-badge
v-if="badges.detached"
v-gl-tooltip
:title="$options.i18n.detachedBadgeTooltip"
variant="info"
size="sm"
data-qa-selector="merge_request_badge_tag"
>
{{ $options.i18n.detachedBadgeText }}
</gl-badge>
<gl-badge
v-if="badges.stuck"
v-gl-tooltip
:title="$options.i18n.stuckBadgeTooltip"
variant="warning"
size="sm"
>
{{ $options.i18n.stuckBadgeText }}
</gl-badge>
<span
v-gl-tooltip
:title="$options.i18n.totalJobsTooltip"
class="gl-ml-2"
data-testid="total-jobs"
>
<gl-icon name="pipeline" />
{{ totalJobsText }}
</span>
<span
v-if="isFinished"
:pipeline="pipeline"
class="gl-display-inline gl-mb-0"
:display-calendar-icon="false"
font-size="gl-font-md"
/>
v-gl-tooltip
:title="$options.i18n.computeCreditsTooltip"
class="gl-ml-2"
data-testid="compute-credits"
>
<gl-icon name="quota" />
{{ computeCredits }}
</span>
<span v-if="inProgress" class="gl-ml-2" data-testid="pipeline-running-text">
<gl-icon name="timer" />
{{ inProgressText }}
</span>
</div>
</div>
<div v-safe-html="refText" class="gl-mb-2" data-testid="pipeline-ref-text"></div>
<div>
<gl-badge
v-if="badges.schedule"
<gl-button
v-if="canRetryPipeline"
v-gl-tooltip
:title="$options.i18n.scheduleBadgeTooltip"
variant="info"
:aria-label="$options.BUTTON_TOOLTIP_RETRY"
:title="$options.BUTTON_TOOLTIP_RETRY"
:loading="isRetrying"
:disabled="isRetrying"
variant="confirm"
data-testid="retry-pipeline"
class="js-retry-button"
@click="retryPipeline()"
>
{{ $options.i18n.scheduleBadgeText }}
</gl-badge>
<gl-badge
v-if="badges.child"
{{ $options.i18n.retryPipelineText }}
</gl-button>
<gl-button
v-if="canCancelPipeline"
v-gl-tooltip
:title="$options.i18n.childBadgeTooltip"
variant="info"
:aria-label="$options.BUTTON_TOOLTIP_CANCEL"
:title="$options.BUTTON_TOOLTIP_CANCEL"
:loading="isCanceling"
:disabled="isCanceling"
class="gl-ml-3"
variant="danger"
data-testid="cancel-pipeline"
@click="cancelPipeline()"
>
<gl-sprintf :message="$options.i18n.childBadgeText">
<template #link="{ content }">
<gl-link :href="paths.triggeredByPath" target="_blank">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</gl-badge>
<gl-badge
v-if="badges.latest"
v-gl-tooltip
:title="$options.i18n.latestBadgeTooltip"
variant="success"
{{ $options.i18n.cancelPipelineText }}
</gl-button>
<gl-button
v-if="pipeline.userPermissions.destroyPipeline"
v-gl-modal="$options.modal.id"
:loading="isDeleting"
:disabled="isDeleting"
class="gl-ml-3"
variant="danger"
category="secondary"
data-testid="delete-pipeline"
>
{{ $options.i18n.latestBadgeText }}
</gl-badge>
<gl-badge
v-if="badges.mergeTrainPipeline"
v-gl-tooltip
:title="$options.i18n.mergeTrainBadgeTooltip"
variant="info"
>
{{ $options.i18n.mergeTrainBadgeText }}
</gl-badge>
<gl-badge v-if="badges.invalid" v-gl-tooltip :title="yamlErrors" variant="danger">
{{ $options.i18n.invalidBadgeText }}
</gl-badge>
<gl-badge v-if="badges.failed" v-gl-tooltip :title="failureReason" variant="danger">
{{ $options.i18n.failedBadgeText }}
</gl-badge>
<gl-badge
v-if="badges.autoDevops"
v-gl-tooltip
:title="$options.i18n.autoDevopsBadgeTooltip"
variant="info"
>
{{ $options.i18n.autoDevopsBadgeText }}
</gl-badge>
<gl-badge
v-if="badges.detached"
v-gl-tooltip
:title="$options.i18n.detachedBadgeTooltip"
variant="info"
data-qa-selector="merge_request_badge_tag"
>
{{ $options.i18n.detachedBadgeText }}
</gl-badge>
<gl-badge
v-if="badges.stuck"
v-gl-tooltip
:title="$options.i18n.stuckBadgeTooltip"
variant="warning"
>
{{ $options.i18n.stuckBadgeText }}
</gl-badge>
<span class="gl-ml-2" data-testid="total-jobs">
<gl-icon name="pipeline" />
{{ totalJobsText }}
</span>
<span v-if="isFinished" class="gl-ml-2" data-testid="compute-credits">
<gl-icon name="quota" />
{{ computeCredits }}
</span>
<span v-if="inProgress" class="gl-ml-2" data-testid="pipeline-running-text">
<gl-icon name="timer" />
{{ inProgressText }}
</span>
{{ $options.i18n.deletePipelineText }}
</gl-button>
</div>
</template>
</div>
<gl-modal
:modal-id="$options.modal.id"
:title="$options.modal.title"
:action-primary="$options.modal.actionPrimary"
:action-cancel="$options.modal.actionCancel"
@primary="deletePipeline()"
>
<p>
{{ $options.modal.deleteConfirmationText }}
</p>
</gl-modal>
</div>
</template>

View File

@ -9,6 +9,8 @@ import {
TAGS,
FETCH_CONTAINING_REFS_EVENT,
FETCH_COMMIT_REFERENCES_ERROR,
BRANCHES_REF_TYPE,
TAGS_REF_TYPE,
} from '../constants';
import RefsList from './refs_list.vue';
@ -98,7 +100,9 @@ export default {
tags: TAGS,
errorMessage: FETCH_COMMIT_REFERENCES_ERROR,
},
fetchContainingRefsEvent: FETCH_CONTAINING_REFS_EVENT,
FETCH_CONTAINING_REFS_EVENT,
BRANCHES_REF_TYPE,
TAGS_REF_TYPE,
};
</script>
@ -112,7 +116,8 @@ export default {
:containing-refs="containingBranches"
:namespace="$options.i18n.branches"
:url-part="commitsUrlPart"
@[$options.fetchContainingRefsEvent]="fetchContainingBranches"
:ref-type="$options.BRANCHES_REF_TYPE"
@[$options.FETCH_CONTAINING_REFS_EVENT]="fetchContainingBranches"
/>
<refs-list
v-if="hasTags"
@ -122,7 +127,8 @@ export default {
:containing-refs="containingTags"
:namespace="$options.i18n.tags"
:url-part="commitsUrlPart"
@[$options.fetchContainingRefsEvent]="fetchContainingTags"
:ref-type="$options.TAGS_REF_TYPE"
@[$options.FETCH_CONTAINING_REFS_EVENT]="fetchContainingTags"
/>
</div>
</template>

View File

@ -16,6 +16,10 @@ export default {
type: String,
required: true,
},
refType: {
type: String,
required: true,
},
containingRefs: {
type: Array,
required: false,
@ -60,6 +64,9 @@ export default {
this.toggleCollapse();
this.$emit(FETCH_CONTAINING_REFS_EVENT);
},
getRefUrl(ref) {
return `${this.urlPart}${ref}?ref_type=${this.refType}`;
},
},
i18n: {
containingCommit: CONTAINING_COMMIT,
@ -73,7 +80,7 @@ export default {
<gl-badge
v-for="ref in tippingRefs"
:key="ref"
:href="`${urlPart}${ref}`"
:href="getRefUrl(ref)"
class="gl-mt-2 gl-mr-2"
size="sm"
>{{ ref }}</gl-badge
@ -94,7 +101,7 @@ export default {
<gl-badge
v-for="ref in containingRefs"
:key="ref"
:href="`${urlPart}${ref}`"
:href="getRefUrl(ref)"
class="gl-mt-3 gl-mr-2"
size="sm"
>{{ ref }}</gl-badge

View File

@ -17,3 +17,7 @@ export const FETCH_CONTAINING_REFS_EVENT = 'fetch-containing-refs';
export const FETCH_COMMIT_REFERENCES_ERROR = s__(
'Commit|There was an error fetching the commit references. Please try again later.',
);
export const BRANCHES_REF_TYPE = 'heads';
export const TAGS_REF_TYPE = 'tags';

View File

@ -164,7 +164,7 @@ export default {
<gl-tabs
content-class="gl-pt-0"
data-qa-selector="security_configuration_container"
data-testid="security-configuration-container"
sync-active-tab-with-query-params
lazy
>
@ -196,12 +196,9 @@ export default {
{{ $options.i18n.description }}
</p>
<p v-if="canViewCiHistory">
<gl-link
data-testid="security-view-history-link"
data-qa-selector="security_configuration_history_link"
:href="gitlabCiHistoryPath"
>{{ $options.i18n.configurationHistory }}</gl-link
>
<gl-link data-testid="security-view-history-link" :href="gitlabCiHistoryPath">{{
$options.i18n.configurationHistory
}}</gl-link>
</p>
</template>

View File

@ -28,7 +28,7 @@ export default {
variant="info"
:primary-button-link="autoDevopsPath"
:primary-button-text="$options.i18n.primaryButtonText"
data-qa-selector="autodevops_container"
data-testid="autodevops-container"
@dismiss="dismissMethod"
>
<gl-sprintf :message="$options.i18n.body">

View File

@ -122,7 +122,7 @@ export default {
v-if="isNotSastIACTemporaryHack"
:class="statusClasses"
data-testid="feature-status"
:data-qa-selector="`${feature.type}_status`"
:data-qa-feature="`${feature.type}_${enabled}_status`"
>
<feature-card-badge
v-if="hasBadge"
@ -164,7 +164,7 @@ export default {
:href="feature.configurationPath"
variant="confirm"
:category="configurationButton.category"
:data-qa-selector="`${feature.type}_enable_button`"
:data-testid="`${feature.type}_enable_button`"
class="gl-mt-5"
>
{{ configurationButton.text }}
@ -176,7 +176,7 @@ export default {
variant="confirm"
:category="manageViaMrButtonCategory"
class="gl-mt-5"
:data-qa-selector="`${feature.type}_mr_button`"
:data-testid="`${feature.type}_mr_button`"
@error="onError"
/>

View File

@ -1,5 +1,5 @@
<script>
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
import CiIcon from './ci_icon.vue';
/**
* Renders CI Badge link with CI icon and status text based on
@ -26,8 +26,8 @@ import CiIcon from './ci_icon.vue';
export default {
components: {
GlLink,
CiIcon,
GlBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -42,6 +42,11 @@ export default {
required: false,
default: true,
},
badgeSize: {
type: String,
required: false,
default: 'md',
},
},
computed: {
title() {
@ -51,27 +56,76 @@ export default {
// For now, this can either come from graphQL with camelCase or REST API in snake_case
return this.status.detailsPath || this.status.details_path;
},
cssClass() {
const className = this.status.group;
return className ? `ci-status ci-${className}` : 'ci-status';
badgeStyles() {
switch (this.status.icon) {
case 'status_success':
return {
textColor: 'gl-text-green-700',
variant: 'success',
};
case 'status_warning':
return {
textColor: 'gl-text-orange-700',
variant: 'warning',
};
case 'status_failed':
return {
textColor: 'gl-text-red-700',
variant: 'danger',
};
case 'status_running':
return {
textColor: 'gl-text-blue-700',
variant: 'info',
};
case 'status_pending':
return {
textColor: 'gl-text-orange-700',
variant: 'warning',
};
case 'status_canceled':
return {
textColor: 'gl-text-gray-700',
variant: 'neutral',
};
case 'status_manual':
return {
textColor: 'gl-text-gray-700',
variant: 'neutral',
};
// default covers the styles for the remainder of CI
// statuses that are not explicitly stated here
default:
return {
textColor: 'gl-text-gray-600',
variant: 'muted',
};
}
},
},
};
</script>
<template>
<gl-link
<gl-badge
v-gl-tooltip
class="gl-display-inline-flex gl-align-items-center gl-line-height-0 gl-px-3 gl-py-2 gl-rounded-base"
:class="cssClass"
:title="title"
data-qa-selector="status_badge_link"
:href="detailsPath"
:size="badgeSize"
:variant="badgeStyles.variant"
:data-testid="`ci-badge-${status.text}`"
data-qa-selector="status_badge_link"
@click="$emit('ciStatusBadgeClick')"
>
<ci-icon :status="status" />
<template v-if="showText">
<span class="gl-ml-2 gl-white-space-nowrap">{{ status.text }}</span>
<span
class="gl-ml-2 gl-white-space-nowrap"
:class="badgeStyles.textColor"
data-testid="ci-badge-text"
>
{{ status.text }}
</span>
</template>
</gl-link>
</gl-badge>
</template>

View File

@ -131,7 +131,6 @@ export default {
ref="search"
:value="searchTerm"
:placeholder="searchText"
class="js-dropdown-input-field"
@input="setSearchTerm"
/>
</slot>

View File

@ -308,7 +308,7 @@ export default {
<gl-search-box-by-type
ref="search"
:value="search"
class="js-dropdown-input-field"
data-testid="user-search-input"
@input="debouncedSearchKeyUpdate"
/>
</template>

View File

@ -47,3 +47,5 @@ module Types
end
end
end
Types::Ci::RunnerManagerType.prepend_mod_with('Types::Ci::RunnerManagerType')

View File

@ -82,12 +82,12 @@ module Integrations
if ref_type == 'tag'
"#{project_url}/-/tags/#{ref}"
else
"#{project_url}/commits/#{ref}"
"#{project_url}/-/commits/#{ref}"
end
end
def compare_url
"#{project_url}/compare/#{before}...#{after}"
"#{project_url}/-/compare/#{before}...#{after}"
end
def ref_link

View File

@ -89,13 +89,13 @@ module Ci
def ref_text
if pipeline.detached_merge_request_pipeline?
_("For merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch}")
_("Related merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch}")
.html_safe % {
link_to_merge_request: link_to_merge_request,
link_to_merge_request_source_branch: link_to_merge_request_source_branch
}
elsif pipeline.merged_result_pipeline?
_("For merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}")
_("Related merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}")
.html_safe % {
link_to_merge_request: link_to_merge_request,
link_to_merge_request_source_branch: link_to_merge_request_source_branch,

View File

@ -26,7 +26,7 @@
.detail-page-header-actions.gl-align-self-start.is-merge-request.js-issuable-actions.gl-display-flex
- if can_update_merge_request
= render Pajamas::ButtonComponent.new(href: edit_project_merge_request_path(@project, @merge_request), button_options: {class: "gl-display-none gl-md-display-block js-issuable-edit", data: { qa_selector: "edit_button" }}) do
= render Pajamas::ButtonComponent.new(href: edit_project_merge_request_path(@project, @merge_request), button_options: {class: "gl-display-none gl-md-display-block js-issuable-edit", data: { qa_selector: "edit_title_button" }}) do
= _('Edit')
- if @merge_request.source_project

View File

@ -3,12 +3,12 @@
= dropdown_tag(_('Select'),
options: { toggle_class: 'js-allowed-to-merge wide',
dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_merge_dropdown_content', dropdown_testid: 'allowed-to-merge-dropdown',
data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes', qa_selector: 'allowed_to_merge_dropdown' }})
data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes', qa_selector: 'select_allowed_to_merge_dropdown' }})
- content_for :push_access_levels do
.push_access_levels-container
= dropdown_tag(_('Select'),
options: { toggle_class: "js-allowed-to-push js-multiselect wide",
dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_push_dropdown_content' , dropdown_testid: 'allowed-to-push-dropdown',
data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes', qa_selector: 'allowed_to_push_dropdown' }})
data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes', qa_selector: 'select_allowed_to_push_dropdown' }})
= render 'protected_branches/shared/create_protected_branch', protected_branch_entity: protected_branch_entity

View File

@ -1,5 +1,5 @@
---
data_category: optional
data_category: operational
key_path: redis_hll_counters.source_code.merge_request_action_monthly
description: Count of unique users who perform an action on a merge request
product_section: dev

View File

@ -1,5 +1,5 @@
---
data_category: optional
data_category: operational
key_path: redis_hll_counters.issues_edit.g_project_management_issue_comment_added_monthly
description: Count of MAU commenting on an issue
product_section: dev

View File

@ -1,5 +1,5 @@
---
data_category: optional
data_category: operational
key_path: counts.groups
description: Total count of groups as of usage ping snapshot
product_section: dev

View File

@ -66,11 +66,6 @@ InitializerConnections.raise_if_new_database_connection do
Gitlab.ee do
resource :company, only: [:new, :create], controller: 'company'
# TODO: remove next line and the controller after the deployment
# https://gitlab.com/gitlab-org/gitlab/-/issues/411208
resources :groups_projects, only: [:create] do
post :import, on: :collection
end
resources :groups, only: [:new, :create] do
post :import, on: :collection
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
class RemoveBroadcastMessagesNamespaceIdColumn < Gitlab::Database::Migration[2.1]
disable_ddl_transaction!
INDEX_NAME = 'index_broadcast_messages_on_namespace_id'
def up
remove_column :broadcast_messages, :namespace_id
end
def down
# rubocop:disable Migration/SchemaAdditionMethodsNoPost
add_column :broadcast_messages, :namespace_id, :bigint unless column_exists?(:broadcast_messages, :namespace_id)
# rubocop:enable Migration/SchemaAdditionMethodsNoPost
add_concurrent_index :broadcast_messages, :namespace_id, name: INDEX_NAME
end
end

View File

@ -0,0 +1 @@
915530f0de68a448bb9c88572896dc0979a38b5624dc5006811a4c635e35c71e

View File

@ -12761,8 +12761,7 @@ CREATE TABLE broadcast_messages (
broadcast_type smallint DEFAULT 1 NOT NULL,
dismissable boolean,
target_access_levels integer[] DEFAULT '{}'::integer[] NOT NULL,
theme smallint DEFAULT 0 NOT NULL,
namespace_id bigint
theme smallint DEFAULT 0 NOT NULL
);
CREATE SEQUENCE broadcast_messages_id_seq
@ -29976,8 +29975,6 @@ CREATE INDEX index_boards_on_project_id ON boards USING btree (project_id);
CREATE INDEX index_broadcast_message_on_ends_at_and_broadcast_type_and_id ON broadcast_messages USING btree (ends_at, broadcast_type, id);
CREATE INDEX index_broadcast_messages_on_namespace_id ON broadcast_messages USING btree (namespace_id);
CREATE INDEX index_btree_namespaces_traversal_ids ON namespaces USING btree (traversal_ids);
CREATE INDEX index_bulk_import_batch_trackers_on_tracker_id ON bulk_import_batch_trackers USING btree (tracker_id);

View File

@ -10348,6 +10348,29 @@ The edge type for [`ProductAnalyticsDashboardPanel`](#productanalyticsdashboardp
| <a id="productanalyticsdashboardpaneledgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
| <a id="productanalyticsdashboardpaneledgenode"></a>`node` | [`ProductAnalyticsDashboardPanel`](#productanalyticsdashboardpanel) | The item at the end of the edge. |
#### `ProductAnalyticsDashboardVisualizationConnection`
The connection type for [`ProductAnalyticsDashboardVisualization`](#productanalyticsdashboardvisualization).
##### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="productanalyticsdashboardvisualizationconnectionedges"></a>`edges` | [`[ProductAnalyticsDashboardVisualizationEdge]`](#productanalyticsdashboardvisualizationedge) | A list of edges. |
| <a id="productanalyticsdashboardvisualizationconnectionnodes"></a>`nodes` | [`[ProductAnalyticsDashboardVisualization]`](#productanalyticsdashboardvisualization) | A list of nodes. |
| <a id="productanalyticsdashboardvisualizationconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
#### `ProductAnalyticsDashboardVisualizationEdge`
The edge type for [`ProductAnalyticsDashboardVisualization`](#productanalyticsdashboardvisualization).
##### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="productanalyticsdashboardvisualizationedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
| <a id="productanalyticsdashboardvisualizationedgenode"></a>`node` | [`ProductAnalyticsDashboardVisualization`](#productanalyticsdashboardvisualization) | The item at the end of the edge. |
#### `ProjectConnection`
The connection type for [`Project`](#project).
@ -12941,6 +12964,7 @@ Returns [`CiRunnerStatus!`](#cirunnerstatus).
| <a id="cirunnermanagerrunner"></a>`runner` | [`CiRunner`](#cirunner) | Runner configuration for the runner manager. |
| <a id="cirunnermanagerstatus"></a>`status` | [`CiRunnerStatus!`](#cirunnerstatus) | Status of the runner manager. |
| <a id="cirunnermanagersystemid"></a>`systemId` | [`String!`](#string) | System ID associated with the runner manager. |
| <a id="cirunnermanagerupgradestatus"></a>`upgradeStatus` **{warning-solid}** | [`CiRunnerUpgradeStatus`](#cirunnerupgradestatus) | **Introduced** in 16.1. This feature is an Experiment. It can be changed or removed at any time. Availability of upgrades for the runner manager. |
| <a id="cirunnermanagerversion"></a>`version` | [`String`](#string) | Version of the runner. |
### `CiSecureFileRegistry`
@ -19505,6 +19529,7 @@ Represents a product analytics dashboard visualization.
| ---- | ---- | ----------- |
| <a id="productanalyticsdashboardvisualizationdata"></a>`data` | [`JSON!`](#json) | Data of the visualization. |
| <a id="productanalyticsdashboardvisualizationoptions"></a>`options` | [`JSON!`](#json) | Options of the visualization. |
| <a id="productanalyticsdashboardvisualizationslug"></a>`slug` | [`String!`](#string) | Slug of the visualization. |
| <a id="productanalyticsdashboardvisualizationtype"></a>`type` | [`String!`](#string) | Type of the visualization. |
### `Project`
@ -20562,6 +20587,26 @@ four standard [pagination arguments](#connection-pagination-arguments):
| ---- | ---- | ----------- |
| <a id="projectproductanalyticsdashboardsslug"></a>`slug` | [`String`](#string) | Find by dashboard slug. |
##### `Project.productAnalyticsVisualizations`
Visualizations of the project or associated configuration project.
WARNING:
**Introduced** in 16.1.
This feature is an Experiment. It can be changed or removed at any time.
Returns [`ProductAnalyticsDashboardVisualizationConnection`](#productanalyticsdashboardvisualizationconnection).
This field returns a [connection](#connections). It accepts the
four standard [pagination arguments](#connection-pagination-arguments):
`before: String`, `after: String`, `first: Int`, `last: Int`.
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="projectproductanalyticsvisualizationsslug"></a>`slug` | [`String`](#string) | Slug of the visualization to return. |
##### `Project.projectMembers`
Members of the project.

View File

@ -411,31 +411,32 @@ scope.
### Stage 5 - Optional disabling of registration token
| Component | Milestone | Changes |
|------------------|----------:|---------|
| GitLab Rails app | `%16.0` | Adapt `register_{group|project}_runner` permissions to take [application setting](https://gitlab.com/gitlab-org/gitlab/-/issues/386712) in consideration. |
| GitLab Rails app | | Add UI to allow disabling use of registration tokens at project or group level. |
| GitLab Rails app | | Introduce `:enforce_create_runner_workflow` feature flag (disabled by default) to control whether use of registration tokens is allowed. |
| GitLab Rails app | | Make [`POST /api/v4/runners` endpoint](../../../api/runners.md#register-a-new-runner) permanently return `HTTP 410 Gone` if either `allow_runner_registration_token` setting or `:enforce_create_runner_workflow` feature flag disables registration tokens.<br/>A future v5 version of the API should return `HTTP 404 Not Found`. |
| GitLab Rails app | | Hide legacy UI showing registration with a registration token, if `:enforce_create_runner_workflow` feature flag disables registration tokens. |
| Component | Milestone | Changes |
|------------------|----------:|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| GitLab Rails app | `%16.0` | Adapt `register_{group|project}_runner` permissions to take [application setting](https://gitlab.com/gitlab-org/gitlab/-/issues/386712) in consideration. |
| GitLab Rails app | | Add UI to allow disabling use of registration tokens in top-level group settings. |
| GitLab Rails app | | Make [`POST /api/v4/runners` endpoint](../../../api/runners.md#register-a-new-runner) permanently return `HTTP 410 Gone` if either `allow_runner_registration_token` setting disables registration tokens.<br/>A future v5 version of the API should return `HTTP 404 Not Found`. |
| GitLab Rails app | | Hide legacy UI showing registration with a registration token, if it disabled on in top-level group settings or by admins. |
### Stage 6 - Enforcement
| Component | Milestone | Changes |
|------------------|----------:|---------|
| GitLab Rails app | `%16.6` | Enable `:enforce_create_runner_workflow` feature flag by default. |
| GitLab Rails app | | Implement new `:create_runner` PPGAT scope so that we don't require a full `api` scope. |
| GitLab Rails app | | Document gotchas when [automatically rotating runner tokens](../../../ci/runners/configure_runners.md#automatically-rotate-authentication-tokens) with multiple machines. |
| Component | Milestone | Changes |
|------------------|----------:|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| GitLab Rails app | `%16.6` | Disable registration tokens for all groups by running database migration (only on GitLab.com) | |
| GitLab Rails app | `%16.6` | Disable registration tokens on the instance level by running database migration (except GitLab.com) | |
| GitLab Rails app | `%16.8` | Disable registration tokens on the instance level for GitLab.com | |
| GitLab Rails app | | Implement new `:create_runner` PPGAT scope so that we don't require a full `api` scope. |
| GitLab Rails app | | Document gotchas when [automatically rotating runner tokens](../../../ci/runners/configure_runners.md#automatically-rotate-authentication-tokens) with multiple machines. |
### Stage 7 - Removals
| Component | Milestone | Changes |
|------------------|----------:|---------|
| GitLab Rails app | `17.0` | Remove legacy UI showing registration with a registration token. |
| GitLab Runner | `17.0` | Remove runner model arguments from `register` command (for example `--run-untagged`, `--tag-list`, etc.) |
| GitLab Rails app | `17.0` | Create database migrations to drop `allow_runner_registration_token` setting columns from `application_settings` and `namespace_settings` tables. |
| Component | Milestone | Changes |
|------------------|----------:|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| GitLab Rails app | `17.0` | Remove UI enabling registration tokens on the group and instance levels. |
| GitLab Rails app | `17.0` | Remove legacy UI showing registration with a registration token. |
| GitLab Runner | `17.0` | Remove runner model arguments from `register` command (for example `--run-untagged`, `--tag-list`, etc.) |
| GitLab Rails app | `17.0` | Create database migrations to drop `allow_runner_registration_token` setting columns from `application_settings` and `namespace_settings` tables. |
| GitLab Rails app | `17.0` | Create database migrations to drop:<br/>- `runners_registration_token`/`runners_registration_token_encrypted` columns from `application_settings`;<br/>- `runners_token`/`runners_token_encrypted` from `namespaces` table;<br/>- `runners_token`/`runners_token_encrypted` from `projects` table. |
| GitLab Rails app | `17.0` | Remove `:enforce_create_runner_workflow` feature flag. |
## FAQ
@ -444,13 +445,16 @@ scope.
If no action is taken before your GitLab instance is upgraded to 16.6, then your runner registration
workflow will break.
Until then, both the new and the old workflow will coexist side-by-side.
For self-managed instances, to continue using the previous runner registration process,
you can disable the `enforce_create_runner_workflow` feature flag until GitLab 17.0.
To avoid a broken workflow, you need to first create a runner in the GitLab runners admin page.
After that, you'll need to replace the registration token you're using in your runner registration
workflow with the obtained runner authentication token.
### Can I use the old runner registration process after 15.6?
- If you're using GitLab.com, you'll be able to manually re-enable the previous runner registration process in the top-level group settings until GitLab 16.8.
- If you're running GitLab self-managed, you'll be able re-enable the previous runner registration process in admin settings until GitLab 17.0.
### What is the new runner registration process?
When the new runner registration process is introduced, you will:
@ -476,12 +480,6 @@ This allows the GitLab instance to display which system executed a given job.
- In GitLab 15.10, we plan to implement runner creation directly in the runners administration page,
and prepare the runner to follow the new workflow.
- In GitLab 16.6, we plan to disable registration tokens.
For self-managed instances, to continue using
registration tokens, you can disable the `enforce_create_runner_workflow` feature flag until
GitLab 17.0.
Previous `gitlab-runner` versions (that don't include the new `system_id` value) will start to be
rejected by the GitLab instance;
- In GitLab 17.0, we plan to completely remove support for runner registration tokens.
### How will the `gitlab-runner register` command syntax change?

View File

@ -35,7 +35,7 @@ as it can cause the pipeline to behave unexpectedly.
| `CI_COMMIT_DESCRIPTION` | 10.8 | all | The description of the commit. If the title is shorter than 100 characters, the message without the first line. |
| `CI_COMMIT_MESSAGE` | 10.8 | all | The full commit message. |
| `CI_COMMIT_REF_NAME` | 9.0 | all | The branch or tag name for which project is built. |
| `CI_COMMIT_REF_PROTECTED` | 11.11 | all | `true` if the job is running for a protected reference. |
| `CI_COMMIT_REF_PROTECTED` | 11.11 | all | `true` if the job is running for a protected reference, `false` otherwise. |
| `CI_COMMIT_REF_SLUG` | 9.0 | all | `CI_COMMIT_REF_NAME` in lowercase, shortened to 63 bytes, and with everything except `0-9` and `a-z` replaced with `-`. No leading / trailing `-`. Use in URLs, host names and domain names. |
| `CI_COMMIT_SHA` | 9.0 | all | The commit revision the project is built for. |
| `CI_COMMIT_SHORT_SHA` | 11.7 | all | The first eight characters of `CI_COMMIT_SHA`. |

View File

@ -129,7 +129,7 @@ workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_TAG
- if: $CI_COMMIT_REF_PROTECTED
- if: $CI_COMMIT_REF_PROTECTED == "true"
```
This example assumes that your long-lived branches are [protected](../../user/project/protected_branches.md).

View File

@ -136,9 +136,9 @@ that are scoped to a single [configuration keyword](../../ci/yaml/index.md#job-k
| `.qa-cache` | Allows a job to use a default `cache` definition suitable for QA tasks. |
| `.yarn-cache` | Allows a job to use a default `cache` definition suitable for frontend jobs that do a `yarn install`. |
| `.assets-compile-cache` | Allows a job to use a default `cache` definition suitable for frontend jobs that compile assets. |
| `.use-pg13` | Allows a job to use the `postgres` 13 and `redis` services (see [`.gitlab/ci/global.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/ci/global.gitlab-ci.yml) for the specific versions of the services). |
| `.use-pg13` | Allows a job to use the `postgres` 13, `redis`, and `rediscluster` services (see [`.gitlab/ci/global.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/ci/global.gitlab-ci.yml) for the specific versions of the services). |
| `.use-pg13-ee` | Same as `.use-pg13` but also use an `elasticsearch` service (see [`.gitlab/ci/global.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/ci/global.gitlab-ci.yml) for the specific version of the service). |
| `.use-pg14` | Allows a job to use the `postgres` 14 and `redis` services (see [`.gitlab/ci/global.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/ci/global.gitlab-ci.yml) for the specific versions of the services). |
| `.use-pg14` | Allows a job to use the `postgres` 14, `redis`, and `rediscluster` services (see [`.gitlab/ci/global.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/ci/global.gitlab-ci.yml) for the specific versions of the services). |
| `.use-pg14-ee` | Same as `.use-pg14` but also use an `elasticsearch` service (see [`.gitlab/ci/global.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/ci/global.gitlab-ci.yml) for the specific version of the service). |
| `.use-kaniko` | Allows a job to use the `kaniko` tool to build Docker images. |
| `.as-if-foss` | Simulate the FOSS project by setting the `FOSS_ONLY='1'` CI/CD variable. |

View File

@ -13,8 +13,8 @@ If you're learning about GitLab, to find more tutorial content:
- Find recent tutorials on the GitLab blog by [searching by the `tutorial` tag](https://about.gitlab.com/blog/tags.html#tutorial).
- Browse the **Learn@GitLab** [playlist on YouTube](https://www.youtube.com/playlist?list=PLFGfElNsQthYDx0A_FaNNfUm9NHsK6zED)
- Browse the **GitLab Snapshots** [playlist on YouTube](https://www.youtube.com/playlist?list=PLFGfElNsQthYDx0A_FaNNfUm9NHsK6zED)
to find video tutorials.
If you find an article, video, or other resource that would be a
great addition to this page, add it in a [merge request](../development/documentation/index.md).
great addition to the tutorial pages, add it in a [merge request](../development/documentation/index.md).

View File

@ -5,11 +5,11 @@ module Gitlab
module Status
class Scheduled < Status::Core
def text
s_('CiStatusText|delayed')
s_('CiStatusText|scheduled')
end
def label
s_('CiStatusLabel|delayed')
s_('CiStatusLabel|scheduled')
end
def icon

View File

@ -9,7 +9,7 @@ module Gitlab
#
class SuccessWarning < Status::Extended
def text
s_('CiStatusText|passed')
s_('CiStatusText|warning')
end
def label

View File

@ -9619,9 +9619,6 @@ msgstr ""
msgid "CiStatusLabel|created"
msgstr ""
msgid "CiStatusLabel|delayed"
msgstr ""
msgid "CiStatusLabel|failed"
msgstr ""
@ -9640,6 +9637,9 @@ msgstr ""
msgid "CiStatusLabel|preparing"
msgstr ""
msgid "CiStatusLabel|scheduled"
msgstr ""
msgid "CiStatusLabel|skipped"
msgstr ""
@ -9679,12 +9679,18 @@ msgstr ""
msgid "CiStatusText|preparing"
msgstr ""
msgid "CiStatusText|scheduled"
msgstr ""
msgid "CiStatusText|skipped"
msgstr ""
msgid "CiStatusText|waiting"
msgstr ""
msgid "CiStatusText|warning"
msgstr ""
msgid "CiStatus|running"
msgstr ""
@ -19289,12 +19295,6 @@ msgstr ""
msgid "For investigating IT service disruptions or outages"
msgstr ""
msgid "For merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch}"
msgstr ""
msgid "For merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}"
msgstr ""
msgid "For more info, read the documentation."
msgstr ""
@ -32155,14 +32155,15 @@ msgstr ""
msgid "PackageRegistry|Debian"
msgstr ""
msgid "PackageRegistry|Delete 1 asset"
msgid_plural "PackageRegistry|Delete %d assets"
msgstr[0] ""
msgstr[1] ""
msgid "PackageRegistry|Delete %{count} assets"
msgstr ""
msgid "PackageRegistry|Delete Package Version"
msgstr ""
msgid "PackageRegistry|Delete asset"
msgstr ""
msgid "PackageRegistry|Delete package"
msgstr ""
@ -32471,6 +32472,9 @@ msgstr ""
msgid "PackageRegistry|Yes, delete selected packages"
msgstr ""
msgid "PackageRegistry|You are about to delete %{count} assets. This operation is irreversible."
msgstr ""
msgid "PackageRegistry|You are about to delete %{count} packages. This operation is irreversible."
msgstr ""
@ -32480,11 +32484,6 @@ msgstr ""
msgid "PackageRegistry|You are about to delete %{name}, are you sure?"
msgstr ""
msgid "PackageRegistry|You are about to delete 1 asset. This operation is irreversible."
msgid_plural "PackageRegistry|You are about to delete %d assets. This operation is irreversible."
msgstr[0] ""
msgstr[1] ""
msgid "PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?"
msgstr ""
@ -33574,6 +33573,12 @@ msgstr ""
msgid "Pipelines|Token"
msgstr ""
msgid "Pipelines|Total amount of compute credits used for the pipeline"
msgstr ""
msgid "Pipelines|Total number of jobs for the pipeline"
msgstr ""
msgid "Pipelines|Trigger user has insufficient permissions to project"
msgstr ""
@ -37681,6 +37686,12 @@ msgstr ""
msgid "Related issues"
msgstr ""
msgid "Related merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch}"
msgstr ""
msgid "Related merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}"
msgstr ""
msgid "Related merge requests"
msgstr ""
@ -39078,6 +39089,9 @@ msgid_plural "Runners|%{highlightStart}%{duration}%{highlightEnd} seconds"
msgstr[0] ""
msgstr[1] ""
msgid "Runners|%{linkStart}Create a new runner%{linkEnd} to get started."
msgstr ""
msgid "Runners|%{link_start}These runners%{link_end} are available to all groups and projects."
msgstr ""
@ -39306,6 +39320,9 @@ msgstr ""
msgid "Runners|Filter projects"
msgstr ""
msgid "Runners|Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner."
msgstr ""
msgid "Runners|Get started with runners"
msgstr ""
@ -39632,10 +39649,7 @@ msgstr ""
msgid "Runners|Runners are grouped when they have the same authentication token. This happens when you re-use a runner configuration in more than one runner manager. %{linkStart}How does this work?%{linkEnd}"
msgstr ""
msgid "Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner."
msgstr ""
msgid "Runners|Runners are the agents that run your CI/CD jobs. To register new runners, please contact your administrator."
msgid "Runners|Runners are the agents that run your CI/CD jobs."
msgstr ""
msgid "Runners|Runners performance"
@ -39707,6 +39721,9 @@ msgstr ""
msgid "Runners|Step 3 (optional)"
msgstr ""
msgid "Runners|Still using registration tokens?"
msgstr ""
msgid "Runners|Stop the runner from accepting new jobs."
msgstr ""
@ -39778,6 +39795,9 @@ msgstr ""
msgid "Runners|To install Runner in a container follow the instructions described in the GitLab documentation"
msgstr ""
msgid "Runners|To register new runners, contact your administrator."
msgstr ""
msgid "Runners|To register them, go to the %{link_start}group's Runners page%{link_end}."
msgstr ""

View File

@ -13,9 +13,7 @@ module QA
@attributes[:pattern] ||= selector
options.each do |option|
if option.is_a?(String) || option.is_a?(Regexp)
@attributes[:pattern] = option
end
@attributes[:pattern] = option if option.is_a?(String) || option.is_a?(Regexp)
end
end
@ -28,7 +26,7 @@ module QA
end
def selector_css
%Q([data-qa-selector="#{@name}"]#{additional_selectors},.#{selector})
%(#{qa_selector}#{additional_selectors},.#{selector})
end
def expression
@ -40,14 +38,26 @@ module QA
end
def matches?(line)
!!(line =~ /["']#{name}['"]|#{expression}/)
!!(line =~ /["']#{name}['"]|["']#{convert_to_kebabcase(name)}['"]|#{expression}/)
end
private
def convert_to_kebabcase(text)
text.to_s.tr('_', '-')
end
def qa_selector
[
%([data-testid="#{name}"]#{additional_selectors}),
%([data-testid="#{convert_to_kebabcase(name)}"]#{additional_selectors}),
%([data-qa-selector="#{name}"]#{additional_selectors})
].join(',')
end
def additional_selectors
@attributes.dup.delete_if { |attr| attr == :pattern || attr == :required }.map do |key, value|
%Q([data-qa-#{key.to_s.tr('_', '-')}="#{value}"])
%([data-qa-#{key.to_s.tr('_', '-')}="#{value}"])
end.join
end
end

View File

@ -118,7 +118,7 @@ module QA
end
view 'app/views/projects/merge_requests/_mr_title.html.haml' do
element :edit_button
element :edit_title_button
element :title_content, required: true
end
@ -211,7 +211,7 @@ module QA
# Click by JS is needed to bypass the Moved MR actions popover
# Change back to regular click_element when moved_mr_sidebar FF is removed
# Rollout issue: https://gitlab.com/gitlab-org/gitlab/-/issues/385460
click_by_javascript(find_element(:edit_button))
click_by_javascript(find_element(:edit_title_button))
end
def fast_forward_not_possible?

View File

@ -9,15 +9,13 @@ module QA
view 'app/assets/javascripts/security_configuration/components/app.vue' do
element :security_configuration_container
element :security_configuration_history_link
element :security_view_history_link
end
view 'app/assets/javascripts/security_configuration/components/feature_card.vue' do
element :dependency_scanning_status, "`${feature.type}_status`" # rubocop:disable QA/ElementWithPattern
element :sast_status, "`${feature.type}_status`" # rubocop:disable QA/ElementWithPattern
element :feature_status
element :sast_enable_button, "`${feature.type}_enable_button`" # rubocop:disable QA/ElementWithPattern
element :dependency_scanning_mr_button, "`${feature.type}_mr_button`" # rubocop:disable QA/ElementWithPattern
element :license_scanning_status, "`${feature.type}_status`" # rubocop:disable QA/ElementWithPattern
end
view 'app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue' do
@ -25,15 +23,15 @@ module QA
end
def has_security_configuration_history_link?
has_element?(:security_configuration_history_link)
has_element?(:security_view_history_link)
end
def has_no_security_configuration_history_link?
has_no_element?(:security_configuration_history_link)
has_no_element?(:security_view_history_link)
end
def click_security_configuration_history_link
click_element(:security_configuration_history_link)
click_element(:security_view_history_link)
end
def click_sast_enable_button
@ -44,40 +42,20 @@ module QA
click_element(:dependency_scanning_mr_button)
end
def has_sast_status?(status_text)
within_element(:sast_status) do
has_text?(status_text)
end
def has_true_sast_status?
has_element?(:feature_status, feature: 'sast_true_status')
end
def has_no_sast_status?(status_text)
within_element(:sast_status) do
has_no_text?(status_text)
end
def has_false_sast_status?
has_element?(:feature_status, feature: 'sast_false_status')
end
def has_dependency_scanning_status?(status_text)
within_element(:dependency_scanning_status) do
has_text?(status_text)
end
def has_true_dependency_scanning_status?
has_element?(:feature_status, feature: 'dependency_scanning_true_status')
end
def has_no_dependency_scanning_status?(status_text)
within_element(:dependency_scanning_status) do
has_no_text?(status_text)
end
end
def has_license_compliance_status?(status_text)
within_element(:license_scanning_status) do
has_text?(status_text)
end
end
def has_no_license_compliance_status?(status_text)
within_element(:license_scanning_status) do
has_no_text?(status_text)
end
def has_false_dependency_scanning_status?
has_element?(:feature_status, feature: 'dependency_scanning_false_status')
end
def has_auto_devops_container?

View File

@ -11,9 +11,9 @@ module QA
end
view 'app/views/protected_branches/_create_protected_branch.html.haml' do
element :allowed_to_push_dropdown
element :select_allowed_to_push_dropdown
element :allowed_to_push_dropdown_content
element :allowed_to_merge_dropdown
element :select_allowed_to_merge_dropdown
element :allowed_to_merge_dropdown_content
end
@ -45,7 +45,7 @@ module QA
private
def select_allowed(action, allowed)
click_element :"allowed_to_#{action}_dropdown"
click_element :"select_allowed_to_#{action}_dropdown"
allowed[:roles] = Resource::ProtectedBranch::Roles::NO_ONE unless allowed.key?(:roles)

View File

@ -43,7 +43,7 @@ module QA
end
it 'mentions another user in an issue',
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347988' do
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347988' do
Page::Project::Issue::Show.perform do |show|
at_username = "@#{user.username}"

View File

@ -4,7 +4,7 @@ module QA
RSpec.describe 'Create' do
describe 'Git push over HTTP', :smoke, :skip_fips_env, product_group: :source_code do
it 'user using a personal access token pushes code to the repository',
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347749' do
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347749' do
Flow::Login.sign_in
access_token = Resource::PersonalAccessToken.fabricate!.token

View File

@ -4,7 +4,7 @@ module QA
RSpec.describe 'Create' do
describe 'Git push over HTTP', product_group: :source_code do
it 'user pushes code to the repository', :smoke, :skip_fips_env,
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347747' do
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347747' do
Flow::Login.sign_in
Resource::Repository::ProjectPush.fabricate! do |push|
@ -20,7 +20,7 @@ module QA
end
it 'pushes to a project using a specific Praefect repository storage', :smoke, :skip_fips_env, :requires_admin,
:requires_praefect, testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347789' do
:requires_praefect, testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347789' do
Flow::Login.sign_in_as_admin
project = Resource::Project.fabricate_via_api! do |storage_project|

View File

@ -27,7 +27,7 @@ module QA
end
it 'pushes code to the repository via SSH', :smoke, :skip_fips_env,
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347825' do
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347825' do
Resource::Repository::ProjectPush.fabricate! do |push|
push.project = project
push.ssh_key = @key
@ -43,7 +43,7 @@ module QA
end
it 'pushes multiple branches and tags together', :smoke, :skip_fips_env,
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347826' do
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347826' do
branches = []
tags = []
Git::Repository.perform do |repository|

View File

@ -17,7 +17,7 @@ module QA
end
it 'can preview markdown side-by-side while editing',
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/367749' do
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/367749' do
project.visit!
Page::Project::Show.perform do |project|
project.click_file('README.md')

View File

@ -71,10 +71,10 @@ module QA
if pull_image
expect(job_log).to have_content(message),
"Expected to find #{message} in #{job_log}, but didn't."
"Expected to find #{message} in #{job_log}, but didn't."
else
expect(job_log).not_to have_content(message),
"Found #{message} in #{job_log}, but didn't expect to."
"Found #{message} in #{job_log}, but didn't expect to."
end
end
end
@ -96,7 +96,7 @@ module QA
visit_job
expect(job_log).to include(text1, text2),
"Expected to find contents #{text1} and #{text2} in #{job_log}, but didn't."
"Expected to find contents #{text1} and #{text2} in #{job_log}, but didn't."
end
end

View File

@ -2,10 +2,10 @@
module QA
RSpec.describe 'Package', :orchestrated, :requires_admin, :packages, :object_storage, :reliable,
feature_flag: {
name: 'maven_central_request_forwarding',
scope: :global
} do
feature_flag: {
name: 'maven_central_request_forwarding',
scope: :global
} do
describe 'Maven project level endpoint', product_group: :package_registry do
include Runtime::Fixtures
include Support::Helpers::MaskToken
@ -218,16 +218,14 @@ module QA
) do
Support::Retrier.retry_on_exception(max_attempts: 3, sleep_interval: 2) do
Resource::Repository::Commit.fabricate_via_api! do |commit|
gitlab_ci_yaml = ERB.new(read_fixture('package_managers/maven/project/request_forwarding',
'gitlab_ci.yaml.erb'
)
)
.result(binding)
settings_xml = ERB.new(read_fixture('package_managers/maven/project/request_forwarding',
'settings.xml.erb'
)
)
.result(binding)
gitlab_ci_yaml = ERB.new(read_fixture(
'package_managers/maven/project/request_forwarding',
'gitlab_ci.yaml.erb'
)).result(binding)
settings_xml = ERB.new(read_fixture(
'package_managers/maven/project/request_forwarding',
'settings.xml.erb'
)).result(binding)
commit.project = imported_project
commit.commit_message = 'Add files'

View File

@ -2,7 +2,7 @@
module QA
RSpec.describe 'Package', :skip_live_env, :orchestrated, :packages, :object_storage,
product_group: :package_registry do
product_group: :package_registry do
describe 'NuGet project level endpoint' do
include Support::Helpers::MaskToken

View File

@ -2,7 +2,7 @@
module QA
RSpec.describe 'Package', :orchestrated, :packages, :object_storage,
feature_flag: { name: 'rubygem_packages', scope: :project } do
feature_flag: { name: 'rubygem_packages', scope: :project } do
describe 'RubyGems Repository', product_group: :package_registry do
include Runtime::Fixtures

View File

@ -73,7 +73,7 @@ RSpec.describe QA::Page::Element do
subject { described_class.new(:something, /link_to 'something'/) }
it 'has an attribute[pattern] of the pattern' do
expect(subject.attributes[:pattern]).to eq /link_to 'something'/
expect(subject.attributes[:pattern]).to eq(/link_to 'something'/)
end
it 'is not required by default' do
@ -98,7 +98,7 @@ RSpec.describe QA::Page::Element do
subject { described_class.new(:something, /link_to 'something_else_entirely'/, required: true) }
it 'has an attribute[pattern] of the passed pattern' do
expect(subject.attributes[:pattern]).to eq /link_to 'something_else_entirely'/
expect(subject.attributes[:pattern]).to eq(/link_to 'something_else_entirely'/)
end
it 'is required' do
@ -118,6 +118,10 @@ RSpec.describe QA::Page::Element do
expect(subject.selector_css).to include(%q([data-qa-selector="my_element"]))
end
it 'properly translates to a data-testid' do
expect(subject.selector_css).to include(%q([data-testid="my_element"]))
end
context 'additional selectors' do
let(:element) { described_class.new(:my_element, index: 3, another_match: 'something') }
let(:required_element) { described_class.new(:my_element, required: true, index: 3) }

View File

@ -162,7 +162,7 @@ RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
open_assignees_dropdown
page.within '.dropdown-menu-user' do
find('.js-dropdown-input-field').find('input').set(user2.name)
find('[data-testid="user-search-input"]').set(user2.name)
wait_for_requests
@ -182,7 +182,7 @@ RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
it 'keeps your filtered term after filtering and dismissing the dropdown' do
open_assignees_dropdown
find('.js-dropdown-input-field').find('input').set(user2.name)
find('[data-testid="user-search-input"]').set(user2.name)
wait_for_requests
page.within '.dropdown-menu-user' do
@ -199,7 +199,7 @@ RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
expect(page.all('[data-testid="selected-participant"]').length).to eq(1)
end
expect(find('.js-dropdown-input-field').find('input').value).to eq(user2.name)
expect(find('[data-testid="user-search-input"]').value).to eq(user2.name)
end
end
end

View File

@ -67,7 +67,7 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request',
it 'sees branch pipelines and detached merge request pipelines in correct order' do
page.within('.ci-table') do
expect(page).to have_selector('.ci-created', count: 2)
expect(page).to have_selector('[data-testid="ci-badge-created"]', count: 2)
expect(first('[data-testid="pipeline-url-link"]')).to have_content("##{detached_merge_request_pipeline.id}")
end
end
@ -103,7 +103,7 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request',
it 'sees branch pipelines and detached merge request pipelines in correct order' do
page.within('.ci-table') do
expect(page).to have_selector('.ci-pending', count: 4)
expect(page).to have_selector('[data-testid="ci-badge-pending"]', count: 4)
expect(all('[data-testid="pipeline-url-link"]')[0])
.to have_content("##{detached_merge_request_pipeline_2.id}")
@ -246,7 +246,7 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request',
it 'sees a branch pipeline in pipeline tab' do
page.within('.ci-table') do
expect(page).to have_selector('.ci-created', count: 1)
expect(page).to have_selector('[data-testid="ci-badge-created"]', count: 1)
expect(first('[data-testid="pipeline-url-link"]')).to have_content("##{push_pipeline.id}")
end
end
@ -299,7 +299,7 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request',
it 'sees branch pipelines and detached merge request pipelines in correct order' do
page.within('.ci-table') do
expect(page).to have_selector('.ci-pending', count: 2)
expect(page).to have_selector('[data-testid="ci-badge-pending"]', count: 2)
expect(first('[data-testid="pipeline-url-link"]')).to have_content("##{detached_merge_request_pipeline.id}")
end
end
@ -315,7 +315,7 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request',
it 'sees pipeline list in forked project' do
visit project_pipelines_path(forked_project)
expect(page).to have_selector('.ci-pending', count: 2)
expect(page).to have_selector('[data-testid="ci-badge-pending"]', count: 2)
end
context 'when a user updated a merge request from a forked project to the parent project' do
@ -341,7 +341,7 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request',
it 'sees branch pipelines and detached merge request pipelines in correct order' do
page.within('.ci-table') do
expect(page).to have_selector('.ci-pending', count: 4)
expect(page).to have_selector('[data-testid="ci-badge-pending"]', count: 4)
expect(all('[data-testid="pipeline-url-link"]')[0])
.to have_content("##{detached_merge_request_pipeline_2.id}")
@ -384,7 +384,7 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request',
it 'sees pipeline list in forked project' do
visit project_pipelines_path(forked_project)
expect(page).to have_selector('.ci-pending', count: 4)
expect(page).to have_selector('[data-testid="ci-badge-pending"]', count: 4)
end
end

View File

@ -42,7 +42,7 @@ RSpec.describe 'Merge request > User sees pipelines', :js, feature_category: :co
wait_for_requests
page.within('[data-testid="pipeline-table-row"]') do
expect(page).to have_selector('.ci-success')
expect(page).to have_selector('[data-testid="ci-badge-passed"]')
expect(page).to have_content(pipeline.id)
expect(page).to have_content('API')
expect(page).to have_css('[data-testid="pipeline-mini-graph"]')

View File

@ -36,7 +36,7 @@ RSpec.describe 'Commit > Pipelines tab', :js, feature_category: :source_code_man
wait_for_requests
page.within('[data-testid="pipeline-table-row"]') do
expect(page).to have_selector('.ci-success')
expect(page).to have_selector('[data-testid="ci-badge-passed"]')
expect(page).to have_content(pipeline.id)
expect(page).to have_content('API')
expect(page).to have_css('[data-testid="pipeline-mini-graph"]')

View File

@ -72,7 +72,7 @@ RSpec.describe 'User browses jobs', feature_category: :groups_and_projects do
wait_for_requests
expect(page).to have_selector('.ci-canceled')
expect(page).to have_selector('[data-testid="ci-badge-canceled"]')
expect(page).not_to have_selector('[data-testid="jobs-table-error-alert"]')
end
end
@ -94,7 +94,7 @@ RSpec.describe 'User browses jobs', feature_category: :groups_and_projects do
wait_for_requests
expect(page).to have_selector('.ci-pending')
expect(page).to have_selector('[data-testid="ci-badge-pending"]')
end
end
@ -134,7 +134,7 @@ RSpec.describe 'User browses jobs', feature_category: :groups_and_projects do
wait_for_requests
expect(page).to have_selector('.ci-pending')
expect(page).to have_selector('[data-testid="ci-badge-pending"]')
end
it 'unschedules a job successfully' do
@ -142,7 +142,7 @@ RSpec.describe 'User browses jobs', feature_category: :groups_and_projects do
wait_for_requests
expect(page).to have_selector('.ci-manual')
expect(page).to have_selector('[data-testid="ci-badge-manual"]')
end
end

View File

@ -66,7 +66,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state, feature_category: :grou
wait_for_requests
expect(page).to have_css('.ci-status.ci-success', text: 'passed')
expect(page).to have_css('[data-testid="ci-badge-passed"]', text: 'passed')
end
it 'shows commit`s data', :js do

View File

@ -120,7 +120,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :groups_and_projects do
it 'indicates that pipeline can be canceled' do
expect(page).to have_selector('.js-pipelines-cancel-button')
expect(page).to have_selector('.ci-running')
expect(page).to have_selector('[data-testid="ci-badge-running"]')
end
context 'when canceling' do
@ -132,7 +132,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :groups_and_projects do
it 'indicated that pipelines was canceled', :sidekiq_might_not_need_inline do
expect(page).not_to have_selector('.js-pipelines-cancel-button')
expect(page).to have_selector('.ci-canceled')
expect(page).to have_selector('[data-testid="ci-badge-canceled"]')
end
end
end
@ -150,7 +150,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :groups_and_projects do
it 'indicates that pipeline can be retried' do
expect(page).to have_selector('.js-pipelines-retry-button')
expect(page).to have_selector('.ci-failed')
expect(page).to have_selector('[data-testid="ci-badge-failed"]')
end
context 'when retrying' do
@ -161,7 +161,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :groups_and_projects do
it 'shows running pipeline that is not retryable' do
expect(page).not_to have_selector('.js-pipelines-retry-button')
expect(page).to have_selector('.ci-running')
expect(page).to have_selector('[data-testid="ci-badge-running"]')
end
end
end
@ -400,7 +400,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :groups_and_projects do
end
it 'shows the pipeline as preparing' do
expect(page).to have_selector('.ci-preparing')
expect(page).to have_selector('[data-testid="ci-badge-preparing"]')
end
end
@ -421,7 +421,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :groups_and_projects do
end
it 'has pipeline running' do
expect(page).to have_selector('.ci-running')
expect(page).to have_selector('[data-testid="ci-badge-running"]')
end
context 'when canceling' do
@ -432,7 +432,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :groups_and_projects do
it 'indicates that pipeline was canceled', :sidekiq_might_not_need_inline do
expect(page).not_to have_selector('.js-pipelines-cancel-button')
expect(page).to have_selector('.ci-canceled')
expect(page).to have_selector('[data-testid="ci-badge-canceled"]')
end
end
end
@ -454,7 +454,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :groups_and_projects do
end
it 'has failed pipeline', :sidekiq_might_not_need_inline do
expect(page).to have_selector('.ci-failed')
expect(page).to have_selector('[data-testid="ci-badge-failed"]')
end
end
end

View File

@ -1,9 +1,11 @@
import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import Vue from 'vue';
import Draggable from 'vuedraggable';
import Vuex from 'vuex';
import eventHub from '~/boards/eventhub';
import createMockApollo from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue';
@ -11,9 +13,18 @@ import getters from 'ee_else_ce/boards/stores/getters';
import BoardColumn from '~/boards/components/board_column.vue';
import BoardContent from '~/boards/components/board_content.vue';
import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue';
import updateBoardListMutation from '~/boards/graphql/board_list_update.mutation.graphql';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
import { mockLists, mockListsById } from '../mock_data';
import { DraggableItemTypes } from 'ee_else_ce/boards/constants';
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
import {
mockLists,
mockListsById,
updateBoardListResponse,
boardListsQueryResponse,
} from '../mock_data';
Vue.use(VueApollo);
Vue.use(Vuex);
const actions = {
@ -22,6 +33,9 @@ const actions = {
describe('BoardContent', () => {
let wrapper;
let mockApollo;
const updateListHandler = jest.fn().mockResolvedValue(updateBoardListResponse);
const defaultState = {
isShowingEpicsSwimlanes: false,
@ -47,21 +61,32 @@ describe('BoardContent', () => {
isIssueBoard = true,
isEpicBoard = false,
} = {}) => {
mockApollo = createMockApollo([[updateBoardListMutation, updateListHandler]]);
const listQueryVariables = { isProject: true };
mockApollo.clients.defaultClient.writeQuery({
query: boardListsQuery,
variables: listQueryVariables,
data: boardListsQueryResponse.data,
});
const store = createStore({
...defaultState,
...state,
});
wrapper = shallowMount(BoardContent, {
apolloProvider: mockApollo,
propsData: {
boardId: 'gid://gitlab/Board/1',
filterParams: {},
isSwimlanesOn: false,
boardListsApollo: mockListsById,
listQueryVariables: {},
listQueryVariables,
addColumnFormVisible: false,
...props,
},
provide: {
boardType: 'project',
canAdminList,
issuableType,
isIssueBoard,
@ -81,6 +106,7 @@ describe('BoardContent', () => {
const findBoardColumns = () => wrapper.findAllComponents(BoardColumn);
const findBoardAddNewColumn = () => wrapper.findComponent(BoardAddNewColumn);
const findDraggable = () => wrapper.findComponent(Draggable);
describe('default', () => {
beforeEach(() => {
@ -128,7 +154,7 @@ describe('BoardContent', () => {
});
it('renders draggable component', () => {
expect(wrapper.findComponent(Draggable).exists()).toBe(true);
expect(findDraggable().exists()).toBe(true);
});
});
@ -138,7 +164,7 @@ describe('BoardContent', () => {
});
it('does not render draggable component', () => {
expect(wrapper.findComponent(Draggable).exists()).toBe(false);
expect(findDraggable().exists()).toBe(false);
});
});
@ -164,6 +190,21 @@ describe('BoardContent', () => {
expect(eventHub.$on).toHaveBeenCalledWith('updateBoard', wrapper.vm.refetchLists);
});
it('reorders lists', async () => {
const movableListsOrder = [mockLists[0].id, mockLists[1].id];
findDraggable().vm.$emit('end', {
item: { dataset: { listId: mockLists[0].id, draggableItemType: DraggableItemTypes.list } },
newIndex: 1,
to: {
children: movableListsOrder.map((listId) => ({ dataset: { listId } })),
},
});
await waitForPromises();
expect(updateListHandler).toHaveBeenCalled();
});
});
describe('when "add column" form is visible', () => {

View File

@ -1023,6 +1023,7 @@ export const updateBoardListResponse = {
data: {
updateBoardList: {
list: mockList,
errors: [],
},
},
};

View File

@ -1,5 +1,6 @@
import Vue from 'vue';
import { GlAlert, GlButton, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
@ -53,9 +54,6 @@ jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(),
}));
const localVue = createLocalVue();
localVue.use(VueApollo);
const defaultProvide = {
ciConfigPath: mockCiConfigPath,
defaultBranch: mockDefaultBranch,
@ -74,24 +72,10 @@ describe('Pipeline editor app component', () => {
let mockLatestCommitShaQuery;
let mockPipelineQuery;
const createComponent = ({
blobLoading = false,
options = {},
provide = {},
stubs = {},
} = {}) => {
const createComponent = ({ options = {}, provide = {}, stubs = {} } = {}) => {
wrapper = shallowMount(PipelineEditorApp, {
provide: { ...defaultProvide, ...provide },
stubs,
mocks: {
$apollo: {
queries: {
initialCiFileContent: {
loading: blobLoading,
},
},
},
},
...options,
});
};
@ -101,6 +85,8 @@ describe('Pipeline editor app component', () => {
stubs = {},
withUndefinedBranch = false,
} = {}) => {
Vue.use(VueApollo);
const handlers = [
[getBlobContent, mockBlobContentData],
[getCiConfigData, mockCiConfigData],
@ -137,7 +123,6 @@ describe('Pipeline editor app component', () => {
});
const options = {
localVue,
mocks: {},
apolloProvider: mockApollo,
};
@ -164,7 +149,7 @@ describe('Pipeline editor app component', () => {
describe('loading state', () => {
it('displays a loading icon if the blob query is loading', () => {
createComponent({ blobLoading: true });
createComponentWithApollo();
expect(findLoadingIcon().exists()).toBe(true);
expect(findEditorHome().exists()).toBe(false);
@ -246,10 +231,6 @@ describe('Pipeline editor app component', () => {
describe('when file exists', () => {
beforeEach(async () => {
await createComponentWithApollo();
jest
.spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling')
.mockImplementation(jest.fn());
});
it('shows pipeline editor home component', () => {
@ -268,8 +249,8 @@ describe('Pipeline editor app component', () => {
});
});
it('does not poll for the commit sha', () => {
expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(0);
it('calls once and does not start poll for the commit sha', () => {
expect(mockLatestCommitShaQuery).toHaveBeenCalledTimes(1);
});
});
@ -281,10 +262,6 @@ describe('Pipeline editor app component', () => {
PipelineEditorEmptyState,
},
});
jest
.spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling')
.mockImplementation(jest.fn());
});
it('shows an empty state and does not show editor home component', () => {
@ -293,8 +270,8 @@ describe('Pipeline editor app component', () => {
expect(findEditorHome().exists()).toBe(false);
});
it('does not poll for the commit sha', () => {
expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(0);
it('calls once and does not start poll for the commit sha', () => {
expect(mockLatestCommitShaQuery).toHaveBeenCalledTimes(1);
});
describe('because of a fetching error', () => {
@ -381,38 +358,27 @@ describe('Pipeline editor app component', () => {
});
it('polls for commit sha while pipeline data is not yet available for current branch', async () => {
jest
.spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling')
.mockImplementation(jest.fn());
// simulate a commit to the current branch
findEditorHome().vm.$emit('updateCommitSha');
await waitForPromises();
expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(1);
expect(mockLatestCommitShaQuery).toHaveBeenCalledTimes(2);
});
it('stops polling for commit sha when pipeline data is available for newly committed branch', async () => {
jest
.spyOn(wrapper.vm.$apollo.queries.commitSha, 'stopPolling')
.mockImplementation(jest.fn());
mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults);
await wrapper.vm.$apollo.queries.commitSha.refetch();
await waitForPromises();
expect(wrapper.vm.$apollo.queries.commitSha.stopPolling).toHaveBeenCalledTimes(1);
await findEditorHome().vm.$emit('updateCommitSha');
expect(mockLatestCommitShaQuery).toHaveBeenCalledTimes(2);
});
it('stops polling for commit sha when pipeline data is available for current branch', async () => {
jest
.spyOn(wrapper.vm.$apollo.queries.commitSha, 'stopPolling')
.mockImplementation(jest.fn());
mockLatestCommitShaQuery.mockResolvedValue(mockNewCommitShaResults);
findEditorHome().vm.$emit('updateCommitSha');
await waitForPromises();
expect(wrapper.vm.$apollo.queries.commitSha.stopPolling).toHaveBeenCalledTimes(1);
expect(mockLatestCommitShaQuery).toHaveBeenCalledTimes(2);
});
});
@ -497,15 +463,12 @@ describe('Pipeline editor app component', () => {
it('refetches blob content', async () => {
await createComponentWithApollo();
jest
.spyOn(wrapper.vm.$apollo.queries.initialCiFileContent, 'refetch')
.mockImplementation(jest.fn());
expect(wrapper.vm.$apollo.queries.initialCiFileContent.refetch).toHaveBeenCalledTimes(0);
expect(mockBlobContentData).toHaveBeenCalledTimes(1);
await wrapper.vm.refetchContent();
findEditorHome().vm.$emit('refetchContent');
expect(wrapper.vm.$apollo.queries.initialCiFileContent.refetch).toHaveBeenCalledTimes(1);
expect(mockBlobContentData).toHaveBeenCalledTimes(2);
});
it('hides start screen when refetch fetches CI file', async () => {
@ -516,7 +479,8 @@ describe('Pipeline editor app component', () => {
expect(findEditorHome().exists()).toBe(false);
mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
await wrapper.vm.$apollo.queries.initialCiFileContent.refetch();
findEmptyState().vm.$emit('refetchContent');
await waitForPromises();
expect(findEmptyState().exists()).toBe(false);
expect(findEditorHome().exists()).toBe(true);
@ -573,10 +537,6 @@ describe('Pipeline editor app component', () => {
mockGetTemplate.mockResolvedValue(mockCiTemplateQueryResponse);
await createComponentWithApollo();
jest
.spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling')
.mockImplementation(jest.fn());
});
it('skips empty state and shows editor home component', () => {

View File

@ -21,6 +21,7 @@ describe('RunnerFormFields', () => {
const findInput = (name) => wrapper.find(`input[name="${name}"]`);
const expectRendersFields = () => {
expect(wrapper.text()).toContain(s__('Runners|Tags'));
expect(wrapper.text()).toContain(s__('Runners|Details'));
expect(wrapper.text()).toContain(s__('Runners|Configuration'));
@ -42,10 +43,11 @@ describe('RunnerFormFields', () => {
});
it('renders a loading frame', () => {
expect(wrapper.text()).toContain(s__('Runners|Tags'));
expect(wrapper.text()).toContain(s__('Runners|Details'));
expect(wrapper.text()).toContain(s__('Runners|Configuration'));
expect(wrapper.findAllComponents(GlSkeletonLoader)).toHaveLength(2);
expect(wrapper.findAllComponents(GlSkeletonLoader)).toHaveLength(3);
expect(wrapper.findAll('input')).toHaveLength(0);
});
@ -101,23 +103,23 @@ describe('RunnerFormFields', () => {
it('checks checkbox fields', async () => {
createComponent({
value: {
runUntagged: false,
paused: false,
accessLevel: ACCESS_LEVEL_NOT_PROTECTED,
runUntagged: false,
},
});
findInput('run-untagged').setChecked(true);
findInput('paused').setChecked(true);
findInput('protected').setChecked(true);
findInput('run-untagged').setChecked(true);
await nextTick();
expect(wrapper.emitted('input').at(-1)).toEqual([
{
runUntagged: true,
paused: true,
accessLevel: ACCESS_LEVEL_REF_PROTECTED,
runUntagged: true,
},
]);
});

View File

@ -1,27 +1,46 @@
import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-pipeline-md.svg?url';
import FILTERED_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-search-md.svg?url';
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
import {
I18N_GET_STARTED,
I18N_RUNNERS_ARE_AGENTS,
I18N_CREATE_RUNNER_LINK,
I18N_STILL_USING_REGISTRATION_TOKENS,
I18N_CONTACT_ADMIN_TO_REGISTER,
I18N_FOLLOW_REGISTRATION_INSTRUCTIONS,
I18N_NO_RESULTS,
I18N_EDIT_YOUR_SEARCH,
} from '~/ci/runner/constants';
import { mockRegistrationToken, newRunnerPath } from 'jest/ci/runner/mock_data';
import {
mockRegistrationToken,
newRunnerPath as mockNewRunnerPath,
} from 'jest/ci/runner/mock_data';
import RunnerListEmptyState from '~/ci/runner/components/runner_list_empty_state.vue';
describe('RunnerListEmptyState', () => {
let wrapper;
let glFeatures;
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findLinks = () => wrapper.findAllComponents(GlLink);
const findLink = () => wrapper.findComponent(GlLink);
const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal);
const createComponent = ({ props, mountFn = shallowMountExtended, ...options } = {}) => {
const expectTitleToBe = (title) => {
expect(findEmptyState().find('h1').text()).toBe(title);
};
const expectDescriptionToBe = (sentences) => {
expect(findEmptyState().find('p').text()).toMatchInterpolatedText(sentences.join(' '));
};
const createComponent = ({ props, mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerListEmptyState, {
propsData: {
registrationToken: mockRegistrationToken,
newRunnerPath,
...props,
},
directives: {
@ -30,109 +49,146 @@ describe('RunnerListEmptyState', () => {
stubs: {
GlEmptyState,
GlSprintf,
GlLink,
},
...options,
provide: { glFeatures },
});
};
beforeEach(() => {
glFeatures = null;
});
describe('when search is not filtered', () => {
const title = s__('Runners|Get started with runners');
describe('when there is a registration token', () => {
describe.each([
{ createRunnerWorkflowForAdmin: true },
{ createRunnerWorkflowForNamespace: true },
])('when createRunnerWorkflow is enabled by %o', (currentGlFeatures) => {
beforeEach(() => {
createComponent();
glFeatures = currentGlFeatures;
});
it('renders an illustration', () => {
expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL);
});
it('displays "no results" text with instructions', () => {
const desc = s__(
'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.',
);
expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`);
});
describe.each([
{ createRunnerWorkflowForAdmin: true },
{ createRunnerWorkflowForNamespace: true },
])('when %o', (glFeatures) => {
describe('when newRunnerPath is defined', () => {
beforeEach(() => {
createComponent({
provide: {
glFeatures,
},
});
});
it('shows a link to the new runner page', () => {
expect(findLink().attributes('href')).toBe(newRunnerPath);
});
});
describe('when newRunnerPath not defined', () => {
describe.each`
newRunnerPath | registrationToken | expectedMessages
${mockNewRunnerPath} | ${mockRegistrationToken} | ${[I18N_CREATE_RUNNER_LINK, I18N_STILL_USING_REGISTRATION_TOKENS]}
${mockNewRunnerPath} | ${null} | ${[I18N_CREATE_RUNNER_LINK]}
${null} | ${mockRegistrationToken} | ${[I18N_STILL_USING_REGISTRATION_TOKENS]}
${null} | ${null} | ${[I18N_CONTACT_ADMIN_TO_REGISTER]}
`(
'when newRunnerPath is $newRunnerPath and registrationToken is $registrationToken',
({ newRunnerPath, registrationToken, expectedMessages }) => {
beforeEach(() => {
createComponent({
props: {
newRunnerPath: null,
},
provide: {
glFeatures,
newRunnerPath,
registrationToken,
},
});
});
it('opens a runner registration instructions modal with a link', () => {
const { value } = getBinding(findLink().element, 'gl-modal');
expect(findRunnerInstructionsModal().props('modalId')).toEqual(value);
it('shows title', () => {
expectTitleToBe(I18N_GET_STARTED);
});
it('renders an illustration', () => {
expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL);
});
it(`shows description: "${expectedMessages.join(' ')}"`, () => {
expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, ...expectedMessages]);
});
},
);
describe('with newRunnerPath and registration token', () => {
beforeEach(() => {
createComponent({
props: {
registrationToken: mockRegistrationToken,
newRunnerPath: mockNewRunnerPath,
},
});
});
it('shows links to the new runner page and registration instructions', () => {
expect(findLinks().at(0).attributes('href')).toBe(mockNewRunnerPath);
const { value } = getBinding(findLinks().at(1).element, 'gl-modal');
expect(findRunnerInstructionsModal().props('modalId')).toEqual(value);
});
});
describe.each([
{ createRunnerWorkflowForAdmin: false },
{ createRunnerWorkflowForNamespace: false },
])('when %o', (glFeatures) => {
describe('with newRunnerPath and no registration token', () => {
beforeEach(() => {
createComponent({
provide: {
glFeatures,
props: {
registrationToken: mockRegistrationToken,
newRunnerPath: null,
},
});
});
it('opens a runner registration instructions modal with a link', () => {
const { value } = getBinding(findLink().element, 'gl-modal');
expect(findRunnerInstructionsModal().props('modalId')).toEqual(value);
});
});
describe('with no newRunnerPath nor registration token', () => {
beforeEach(() => {
createComponent({
props: {
registrationToken: null,
newRunnerPath: null,
},
});
});
it('has no link', () => {
expect(findLink().exists()).toBe(false);
});
});
});
describe('when there is no registration token', () => {
beforeEach(() => {
createComponent({ props: { registrationToken: null } });
describe('when createRunnerWorkflow is disabled', () => {
describe('when there is a registration token', () => {
beforeEach(() => {
createComponent({
props: {
registrationToken: mockRegistrationToken,
},
});
});
it('renders an illustration', () => {
expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL);
});
it('opens a runner registration instructions modal with a link', () => {
const { value } = getBinding(findLink().element, 'gl-modal');
expect(findRunnerInstructionsModal().props('modalId')).toEqual(value);
});
it('displays text with registration instructions', () => {
expectTitleToBe(I18N_GET_STARTED);
expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, I18N_FOLLOW_REGISTRATION_INSTRUCTIONS]);
});
});
it('renders an illustration', () => {
expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL);
});
describe('when there is no registration token', () => {
beforeEach(() => {
createComponent({ props: { registrationToken: null } });
});
it('displays "no results" text', () => {
const desc = s__(
'Runners|Runners are the agents that run your CI/CD jobs. To register new runners, please contact your administrator.',
);
it('displays "contact admin" text', () => {
expectTitleToBe(I18N_GET_STARTED);
expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`);
});
expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, I18N_CONTACT_ADMIN_TO_REGISTER]);
});
it('has no registration instructions link', () => {
expect(findLink().exists()).toBe(false);
it('has no registration instructions link', () => {
expect(findLink().exists()).toBe(false);
});
});
});
});
@ -147,8 +203,9 @@ describe('RunnerListEmptyState', () => {
});
it('displays "no filtered results" text', () => {
expect(findEmptyState().text()).toContain(s__('Runners|No results found'));
expect(findEmptyState().text()).toContain(s__('Runners|Edit your search and try again'));
expectTitleToBe(I18N_NO_RESULTS);
expectDescriptionToBe([I18N_EDIT_YOUR_SEARCH]);
});
});
});

View File

@ -61,7 +61,7 @@ describe('Commit references component', () => {
it('renders links to refs', () => {
const index = 0;
const refBadge = findTippingRefs().at(index);
const refUrl = `${refsListPropsMock.urlPart}${refsListPropsMock.tippingRefs[index]}`;
const refUrl = `${refsListPropsMock.urlPart}${refsListPropsMock.tippingRefs[index]}?ref_type=${refsListPropsMock.refType}`;
expect(refBadge.attributes('href')).toBe(refUrl);
});

View File

@ -289,4 +289,5 @@ export const refsListPropsMock = {
tippingRefs: tippingBranchesMock,
isLoading: false,
urlPart: '/some/project/-/commits/',
refType: 'heads',
};

View File

@ -51,6 +51,8 @@ RSpec.describe "GraphQL Pipeline Header", '(JavaScript fixtures)', type: :reques
)
end
let_it_be(:build) { create(:ci_build, :running, pipeline: pipeline, ref: 'master') }
it "graphql/pipelines/pipeline_header_running.json" do
query = get_graphql_query_as_string(query_path)
@ -59,4 +61,29 @@ RSpec.describe "GraphQL Pipeline Header", '(JavaScript fixtures)', type: :reques
expect_graphql_errors_to_be_empty
end
end
context 'with failed pipeline' do
let_it_be(:pipeline) do
create(
:ci_pipeline,
project: project,
sha: commit.id,
ref: 'master',
user: user,
status: :failed,
started_at: 1.hour.ago,
finished_at: Time.current
)
end
let_it_be(:build) { create(:ci_build, :canceled, pipeline: pipeline, ref: 'master') }
it "graphql/pipelines/pipeline_header_failed.json" do
query = get_graphql_query_as_string(query_path)
post_graphql(query, current_user: user, variables: { fullPath: project.full_path, iid: pipeline.iid })
expect_graphql_errors_to_be_empty
end
end
end

View File

@ -1,22 +1,37 @@
import { GlAlert, GlDropdown, GlButton, GlFormCheckbox, GlLoadingIcon } from '@gitlab/ui';
import { GlAlert, GlDropdown, GlButton, GlFormCheckbox, GlLoadingIcon, GlModal } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { stubComponent } from 'helpers/stub_component';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import Tracking from '~/tracking';
import { s__ } from '~/locale';
import { createAlert } from '~/alert';
import {
packageFiles as packageFilesMock,
packageFilesQuery,
packageDestroyFilesMutation,
packageDestroyFilesMutationError,
} from 'jest/packages_and_registries/package_registry/mock_data';
import {
DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT,
DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT,
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
DELETE_PACKAGE_FILES_ERROR_MESSAGE,
} from '~/packages_and_registries/package_registry/constants';
import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import getPackageFiles from '~/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql';
import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql';
Vue.use(VueApollo);
jest.mock('~/alert');
describe('Package Files', () => {
let wrapper;
@ -24,6 +39,7 @@ describe('Package Files', () => {
const findAllRows = () => wrapper.findAllByTestId('file-row');
const findDeleteSelectedButton = () => wrapper.findByTestId('delete-selected');
const findDeleteFilesModal = () => wrapper.findByTestId('delete-files-modal');
const findFirstRow = () => extendedWrapper(findAllRows().at(0));
const findSecondRow = () => extendedWrapper(findAllRows().at(1));
const findPackageFilesAlert = () => wrapper.findComponent(GlAlert);
@ -41,27 +57,39 @@ describe('Package Files', () => {
const files = packageFilesMock();
const [file] = files;
const showMock = jest.fn();
const eventCategory = 'UI::NpmPackages';
const createComponent = ({
packageId = '1',
packageType = 'NPM',
isLoading = false,
projectPath = 'gitlab-test',
canDelete = true,
stubs,
resolver = jest.fn().mockResolvedValue(packageFilesQuery([file])),
resolver = jest.fn().mockResolvedValue(packageFilesQuery({ files: [file] })),
filesDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFilesMutation()),
} = {}) => {
const requestHandlers = [[getPackageFiles, resolver]];
const requestHandlers = [
[getPackageFiles, resolver],
[destroyPackageFilesMutation, filesDeleteMutationResolver],
];
apolloProvider = createMockApollo(requestHandlers);
wrapper = mountExtended(PackageFiles, {
apolloProvider,
propsData: {
canDelete,
isLoading,
packageId,
packageType,
projectPath,
},
stubs: {
GlTable: false,
GlModal: stubComponent(GlModal, {
methods: {
show: showMock,
},
}),
...stubs,
},
});
@ -122,10 +150,16 @@ describe('Package Files', () => {
expect(findFirstRowDownloadLink().attributes('href')).toBe(file.downloadPath);
});
it('emits "download-file" event on click', () => {
it('tracks "download-file" event on click', () => {
const eventSpy = jest.spyOn(Tracking, 'event');
findFirstRowDownloadLink().vm.$emit('click');
expect(wrapper.emitted('download-file')).toEqual([[]]);
expect(eventSpy).toHaveBeenCalledWith(
eventCategory,
DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
expect.any(Object),
);
});
});
@ -179,12 +213,14 @@ describe('Package Files', () => {
expect(findActionMenuDelete().exists()).toBe(true);
});
it('emits a delete event when clicked', async () => {
it('shows delete file confirmation modal', async () => {
await findActionMenuDelete().trigger('click');
const [[items]] = wrapper.emitted('delete-files');
const [{ id }] = items;
expect(id).toBe(file.id);
expect(showMock).toHaveBeenCalledTimes(1);
expect(findDeleteFilesModal().text()).toBe(
'You are about to delete foo-1.0.1.tgz. This is a destructive action that may render your package unusable. Are you sure?',
);
});
});
});
@ -213,21 +249,6 @@ describe('Package Files', () => {
expect(findDeleteSelectedButton().props('disabled')).toBe(true);
});
it('delete selected button exists & is disabled when isLoading prop is true', async () => {
createComponent();
await waitForPromises();
const first = findAllRowCheckboxes().at(0);
await first.setChecked(true);
expect(findDeleteSelectedButton().props('disabled')).toBe(false);
await wrapper.setProps({ isLoading: true });
expect(findDeleteSelectedButton().props('disabled')).toBe(true);
expect(findLoadingIcon().exists()).toBe(true);
});
it('checkboxes to select file are visible', async () => {
createComponent({ resolver: jest.fn().mockResolvedValue(packageFilesQuery()) });
await waitForPromises();
@ -295,7 +316,7 @@ describe('Package Files', () => {
});
});
it('emits a delete event when selected', async () => {
it('shows delete modal with single file confirmation text when delete selected is clicked', async () => {
createComponent();
await waitForPromises();
@ -305,12 +326,14 @@ describe('Package Files', () => {
await findDeleteSelectedButton().trigger('click');
const [[items]] = wrapper.emitted('delete-files');
const [{ id }] = items;
expect(id).toBe(file.id);
expect(showMock).toHaveBeenCalledTimes(1);
expect(findDeleteFilesModal().text()).toBe(
'You are about to delete foo-1.0.1.tgz. This is a destructive action that may render your package unusable. Are you sure?',
);
});
it('emits delete event with both items when all are selected', async () => {
it('shows delete modal with multiple files confirmation text when delete selected is clicked', async () => {
createComponent({ resolver: jest.fn().mockResolvedValue(packageFilesQuery()) });
await waitForPromises();
@ -318,8 +341,63 @@ describe('Package Files', () => {
await findDeleteSelectedButton().trigger('click');
const [[items]] = wrapper.emitted('delete-files');
expect(items).toHaveLength(2);
expect(showMock).toHaveBeenCalledTimes(1);
expect(findDeleteFilesModal().text()).toMatchInterpolatedText(
'You are about to delete 2 assets. This operation is irreversible.',
);
});
describe('emits delete-all-files event', () => {
it('with right content for last file in package', async () => {
createComponent({
resolver: jest.fn().mockResolvedValue(
packageFilesQuery({
files: [file],
pageInfo: {
hasNextPage: false,
},
}),
),
});
await waitForPromises();
const first = findAllRowCheckboxes().at(0);
await first.setChecked(true);
await findDeleteSelectedButton().trigger('click');
expect(showMock).toHaveBeenCalledTimes(0);
expect(wrapper.emitted('delete-all-files')).toHaveLength(1);
expect(wrapper.emitted('delete-all-files')[0]).toEqual([
DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT,
]);
});
it('with right content for all files in package', async () => {
createComponent({
resolver: jest.fn().mockResolvedValue(
packageFilesQuery({
pageInfo: {
hasNextPage: false,
},
}),
),
});
await waitForPromises();
await findCheckAllCheckbox().setChecked(true);
await findDeleteSelectedButton().trigger('click');
expect(showMock).toHaveBeenCalledTimes(0);
expect(wrapper.emitted('delete-all-files')).toHaveLength(1);
expect(wrapper.emitted('delete-all-files')[0]).toEqual([
DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT,
]);
});
});
});
@ -343,6 +421,195 @@ describe('Package Files', () => {
});
});
describe('deleting a file', () => {
const doDeleteFile = async () => {
const first = findAllRowCheckboxes().at(0);
await first.setChecked(true);
await findDeleteSelectedButton().trigger('click');
findDeleteFilesModal().vm.$emit('primary');
};
it('confirming on the modal sets the loading state', async () => {
createComponent();
await waitForPromises();
await doDeleteFile();
await nextTick();
expect(findLoadingIcon().exists()).toBe(true);
});
it('confirming on the modal deletes the file and shows a success message', async () => {
const resolver = jest.fn().mockResolvedValue(packageFilesQuery({ files: [file] }));
const filesDeleteMutationResolver = jest
.fn()
.mockResolvedValue(packageDestroyFilesMutation());
createComponent({ resolver, filesDeleteMutationResolver });
await waitForPromises();
await doDeleteFile();
await waitForPromises();
expect(findLoadingIcon().exists()).toBe(false);
expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({
message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
}),
);
expect(filesDeleteMutationResolver).toHaveBeenCalledWith({
ids: [file.id],
projectPath: 'gitlab-test',
});
// we are re-fetching the package files, so we expect the resolver to have been called twice
expect(resolver).toHaveBeenCalledTimes(2);
expect(resolver).toHaveBeenCalledWith({
id: '1',
first: 100,
});
});
describe('errors', () => {
it('shows an error when the mutation request fails', async () => {
createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue() });
await waitForPromises();
await doDeleteFile();
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({
message: DELETE_PACKAGE_FILE_ERROR_MESSAGE,
}),
);
});
it('shows an error when the mutation request returns an error payload', async () => {
createComponent({
filesDeleteMutationResolver: jest
.fn()
.mockResolvedValue(packageDestroyFilesMutationError()),
});
await waitForPromises();
await doDeleteFile();
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({
message: DELETE_PACKAGE_FILE_ERROR_MESSAGE,
}),
);
});
});
});
describe('deleting multiple files', () => {
const doDeleteFiles = async () => {
await findCheckAllCheckbox().setChecked(true);
await findDeleteSelectedButton().trigger('click');
findDeleteFilesModal().vm.$emit('primary');
};
it('confirming on the modal sets the loading state', async () => {
createComponent();
await waitForPromises();
await doDeleteFiles();
await nextTick();
expect(findLoadingIcon().exists()).toBe(true);
});
it('confirming on the modal deletes the file and shows a success message', async () => {
const resolver = jest.fn().mockResolvedValue(packageFilesQuery());
const filesDeleteMutationResolver = jest
.fn()
.mockResolvedValue(packageDestroyFilesMutation());
createComponent({ resolver, filesDeleteMutationResolver });
await waitForPromises();
await doDeleteFiles();
await waitForPromises();
expect(findLoadingIcon().exists()).toBe(false);
expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({
message: DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
}),
);
expect(filesDeleteMutationResolver).toHaveBeenCalledWith({
ids: files.map(({ id }) => id),
projectPath: 'gitlab-test',
});
// we are re-fetching the package files, so we expect the resolver to have been called twice
expect(resolver).toHaveBeenCalledTimes(2);
expect(resolver).toHaveBeenCalledWith({
id: '1',
first: 100,
});
});
describe('errors', () => {
it('shows an error when the mutation request fails', async () => {
const resolver = jest.fn().mockResolvedValue(packageFilesQuery());
createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue(), resolver });
await waitForPromises();
await doDeleteFiles();
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({
message: DELETE_PACKAGE_FILES_ERROR_MESSAGE,
}),
);
});
it('shows an error when the mutation request returns an error payload', async () => {
const resolver = jest.fn().mockResolvedValue(packageFilesQuery());
createComponent({
filesDeleteMutationResolver: jest
.fn()
.mockResolvedValue(packageDestroyFilesMutationError()),
resolver,
});
await waitForPromises();
await doDeleteFiles();
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({
message: DELETE_PACKAGE_FILES_ERROR_MESSAGE,
}),
);
});
});
});
describe('additional details', () => {
describe('details toggle button', () => {
it('exists', async () => {
@ -357,7 +624,9 @@ describe('Package Files', () => {
noShaFile.fileSha256 = null;
noShaFile.fileMd5 = null;
noShaFile.fileSha1 = null;
createComponent({ resolver: jest.fn().mockResolvedValue(packageFilesQuery([noShaFile])) });
createComponent({
resolver: jest.fn().mockResolvedValue(packageFilesQuery({ files: [noShaFile] })),
});
await waitForPromises();
expect(findFirstToggleDetailsButton().exists()).toBe(false);
@ -410,7 +679,9 @@ describe('Package Files', () => {
const { ...missingMd5 } = file;
missingMd5.fileMd5 = null;
createComponent({ resolver: jest.fn().mockResolvedValue(packageFilesQuery([missingMd5])) });
createComponent({
resolver: jest.fn().mockResolvedValue(packageFilesQuery({ files: [missingMd5] })),
});
await waitForPromises();
await showShaFiles();

View File

@ -254,9 +254,6 @@ export const packageDetailsQuery = ({
__typename: 'PipelineConnection',
},
packageFiles: {
pageInfo: {
hasNextPage: true,
},
nodes: packageFiles().map(({ id, size }) => ({ id, size })),
__typename: 'PackageFileConnection',
},
@ -285,11 +282,15 @@ export const packagePipelinesQuery = (pipelines = packagePipelines()) => ({
},
});
export const packageFilesQuery = (files = packageFiles()) => ({
export const packageFilesQuery = ({ files = packageFiles(), pageInfo = {} } = {}) => ({
data: {
package: {
id: 'gid://gitlab/Packages::Package/111',
packageFiles: {
pageInfo: {
hasNextPage: true,
...pageInfo,
},
nodes: files,
__typename: 'PackageFileConnection',
},

View File

@ -21,10 +21,7 @@ import {
REQUEST_FORWARDING_HELP_PAGE_PATH,
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
PACKAGE_TYPE_COMPOSER,
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
DELETE_PACKAGE_FILES_ERROR_MESSAGE,
DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT,
PACKAGE_TYPE_NUGET,
PACKAGE_TYPE_MAVEN,
PACKAGE_TYPE_CONAN,
@ -32,7 +29,6 @@ import {
PACKAGE_TYPE_NPM,
} from '~/packages_and_registries/package_registry/constants';
import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql';
import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql';
import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql//queries/get_package_versions.query.graphql';
import {
@ -41,9 +37,6 @@ import {
packageVersions,
dependencyLinks,
emptyPackageDetailsQuery,
packageFiles,
packageDestroyFilesMutation,
packageDestroyFilesMutationError,
defaultPackageGroupSettings,
} from '../mock_data';
@ -74,13 +67,9 @@ describe('PackagesApp', () => {
function createComponent({
resolver = jest.fn().mockResolvedValue(packageDetailsQuery()),
filesDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFilesMutation()),
routeId = '1',
} = {}) {
const requestHandlers = [
[getPackageDetails, resolver],
[destroyPackageFilesMutation, filesDeleteMutationResolver],
];
const requestHandlers = [[getPackageDetails, resolver]];
apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMountExtended(PackagesApp, {
@ -117,8 +106,6 @@ describe('PackagesApp', () => {
const findDeleteModal = () => wrapper.findByTestId('delete-modal');
const findDeleteButton = () => wrapper.findByTestId('delete-package');
const findPackageFiles = () => wrapper.findComponent(PackageFiles);
const findDeleteFileModal = () => wrapper.findByTestId('delete-file-modal');
const findDeleteFilesModal = () => wrapper.findByTestId('delete-files-modal');
const findVersionsList = () => wrapper.findComponent(PackageVersionsList);
const findVersionsCountBadge = () => wrapper.findByTestId('other-versions-badge');
const findNoVersionsMessage = () => wrapper.findByTestId('no-versions-message');
@ -336,9 +323,9 @@ describe('PackagesApp', () => {
expect(findPackageFiles().props()).toMatchObject({
canDelete: packageData().canDestroy,
isLoading: false,
packageId: packageData().id,
packageType: packageData().packageType,
projectPath: 'gitlab-test',
});
});
@ -356,250 +343,26 @@ describe('PackagesApp', () => {
expect(findPackageFiles().exists()).toBe(false);
});
describe('deleting a file', () => {
const [fileToDelete] = packageFiles();
const doDeleteFile = () => {
findPackageFiles().vm.$emit('delete-files', [fileToDelete]);
findDeleteFileModal().vm.$emit('primary');
return waitForPromises();
};
it('opens delete file confirmation modal', async () => {
createComponent();
await waitForPromises();
findPackageFiles().vm.$emit('delete-files', [fileToDelete]);
expect(showMock).toHaveBeenCalledTimes(1);
await waitForPromises();
expect(findDeleteFileModal().text()).toBe(
'You are about to delete foo-1.0.1.tgz. This is a destructive action that may render your package unusable. Are you sure?',
);
});
it('when its the only file opens delete package confirmation modal', async () => {
const [packageFile] = packageFiles();
describe('emits delete-all-files event', () => {
it('opens the delete package confirmation modal and shows confirmation text', async () => {
const resolver = jest.fn().mockResolvedValue(
packageDetailsQuery({
extendPackage: {
packageFiles: {
pageInfo: {
hasNextPage: false,
},
nodes: [packageFile],
__typename: 'PackageFileConnection',
},
},
extendPackage: {},
packageSettings: {
...defaultPackageGroupSettings,
npmPackageRequestsForwarding: false,
},
}),
);
createComponent({
resolver,
});
await waitForPromises();
findPackageFiles().vm.$emit('delete-files', [fileToDelete]);
expect(showMock).toHaveBeenCalledTimes(1);
await waitForPromises();
expect(findDeleteModal().text()).toBe(
'Deleting the last package asset will remove version 1.0.0 of @gitlab-org/package-15. Are you sure?',
);
});
it('confirming on the modal sets the loading state', async () => {
createComponent();
await waitForPromises();
findPackageFiles().vm.$emit('delete-files', [fileToDelete]);
findDeleteFileModal().vm.$emit('primary');
await nextTick();
expect(findPackageFiles().props('isLoading')).toEqual(true);
});
it('confirming on the modal deletes the file and shows a success message', async () => {
const resolver = jest.fn().mockResolvedValue(packageDetailsQuery());
createComponent({ resolver });
await waitForPromises();
await doDeleteFile();
expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({
message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
}),
);
// we are re-fetching the package details, so we expect the resolver to have been called twice
expect(resolver).toHaveBeenCalledTimes(2);
});
describe('errors', () => {
it('shows an error when the mutation request fails', async () => {
createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue() });
await waitForPromises();
await doDeleteFile();
expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({
message: DELETE_PACKAGE_FILE_ERROR_MESSAGE,
}),
);
});
it('shows an error when the mutation request returns an error payload', async () => {
createComponent({
filesDeleteMutationResolver: jest
.fn()
.mockResolvedValue(packageDestroyFilesMutationError()),
});
await waitForPromises();
await doDeleteFile();
expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({
message: DELETE_PACKAGE_FILE_ERROR_MESSAGE,
}),
);
});
});
});
describe('deleting multiple files', () => {
const doDeleteFiles = () => {
findPackageFiles().vm.$emit('delete-files', packageFiles());
findDeleteFilesModal().vm.$emit('primary');
return waitForPromises();
};
it('opens delete files confirmation modal', async () => {
createComponent();
await waitForPromises();
const showDeleteFilesSpy = jest.spyOn(wrapper.vm.$refs.deleteFilesModal, 'show');
findPackageFiles().vm.$emit('delete-files', packageFiles());
expect(showDeleteFilesSpy).toHaveBeenCalled();
});
it('confirming on the modal sets the loading state', async () => {
createComponent();
await waitForPromises();
findPackageFiles().vm.$emit('delete-files', packageFiles());
findDeleteFilesModal().vm.$emit('primary');
await nextTick();
expect(findPackageFiles().props('isLoading')).toEqual(true);
});
it('confirming on the modal deletes the file and shows a success message', async () => {
const resolver = jest.fn().mockResolvedValue(packageDetailsQuery());
createComponent({ resolver });
await waitForPromises();
await doDeleteFiles();
expect(resolver).toHaveBeenCalledTimes(2);
expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({
message: DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
}),
);
// we are re-fetching the package details, so we expect the resolver to have been called twice
expect(resolver).toHaveBeenCalledTimes(2);
});
describe('errors', () => {
it('shows an error when the mutation request fails', async () => {
createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue() });
await waitForPromises();
await doDeleteFiles();
expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({
message: DELETE_PACKAGE_FILES_ERROR_MESSAGE,
}),
);
});
it('shows an error when the mutation request returns an error payload', async () => {
createComponent({
filesDeleteMutationResolver: jest
.fn()
.mockResolvedValue(packageDestroyFilesMutationError()),
});
await waitForPromises();
await doDeleteFiles();
expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({
message: DELETE_PACKAGE_FILES_ERROR_MESSAGE,
}),
);
});
});
});
describe('deleting all files', () => {
it('opens the delete package confirmation modal', async () => {
const resolver = jest.fn().mockResolvedValue(
packageDetailsQuery({
extendPackage: {
packageFiles: {
pageInfo: {
hasNextPage: false,
},
nodes: packageFiles(),
},
},
packageSettings: {
...defaultPackageGroupSettings,
npmPackageRequestsForwarding: false,
},
}),
);
createComponent({
resolver,
});
await waitForPromises();
findPackageFiles().vm.$emit('delete-files', packageFiles());
findPackageFiles().vm.$emit('delete-all-files', DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT);
expect(showMock).toHaveBeenCalledTimes(1);
await waitForPromises();
await nextTick();
expect(findDeleteModal().text()).toBe(
'Deleting all package assets will remove version 1.0.0 of @gitlab-org/package-15. Are you sure?',

View File

@ -1,5 +1,6 @@
import pipelineHeaderSuccess from 'test_fixtures/graphql/pipelines/pipeline_header_success.json';
import pipelineHeaderRunning from 'test_fixtures/graphql/pipelines/pipeline_header_running.json';
import pipelineHeaderFailed from 'test_fixtures/graphql/pipelines/pipeline_header_failed.json';
const PIPELINE_RUNNING = 'RUNNING';
const PIPELINE_CANCELED = 'CANCELED';
@ -8,7 +9,31 @@ const PIPELINE_FAILED = 'FAILED';
const threeWeeksAgo = new Date();
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
export { pipelineHeaderSuccess, pipelineHeaderRunning };
export { pipelineHeaderSuccess, pipelineHeaderRunning, pipelineHeaderFailed };
export const pipelineRetryMutationResponseSuccess = {
data: { pipelineRetry: { errors: [] } },
};
export const pipelineRetryMutationResponseFailed = {
data: { pipelineRetry: { errors: ['error'] } },
};
export const pipelineCancelMutationResponseSuccess = {
data: { pipelineRetry: { errors: [] } },
};
export const pipelineCancelMutationResponseFailed = {
data: { pipelineRetry: { errors: ['error'] } },
};
export const pipelineDeleteMutationResponseSuccess = {
data: { pipelineRetry: { errors: [] } },
};
export const pipelineDeleteMutationResponseFailed = {
data: { pipelineRetry: { errors: ['error'] } },
};
export const mockPipelineHeader = {
detailedStatus: {},

View File

@ -1,23 +1,59 @@
import { GlBadge, GlLoadingIcon } from '@gitlab/ui';
import Vue from 'vue';
import { GlAlert, GlBadge, GlLoadingIcon, GlModal } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import PipelineDetailsHeader from '~/pipelines/components/pipeline_details_header.vue';
import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/pipelines/constants';
import TimeAgo from '~/pipelines/components/pipelines_list/time_ago.vue';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import cancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql';
import deletePipelineMutation from '~/pipelines/graphql/mutations/delete_pipeline.mutation.graphql';
import retryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql';
import getPipelineDetailsQuery from '~/pipelines/graphql/queries/get_pipeline_header_data.query.graphql';
import { pipelineHeaderSuccess, pipelineHeaderRunning } from './mock_data';
import {
pipelineHeaderSuccess,
pipelineHeaderRunning,
pipelineHeaderFailed,
pipelineRetryMutationResponseSuccess,
pipelineCancelMutationResponseSuccess,
pipelineDeleteMutationResponseSuccess,
pipelineRetryMutationResponseFailed,
pipelineCancelMutationResponseFailed,
pipelineDeleteMutationResponseFailed,
} from './mock_data';
Vue.use(VueApollo);
describe('Pipeline details header', () => {
let wrapper;
let glModalDirective;
const successHandler = jest.fn().mockResolvedValue(pipelineHeaderSuccess);
const runningHandler = jest.fn().mockResolvedValue(pipelineHeaderRunning);
const failedHandler = jest.fn().mockResolvedValue(pipelineHeaderFailed);
const retryMutationHandlerSuccess = jest
.fn()
.mockResolvedValue(pipelineRetryMutationResponseSuccess);
const cancelMutationHandlerSuccess = jest
.fn()
.mockResolvedValue(pipelineCancelMutationResponseSuccess);
const deleteMutationHandlerSuccess = jest
.fn()
.mockResolvedValue(pipelineDeleteMutationResponseSuccess);
const retryMutationHandlerFailed = jest
.fn()
.mockResolvedValue(pipelineRetryMutationResponseFailed);
const cancelMutationHandlerFailed = jest
.fn()
.mockResolvedValue(pipelineCancelMutationResponseFailed);
const deleteMutationHandlerFailed = jest
.fn()
.mockResolvedValue(pipelineDeleteMutationResponseFailed);
const findAlert = () => wrapper.findComponent(GlAlert);
const findStatus = () => wrapper.findComponent(CiBadgeLink);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findTimeAgo = () => wrapper.findComponent(TimeAgo);
@ -28,6 +64,10 @@ describe('Pipeline details header', () => {
const findCommitLink = () => wrapper.findByTestId('commit-link');
const findPipelineRunningText = () => wrapper.findByTestId('pipeline-running-text').text();
const findPipelineRefText = () => wrapper.findByTestId('pipeline-ref-text').text();
const findRetryButton = () => wrapper.findByTestId('retry-pipeline');
const findCancelButton = () => wrapper.findByTestId('cancel-pipeline');
const findDeleteButton = () => wrapper.findByTestId('delete-pipeline');
const findDeleteModal = () => wrapper.findComponent(GlModal);
const defaultHandlers = [[getPipelineDetailsQuery, successHandler]];
@ -58,7 +98,7 @@ describe('Pipeline details header', () => {
stuck: false,
},
refText:
'For merge request <a class="mr-iid" href="/root/ci-project/-/merge_requests/1">!1</a> to merge <a class="ref-name" href="/root/ci-project/-/commits/test">test</a>',
'Related merge request <a class="mr-iid" href="/root/ci-project/-/merge_requests/1">!1</a> to merge <a class="ref-name" href="/root/ci-project/-/commits/test">test</a>',
};
const createMockApolloProvider = (handlers) => {
@ -66,6 +106,8 @@ describe('Pipeline details header', () => {
};
const createComponent = (handlers = defaultHandlers, props = defaultProps) => {
glModalDirective = jest.fn();
wrapper = shallowMountExtended(PipelineDetailsHeader, {
provide: {
...defaultProvideOptions,
@ -73,6 +115,13 @@ describe('Pipeline details header', () => {
propsData: {
...props,
},
directives: {
glModal: {
bind(_, { value }) {
glModalDirective(value);
},
},
},
apolloProvider: createMockApolloProvider(handlers),
});
};
@ -125,7 +174,7 @@ describe('Pipeline details header', () => {
});
it('displays ref text', () => {
expect(findPipelineRefText()).toBe('For merge request !1 to merge test');
expect(findPipelineRefText()).toBe('Related merge request !1 to merge test');
});
});
@ -164,4 +213,155 @@ describe('Pipeline details header', () => {
expect(findPipelineRunningText()).toBe('In progress, queued for 3600 seconds');
});
});
describe('actions', () => {
describe('retry action', () => {
beforeEach(async () => {
createComponent([
[getPipelineDetailsQuery, failedHandler],
[retryPipelineMutation, retryMutationHandlerSuccess],
]);
await waitForPromises();
});
it('should call retryPipeline Mutation with pipeline id', () => {
findRetryButton().vm.$emit('click');
expect(retryMutationHandlerSuccess).toHaveBeenCalledWith({
id: pipelineHeaderFailed.data.project.pipeline.id,
});
expect(findAlert().exists()).toBe(false);
});
it('should render retry action tooltip', () => {
expect(findRetryButton().attributes('title')).toBe(BUTTON_TOOLTIP_RETRY);
});
});
describe('retry action failed', () => {
beforeEach(async () => {
createComponent([
[getPipelineDetailsQuery, failedHandler],
[retryPipelineMutation, retryMutationHandlerFailed],
]);
await waitForPromises();
});
it('should display error message on failure', async () => {
findRetryButton().vm.$emit('click');
await waitForPromises();
expect(findAlert().exists()).toBe(true);
});
it('retry button loading state should reset on error', async () => {
findRetryButton().vm.$emit('click');
await nextTick();
expect(findRetryButton().props('loading')).toBe(true);
await waitForPromises();
expect(findRetryButton().props('loading')).toBe(false);
});
});
describe('cancel action', () => {
it('should call cancelPipeline Mutation with pipeline id', async () => {
createComponent([
[getPipelineDetailsQuery, runningHandler],
[cancelPipelineMutation, cancelMutationHandlerSuccess],
]);
await waitForPromises();
findCancelButton().vm.$emit('click');
expect(cancelMutationHandlerSuccess).toHaveBeenCalledWith({
id: pipelineHeaderRunning.data.project.pipeline.id,
});
expect(findAlert().exists()).toBe(false);
});
it('should render cancel action tooltip', async () => {
createComponent([
[getPipelineDetailsQuery, runningHandler],
[cancelPipelineMutation, cancelMutationHandlerSuccess],
]);
await waitForPromises();
expect(findCancelButton().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL);
});
it('should display error message on failure', async () => {
createComponent([
[getPipelineDetailsQuery, runningHandler],
[cancelPipelineMutation, cancelMutationHandlerFailed],
]);
await waitForPromises();
findCancelButton().vm.$emit('click');
await waitForPromises();
expect(findAlert().exists()).toBe(true);
});
});
describe('delete action', () => {
it('displays delete modal when clicking on delete and does not call the delete action', async () => {
createComponent([
[getPipelineDetailsQuery, successHandler],
[deletePipelineMutation, deleteMutationHandlerSuccess],
]);
await waitForPromises();
findDeleteButton().vm.$emit('click');
const modalId = 'pipeline-delete-modal';
expect(findDeleteModal().props('modalId')).toBe(modalId);
expect(glModalDirective).toHaveBeenCalledWith(modalId);
expect(deleteMutationHandlerSuccess).not.toHaveBeenCalled();
expect(findAlert().exists()).toBe(false);
});
it('should call deletePipeline Mutation with pipeline id when modal is submitted', async () => {
createComponent([
[getPipelineDetailsQuery, successHandler],
[deletePipelineMutation, deleteMutationHandlerSuccess],
]);
await waitForPromises();
findDeleteModal().vm.$emit('primary');
expect(deleteMutationHandlerSuccess).toHaveBeenCalledWith({
id: pipelineHeaderSuccess.data.project.pipeline.id,
});
});
it('should display error message on failure', async () => {
createComponent([
[getPipelineDetailsQuery, successHandler],
[deletePipelineMutation, deleteMutationHandlerFailed],
]);
await waitForPromises();
findDeleteModal().vm.$emit('primary');
await waitForPromises();
expect(findAlert().exists()).toBe(true);
});
});
});
});

View File

@ -1,9 +1,11 @@
import { nextTick } from 'vue';
import { GlAlert, GlDropdown, GlSprintf, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { stubComponent } from 'helpers/stub_component';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import PipelineMultiActions, {
@ -14,6 +16,7 @@ import { TRACKING_CATEGORIES } from '~/pipelines/constants';
describe('Pipeline Multi Actions Dropdown', () => {
let wrapper;
let mockAxios;
const focusInputMock = jest.fn();
const artifacts = [
{
@ -30,7 +33,7 @@ describe('Pipeline Multi Actions Dropdown', () => {
const artifactsEndpoint = `endpoint/${artifactsEndpointPlaceholder}/artifacts.json`;
const pipelineId = 108;
const createComponent = ({ mockData = {} } = {}) => {
const createComponent = () => {
wrapper = extendedWrapper(
shallowMount(PipelineMultiActions, {
provide: {
@ -40,14 +43,12 @@ describe('Pipeline Multi Actions Dropdown', () => {
propsData: {
pipelineId,
},
data() {
return {
...mockData,
};
},
stubs: {
GlSprintf,
GlDropdown,
GlSearchBoxByType: stubComponent(GlSearchBoxByType, {
methods: { focusInput: focusInputMock },
}),
},
}),
);
@ -76,70 +77,91 @@ describe('Pipeline Multi Actions Dropdown', () => {
});
describe('Artifacts', () => {
it('should fetch artifacts and show search box on dropdown click', async () => {
const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId);
mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts });
createComponent();
findDropdown().vm.$emit('show');
await waitForPromises();
expect(mockAxios.history.get).toHaveLength(1);
expect(wrapper.vm.artifacts).toEqual(artifacts);
expect(findSearchBox().exists()).toBe(true);
});
it('should focus the search box when opened with artifacts', () => {
createComponent({ mockData: { artifacts } });
wrapper.vm.$refs.searchInput.focusInput = jest.fn();
findDropdown().vm.$emit('shown');
expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled();
});
it('should render all the provided artifacts when search query is empty', () => {
const searchQuery = '';
createComponent({ mockData: { searchQuery, artifacts } });
expect(findAllArtifactItems()).toHaveLength(artifacts.length);
expect(findEmptyMessage().exists()).toBe(false);
});
it('should render filtered artifacts when search query is not empty', () => {
const searchQuery = 'job-2';
createComponent({ mockData: { searchQuery, artifacts } });
expect(findAllArtifactItems()).toHaveLength(1);
expect(findEmptyMessage().exists()).toBe(false);
});
it('should render the correct artifact name and path', () => {
createComponent({ mockData: { artifacts } });
expect(findFirstArtifactItem().attributes('href')).toBe(artifacts[0].path);
expect(findFirstArtifactItem().text()).toBe(artifacts[0].name);
});
it('should render empty message and no search box when no artifacts are found', () => {
createComponent({ mockData: { artifacts: [] } });
expect(findEmptyMessage().exists()).toBe(true);
expect(findSearchBox().exists()).toBe(false);
});
const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId);
describe('while loading artifacts', () => {
it('should render a loading spinner and no empty message', () => {
createComponent({ mockData: { isLoading: true, artifacts: [] } });
beforeEach(() => {
mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts });
});
it('should render a loading spinner and no empty message', async () => {
createComponent();
findDropdown().vm.$emit('show');
await nextTick();
expect(findLoadingIcon().exists()).toBe(true);
expect(findEmptyMessage().exists()).toBe(false);
});
});
describe('artifacts loaded successfully', () => {
describe('artifacts exist', () => {
beforeEach(async () => {
mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts });
createComponent();
findDropdown().vm.$emit('show');
await waitForPromises();
});
it('should fetch artifacts and show search box on dropdown click', () => {
expect(mockAxios.history.get).toHaveLength(1);
expect(findSearchBox().exists()).toBe(true);
});
it('should focus the search box when opened with artifacts', () => {
findDropdown().vm.$emit('shown');
expect(focusInputMock).toHaveBeenCalled();
});
it('should render all the provided artifacts when search query is empty', () => {
findSearchBox().vm.$emit('input', '');
expect(findAllArtifactItems()).toHaveLength(artifacts.length);
expect(findEmptyMessage().exists()).toBe(false);
});
it('should render filtered artifacts when search query is not empty', async () => {
findSearchBox().vm.$emit('input', 'job-2');
await waitForPromises();
expect(findAllArtifactItems()).toHaveLength(1);
expect(findEmptyMessage().exists()).toBe(false);
});
it('should render the correct artifact name and path', () => {
expect(findFirstArtifactItem().attributes('href')).toBe(artifacts[0].path);
expect(findFirstArtifactItem().text()).toBe(artifacts[0].name);
});
});
describe('artifacts list is empty', () => {
beforeEach(() => {
mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts: [] });
});
it('should render empty message and no search box when no artifacts are found', async () => {
createComponent();
findDropdown().vm.$emit('show');
await waitForPromises();
expect(findEmptyMessage().exists()).toBe(true);
expect(findSearchBox().exists()).toBe(false);
expect(findLoadingIcon().exists()).toBe(false);
});
});
});
describe('with a failing request', () => {
it('should render an error message', async () => {
const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId);
beforeEach(() => {
mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
});
it('should render an error message', async () => {
createComponent();
findDropdown().vm.$emit('show');
await waitForPromises();

View File

@ -1,4 +1,4 @@
import { GlLink } from '@gitlab/ui';
import { GlBadge } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
@ -46,6 +46,13 @@ describe('CI Badge Link Component', () => {
icon: 'status_pending',
details_path: 'status/pending',
},
preparing: {
text: 'preparing',
label: 'preparing',
group: 'preparing',
icon: 'status_preparing',
details_path: 'status/preparing',
},
running: {
text: 'running',
label: 'running',
@ -53,6 +60,13 @@ describe('CI Badge Link Component', () => {
icon: 'status_running',
details_path: 'status/running',
},
scheduled: {
text: 'scheduled',
label: 'scheduled',
group: 'scheduled',
icon: 'status_scheduled',
details_path: 'status/scheduled',
},
skipped: {
text: 'skipped',
label: 'skipped',
@ -61,8 +75,8 @@ describe('CI Badge Link Component', () => {
details_path: 'status/skipped',
},
success_warining: {
text: 'passed',
label: 'passed',
text: 'warning',
label: 'passed with warnings',
group: 'success-with-warnings',
icon: 'status_warning',
details_path: 'status/warning',
@ -77,6 +91,8 @@ describe('CI Badge Link Component', () => {
};
const findIcon = () => wrapper.findComponent(CiIcon);
const findBadge = () => wrapper.findComponent(GlBadge);
const findBadgeText = () => wrapper.find('[data-testid="ci-badge-text"');
const createComponent = (propsData) => {
wrapper = shallowMount(CiBadgeLink, { propsData });
@ -87,22 +103,50 @@ describe('CI Badge Link Component', () => {
expect(wrapper.attributes('href')).toBe(statuses[status].details_path);
expect(wrapper.text()).toBe(statuses[status].text);
expect(wrapper.classes()).toContain('ci-status');
expect(wrapper.classes()).toContain(`ci-${statuses[status].group}`);
expect(findBadge().props('size')).toBe('md');
expect(findIcon().exists()).toBe(true);
});
it.each`
status | textColor | variant
${statuses.success} | ${'gl-text-green-700'} | ${'success'}
${statuses.success_warining} | ${'gl-text-orange-700'} | ${'warning'}
${statuses.failed} | ${'gl-text-red-700'} | ${'danger'}
${statuses.running} | ${'gl-text-blue-700'} | ${'info'}
${statuses.pending} | ${'gl-text-orange-700'} | ${'warning'}
${statuses.preparing} | ${'gl-text-gray-600'} | ${'muted'}
${statuses.canceled} | ${'gl-text-gray-700'} | ${'neutral'}
${statuses.scheduled} | ${'gl-text-gray-600'} | ${'muted'}
${statuses.skipped} | ${'gl-text-gray-600'} | ${'muted'}
${statuses.manual} | ${'gl-text-gray-700'} | ${'neutral'}
${statuses.created} | ${'gl-text-gray-600'} | ${'muted'}
`(
'should contain correct badge class and variant for status: $status.text',
({ status, textColor, variant }) => {
createComponent({ status });
expect(findBadgeText().classes()).toContain(textColor);
expect(findBadge().props('variant')).toBe(variant);
},
);
it('should not render label', () => {
createComponent({ status: statuses.canceled, showText: false });
expect(wrapper.text()).toBe('');
});
it('should emit ciStatusBadgeClick event', async () => {
it('should emit ciStatusBadgeClick event', () => {
createComponent({ status: statuses.success });
await wrapper.findComponent(GlLink).vm.$emit('click');
findBadge().vm.$emit('click');
expect(wrapper.emitted('ciStatusBadgeClick')).toEqual([[]]);
});
it('should render dynamic badge size', () => {
createComponent({ status: statuses.success, badgeSize: 'lg' });
expect(findBadge().props('size')).toBe('lg');
});
});

View File

@ -13,6 +13,6 @@ RSpec.describe GitlabSchema.types['CiRunnerManager'], feature_category: :runner_
runner status system_id version
]
expect(described_class).to have_graphql_fields(*expected_fields)
expect(described_class).to include_graphql_fields(*expected_fields)
end
end

View File

@ -370,7 +370,7 @@ RSpec.describe Gitlab::Ci::Status::Build::Factory do
end
it 'fabricates status with correct details' do
expect(status.text).to eq s_('CiStatusText|delayed')
expect(status.text).to eq s_('CiStatusText|scheduled')
expect(status.group).to eq 'scheduled'
expect(status.icon).to eq 'status_scheduled'
expect(status.favicon).to eq 'favicon_status_scheduled'

View File

@ -2,17 +2,17 @@
require 'spec_helper'
RSpec.describe Gitlab::Ci::Status::Scheduled do
RSpec.describe Gitlab::Ci::Status::Scheduled, feature_category: :continuous_integration do
subject do
described_class.new(double('subject'), double('user'))
end
describe '#text' do
it { expect(subject.text).to eq 'delayed' }
it { expect(subject.text).to eq 'scheduled' }
end
describe '#label' do
it { expect(subject.label).to eq 'delayed' }
it { expect(subject.label).to eq 'scheduled' }
end
describe '#icon' do

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Gitlab::Ci::Status::SuccessWarning do
RSpec.describe Gitlab::Ci::Status::SuccessWarning, feature_category: :continuous_integration do
let(:status) { double('status') }
subject do
@ -10,7 +10,7 @@ RSpec.describe Gitlab::Ci::Status::SuccessWarning do
end
describe '#test' do
it { expect(subject.text).to eq 'passed' }
it { expect(subject.text).to eq 'warning' }
end
describe '#label' do

View File

@ -38,8 +38,8 @@ RSpec.describe Integrations::ChatMessage::PushMessage do
context 'without markdown' do
it 'returns a message regarding pushes' do
expect(subject.pretext).to eq(
'test.user pushed to branch <http://url.com/commits/master|master> of '\
'<http://url.com|project_name> (<http://url.com/compare/before...after|Compare changes>)')
'test.user pushed to branch <http://url.com/-/commits/master|master> of '\
'<http://url.com|project_name> (<http://url.com/-/compare/before...after|Compare changes>)')
expect(subject.attachments).to eq([{
text: "<http://url1.com|abcdefgh>: message1 - author1\n\n"\
"<http://url2.com|12345678>: message2 w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w ... - author2",
@ -55,13 +55,13 @@ RSpec.describe Integrations::ChatMessage::PushMessage do
it 'returns a message regarding pushes' do
expect(subject.pretext).to eq(
'test.user pushed to branch [master](http://url.com/commits/master) of [project_name](http://url.com) ([Compare changes](http://url.com/compare/before...after))')
'test.user pushed to branch [master](http://url.com/-/commits/master) of [project_name](http://url.com) ([Compare changes](http://url.com/-/compare/before...after))')
expect(subject.attachments).to eq(
"[abcdefgh](http://url1.com): message1 - author1\n\n[12345678](http://url2.com): message2 w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w w ... - author2")
expect(subject.activity).to eq(
title: 'test.user pushed to branch [master](http://url.com/commits/master)',
title: 'test.user pushed to branch [master](http://url.com/-/commits/master)',
subtitle: 'in [project_name](http://url.com)',
text: '[Compare changes](http://url.com/compare/before...after)',
text: '[Compare changes](http://url.com/-/compare/before...after)',
image: 'http://someavatar.com'
)
end
@ -102,7 +102,7 @@ RSpec.describe Integrations::ChatMessage::PushMessage do
expect(subject.activity).to eq(
title: 'test.user pushed new tag [new_tag](http://url.com/-/tags/new_tag)',
subtitle: 'in [project_name](http://url.com)',
text: '[Compare changes](http://url.com/compare/0000000000000000000000000000000000000000...after)',
text: '[Compare changes](http://url.com/-/compare/0000000000000000000000000000000000000000...after)',
image: 'http://someavatar.com'
)
end
@ -143,7 +143,7 @@ RSpec.describe Integrations::ChatMessage::PushMessage do
expect(subject.activity).to eq(
title: 'test.user removed tag new_tag',
subtitle: 'in [project_name](http://url.com)',
text: '[Compare changes](http://url.com/compare/before...0000000000000000000000000000000000000000)',
text: '[Compare changes](http://url.com/-/compare/before...0000000000000000000000000000000000000000)',
image: 'http://someavatar.com'
)
end
@ -158,7 +158,7 @@ RSpec.describe Integrations::ChatMessage::PushMessage do
context 'without markdown' do
it 'returns a message regarding a new branch' do
expect(subject.pretext).to eq(
'test.user pushed new branch <http://url.com/commits/master|master> to '\
'test.user pushed new branch <http://url.com/-/commits/master|master> to '\
'<http://url.com|project_name>')
expect(subject.attachments).to be_empty
end
@ -171,12 +171,12 @@ RSpec.describe Integrations::ChatMessage::PushMessage do
it 'returns a message regarding a new branch' do
expect(subject.pretext).to eq(
'test.user pushed new branch [master](http://url.com/commits/master) to [project_name](http://url.com)')
'test.user pushed new branch [master](http://url.com/-/commits/master) to [project_name](http://url.com)')
expect(subject.attachments).to be_empty
expect(subject.activity).to eq(
title: 'test.user pushed new branch [master](http://url.com/commits/master)',
title: 'test.user pushed new branch [master](http://url.com/-/commits/master)',
subtitle: 'in [project_name](http://url.com)',
text: '[Compare changes](http://url.com/compare/0000000000000000000000000000000000000000...after)',
text: '[Compare changes](http://url.com/-/compare/0000000000000000000000000000000000000000...after)',
image: 'http://someavatar.com'
)
end
@ -208,7 +208,7 @@ RSpec.describe Integrations::ChatMessage::PushMessage do
expect(subject.activity).to eq(
title: 'test.user removed branch master',
subtitle: 'in [project_name](http://url.com)',
text: '[Compare changes](http://url.com/compare/before...0000000000000000000000000000000000000000)',
text: '[Compare changes](http://url.com/-/compare/before...0000000000000000000000000000000000000000)',
image: 'http://someavatar.com'
)
end

View File

@ -52,7 +52,7 @@ RSpec.describe Integrations::Discord do
subject.execute(sample_data)
expect(builder.to_json_hash[:embeds].first).to include(
description: start_with("#{user.name} pushed to branch [master](http://localhost/#{project.namespace.path}/#{project.path}/commits/master) of"),
description: start_with("#{user.name} pushed to branch [master](http://localhost/#{project.namespace.path}/#{project.path}/-/commits/master) of"),
author: hash_including(
icon_url: start_with('https://www.gravatar.com/avatar/'),
name: user.name

View File

@ -217,7 +217,7 @@ RSpec.describe Ci::PipelinePresenter do
let(:pipeline) { merge_request.all_pipelines.last }
it 'returns a correct ref text' do
is_expected.to eq("For merge request <a class=\"mr-iid\" href=\"#{project_merge_request_path(merge_request.project, merge_request)}\">#{merge_request.to_reference}</a> " \
is_expected.to eq("Related merge request <a class=\"mr-iid\" href=\"#{project_merge_request_path(merge_request.project, merge_request)}\">#{merge_request.to_reference}</a> " \
"to merge <a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{project_commits_path(merge_request.source_project, merge_request.source_branch)}\">#{merge_request.source_branch}</a>")
end
end
@ -227,7 +227,7 @@ RSpec.describe Ci::PipelinePresenter do
let(:pipeline) { merge_request.all_pipelines.last }
it 'returns a correct ref text' do
is_expected.to eq("For merge request <a class=\"mr-iid\" href=\"#{project_merge_request_path(merge_request.project, merge_request)}\">#{merge_request.to_reference}</a> " \
is_expected.to eq("Related merge request <a class=\"mr-iid\" href=\"#{project_merge_request_path(merge_request.project, merge_request)}\">#{merge_request.to_reference}</a> " \
"to merge <a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{project_commits_path(merge_request.source_project, merge_request.source_branch)}\">#{merge_request.source_branch}</a> " \
"into <a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{project_commits_path(merge_request.target_project, merge_request.target_branch)}\">#{merge_request.target_branch}</a>")
end

View File

@ -433,8 +433,6 @@ RSpec.describe 'Query.project.pipeline', feature_category: :continuous_integrati
end
it 'does not generate N+1 queries', :request_store, :use_sql_query_cache do
admin2 = create(:admin)
control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
post_graphql(query, current_user: admin)
end
@ -442,7 +440,7 @@ RSpec.describe 'Query.project.pipeline', feature_category: :continuous_integrati
runner_manager2 = create(:ci_runner_machine)
create(:ci_build, pipeline: pipeline, name: 'my test job2', runner_manager: runner_manager2)
expect { post_graphql(query, current_user: admin2) }.not_to exceed_all_query_limit(control)
expect { post_graphql(query, current_user: admin) }.not_to exceed_all_query_limit(control)
end
end