diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-14 18:12:38 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-14 18:12:38 +0000 |
commit | 283c7bb302510ed1fc55f0d333c484ce7fa781fd (patch) | |
tree | adbb03e34cc91f339395f6417502c075ee84b8bb | |
parent | a2b7b398c7855bccee5d2f0f9a021b2efea0838e (diff) | |
download | gitlab-ce-283c7bb302510ed1fc55f0d333c484ce7fa781fd.tar.gz |
Add latest changes from gitlab-org/gitlab@master
65 files changed, 1352 insertions, 777 deletions
diff --git a/.rubocop_todo/rake/require.yml b/.rubocop_todo/rake/require.yml index 1f7a488ba35..d098c28bf80 100644 --- a/.rubocop_todo/rake/require.yml +++ b/.rubocop_todo/rake/require.yml @@ -3,7 +3,6 @@ Rake/Require: Details: grace period Exclude: - 'lib/tasks/gitlab/assets.rake' - - 'lib/tasks/gitlab/cleanup.rake' - 'lib/tasks/gitlab/dependency_proxy/migrate.rake' - 'lib/tasks/gitlab/docs/redirect.rake' - 'lib/tasks/gitlab/graphql.rake' diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue index 1d6a06a08dc..16034cce381 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue @@ -130,9 +130,6 @@ export default { displayMaskedError() { return !this.canMask && this.variable.masked; }, - isUsingRawRegexFlag() { - return this.glFeatures.ciRemoveCharacterLimitationRawMaskedVar; - }, isEditing() { return this.mode === EDIT_VARIABLE_ACTION; }, @@ -177,7 +174,7 @@ export default { return true; }, useRawMaskableRegexp() { - return this.isRaw && this.isUsingRawRegexFlag; + return this.isRaw; }, variableValidationFeedback() { return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue index e36a7864878..525094271d9 100644 --- a/app/assets/javascripts/merge_requests/components/sticky_header.vue +++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue @@ -77,7 +77,7 @@ export default { <template> <gl-intersection-observer - class="gl-relative gl-top-2" + class="gl-relative gl-top-n5" @appear="setStickyHeaderVisible(false)" @disappear="setStickyHeaderVisible(true)" > diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue index 787f21d9419..d982df4f984 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue @@ -1,15 +1,30 @@ <script> +import { n__ } from '~/locale'; import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue'; +import { + CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, + DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, +} from '~/packages_and_registries/package_registry/constants'; +import Tracking from '~/tracking'; export default { components: { + DeleteModal, VersionRow, PackagesListLoader, RegistryList, }, + mixins: [Tracking.mixin()], props: { + canDestroy: { + type: Boolean, + required: false, + default: false, + }, versions: { type: Array, required: true, @@ -25,11 +40,35 @@ export default { default: false, }, }, + data() { + return { + itemsToBeDeleted: [], + }; + }, computed: { + listTitle() { + return n__('%d version', '%d versions', this.versions.length); + }, isListEmpty() { return this.versions.length === 0; }, }, + methods: { + deleteItemsCanceled() { + this.track(CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION); + this.itemsToBeDeleted = []; + }, + deleteItemsConfirmation() { + this.$emit('delete', this.itemsToBeDeleted); + this.track(DELETE_PACKAGE_VERSIONS_TRACKING_ACTION); + this.itemsToBeDeleted = []; + }, + setItemsToBeDeleted(items) { + this.itemsToBeDeleted = items; + this.track(REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION); + this.$refs.deletePackagesModal.show(); + }, + }, }; </script> <template> @@ -40,17 +79,34 @@ export default { <slot v-else-if="isListEmpty" name="empty-state"></slot> <div v-else> <registry-list - :hidden-delete="true" + :hidden-delete="!canDestroy" :is-loading="isLoading" :items="versions" :pagination="pageInfo" + :title="listTitle" + @delete="setItemsToBeDeleted" @prev-page="$emit('prev-page')" @next-page="$emit('next-page')" > - <template #default="{ item }"> - <version-row :package-entity="item" /> + <template #default="{ first, item, isSelected, selectItem }"> + <!-- `first` prop is used to decide whether to show the top border + for the first element. We want to show the top border only when + user has permission to bulk delete versions. --> + <version-row + :first="canDestroy && first" + :package-entity="item" + :selected="isSelected(item)" + @select="selectItem(item)" + /> </template> </registry-list> + + <delete-modal + ref="deletePackagesModal" + :items-to-be-deleted="itemsToBeDeleted" + @confirm="deleteItemsConfirmation" + @cancel="deleteItemsCanceled" + /> </div> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue index d6732cad6c1..9f8f6328970 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue @@ -1,5 +1,12 @@ <script> -import { GlIcon, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui'; +import { + GlFormCheckbox, + GlIcon, + GlLink, + GlSprintf, + GlTooltipDirective, + GlTruncate, +} from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; import PublishMethod from '~/packages_and_registries/shared/components/publish_method.vue'; @@ -15,6 +22,7 @@ import { export default { name: 'PackageVersionRow', components: { + GlFormCheckbox, GlIcon, GlLink, GlSprintf, @@ -32,6 +40,11 @@ export default { type: Object, required: true, }, + selected: { + type: Boolean, + default: false, + required: false, + }, }, computed: { containsWebPathLink() { @@ -53,7 +66,15 @@ export default { </script> <template> - <list-item> + <list-item :selected="selected" v-bind="$attrs"> + <template #left-action> + <gl-form-checkbox + v-if="packageEntity.canDestroy" + class="gl-m-0" + :checked="selected" + @change="$emit('select')" + /> + </template> <template #left-primary> <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"> <gl-link v-if="containsWebPathLink" class="gl-text-body gl-min-w-0" :href="packageLink"> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue index a8a96b89256..16f21bfe61d 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue @@ -100,7 +100,7 @@ export default { </script> <template> - <list-item data-testid="package-row" v-bind="$attrs"> + <list-item data-testid="package-row" :selected="selected" v-bind="$attrs"> <template #left-action> <gl-form-checkbox v-if="packageEntity.canDestroy" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js index ddd2fda7733..d979ae5c08c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -115,6 +115,10 @@ export const DELETE_PACKAGES_TRACKING_ACTION = 'delete_packages'; export const REQUEST_DELETE_PACKAGES_TRACKING_ACTION = 'request_delete_packages'; export const CANCEL_DELETE_PACKAGES_TRACKING_ACTION = 'cancel_delete_packages'; +export const DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'delete_package_versions'; +export const REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'request_delete_package_versions'; +export const CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'cancel_delete_package_versions'; + export const DELETE_PACKAGES_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting packages.', ); diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql index 31579cb4c72..109d535469b 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql @@ -66,6 +66,7 @@ query getPackageDetails( nodes { id name + canDestroy createdAt version status diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue index 8f0efd5e74f..4591c2eca87 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue @@ -95,6 +95,7 @@ export default { deletePackageModalContent: DELETE_MODAL_CONTENT, filesToDelete: [], mutationLoading: false, + versionsMutationLoading: false, packageEntity: {}, }; }, @@ -146,6 +147,9 @@ export default { isLoading() { return this.$apollo.queries.packageEntity.loading; }, + isVersionsLoading() { + return this.isLoading || this.versionsMutationLoading; + }, packageFilesLoading() { return this.isLoading || this.mutationLoading; }, @@ -157,9 +161,6 @@ export default { category: packageTypeToTrackCategory(this.packageType), }; }, - hasVersions() { - return this.packageEntity.versions?.nodes?.length > 0; - }, versionPageInfo() { return this.packageEntity?.versions?.pageInfo ?? {}; }, @@ -181,6 +182,14 @@ export default { PACKAGE_TYPE_PYPI, ].includes(this.packageType); }, + refetchQueriesData() { + return [ + { + query: getPackageDetails, + variables: this.queryVariables, + }, + ]; + }, }, methods: { formatSize(size) { @@ -206,12 +215,7 @@ export default { ids, }, awaitRefetchQueries: true, - refetchQueries: [ - { - query: getPackageDetails, - variables: this.queryVariables, - }, - ], + refetchQueries: this.refetchQueriesData, }); if (data?.destroyPackageFiles?.errors[0]) { throw data.destroyPackageFiles.errors[0]; @@ -403,19 +407,30 @@ export default { }}</gl-badge> </template> - <package-versions-list - :is-loading="isLoading" - :page-info="versionPageInfo" - :versions="packageEntity.versions.nodes" - @prev-page="fetchPreviousVersionsPage" - @next-page="fetchNextVersionsPage" + <delete-packages + :refetch-queries="refetchQueriesData" + show-success-alert + @start="versionsMutationLoading = true" + @end="versionsMutationLoading = false" > - <template #empty-state> - <p class="gl-mt-3" data-testid="no-versions-message"> - {{ s__('PackageRegistry|There are no other versions of this package.') }} - </p> + <template #default="{ deletePackages }"> + <package-versions-list + :can-destroy="packageEntity.canDestroy" + :is-loading="isVersionsLoading" + :page-info="versionPageInfo" + :versions="packageEntity.versions.nodes" + @delete="deletePackages" + @prev-page="fetchPreviousVersionsPage" + @next-page="fetchNextVersionsPage" + > + <template #empty-state> + <p class="gl-mt-3" data-testid="no-versions-message"> + {{ s__('PackageRegistry|There are no other versions of this package.') }} + </p> + </template> + </package-versions-list> </template> - </package-versions-list> + </delete-packages> </gl-tab> </gl-tabs> diff --git a/app/assets/javascripts/ref/components/ref_results_section.vue b/app/assets/javascripts/ref/components/ref_results_section.vue deleted file mode 100644 index 52d1ed96b21..00000000000 --- a/app/assets/javascripts/ref/components/ref_results_section.vue +++ /dev/null @@ -1,138 +0,0 @@ -<script> -import { GlDropdownSectionHeader, GlDropdownItem, GlBadge, GlIcon } from '@gitlab/ui'; -import { s__ } from '~/locale'; - -export default { - name: 'RefResultsSection', - components: { - GlDropdownSectionHeader, - GlDropdownItem, - GlBadge, - GlIcon, - }, - props: { - showHeader: { - type: Boolean, - required: false, - default: true, - }, - - sectionTitle: { - type: String, - required: true, - }, - - totalCount: { - type: Number, - required: true, - }, - - /** - * An array of object that have the following properties: - * - * - name (String, required): The name of the ref that will be displayed - * - value (String, optional): The value that will be selected when the ref - * is selected. If not provided, `name` will be used as the value. - * For example, commits use the short SHA for `name` - * and long SHA for `value`. - * - subtitle (String, optional): Text to render underneath the name. - * For example, used to render the commit's title underneath its SHA. - * - default (Boolean, optional): Whether or not to render a "default" - * indicator next to the item. Used to indicate - * the project's default branch. - * - */ - items: { - type: Array, - required: true, - validator: (items) => Array.isArray(items) && items.every((item) => item.name), - }, - - /** - * The currently selected ref. - * Used to render a check mark by the selected item. - * */ - selectedRef: { - type: String, - required: false, - default: '', - }, - - /** - * An error object that indicates that an error - * occurred while fetching items for this section - */ - error: { - type: Error, - required: false, - default: null, - }, - - /** The message to display if an error occurs */ - errorMessage: { - type: String, - required: false, - default: '', - }, - shouldShowCheck: { - type: Boolean, - required: false, - default: true, - }, - }, - computed: { - totalCountText() { - return this.totalCount > 999 ? s__('TotalRefCountIndicator|1000+') : `${this.totalCount}`; - }, - }, - methods: { - showCheck(item) { - if (!this.shouldShowCheck) { - return false; - } - return item.name === this.selectedRef || item.value === this.selectedRef; - }, - }, -}; -</script> - -<template> - <div> - <gl-dropdown-section-header v-if="showHeader"> - <div class="gl-display-flex align-items-center" data-testid="section-header"> - <span class="gl-mr-2 gl-mb-1">{{ sectionTitle }}</span> - <gl-badge variant="neutral">{{ totalCountText }}</gl-badge> - </div> - </gl-dropdown-section-header> - <template v-if="error"> - <div class="gl-display-flex align-items-start text-danger gl-ml-4 gl-mr-4 gl-mb-3"> - <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" /> - <span>{{ errorMessage }}</span> - </div> - </template> - <template v-else> - <gl-dropdown-item - v-for="item in items" - :key="item.name" - @click="$emit('selected', item.value || item.name)" - > - <div class="gl-display-flex align-items-start"> - <gl-icon - name="mobile-issue-close" - class="gl-mr-2 gl-flex-shrink-0" - :class="{ 'gl-visibility-hidden': !showCheck(item) }" - /> - - <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column"> - <span class="gl-font-monospace">{{ item.name }}</span> - <span class="gl-text-gray-400">{{ item.subtitle }}</span> - </div> - - <gl-badge v-if="item.default" size="sm" variant="info">{{ - s__('DefaultBranchLabel|default') - }}</gl-badge> - </div> - </gl-dropdown-item> - </template> - </div> -</template> diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index 2f5b5bcebd6..359909b8f3b 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -1,13 +1,8 @@ <script> -import { - GlDropdown, - GlDropdownDivider, - GlSearchBoxByType, - GlSprintf, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlBadge, GlIcon, GlCollapsibleListbox } from '@gitlab/ui'; import { debounce, isArray } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; +import { sprintf } from '~/locale'; import { ALL_REF_TYPES, SEARCH_DEBOUNCE_MS, @@ -15,21 +10,16 @@ import { REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS, - BRANCH_REF_TYPE, - TAG_REF_TYPE, } from '../constants'; import createStore from '../stores'; -import RefResultsSection from './ref_results_section.vue'; +import { formatListBoxItems, formatErrors } from '../format_refs'; export default { name: 'RefSelector', components: { - GlDropdown, - GlDropdownDivider, - GlSearchBoxByType, - GlSprintf, - GlLoadingIcon, - RefResultsSection, + GlBadge, + GlIcon, + GlCollapsibleListbox, }, inheritAttrs: false, props: { @@ -87,7 +77,6 @@ export default { required: false, default: '', }, - toggleButtonClass: { type: [String, Object, Array], required: false, @@ -112,29 +101,17 @@ export default { ...this.translations, }; }, - showBranchesSection() { - return ( - this.enabledRefTypes.includes(REF_TYPE_BRANCHES) && - Boolean(this.matches.branches.totalCount > 0 || this.matches.branches.error) - ); - }, - showTagsSection() { - return ( - this.enabledRefTypes.includes(REF_TYPE_TAGS) && - Boolean(this.matches.tags.totalCount > 0 || this.matches.tags.error) - ); + listBoxItems() { + return formatListBoxItems(this.branches, this.tags, this.commits); }, - showCommitsSection() { - return ( - this.enabledRefTypes.includes(REF_TYPE_COMMITS) && - Boolean(this.matches.commits.totalCount > 0 || this.matches.commits.error) - ); + branches() { + return this.enabledRefTypes.includes(REF_TYPE_BRANCHES) ? this.matches.branches.list : []; }, - showNoResults() { - return !this.showBranchesSection && !this.showTagsSection && !this.showCommitsSection; + tags() { + return this.enabledRefTypes.includes(REF_TYPE_TAGS) ? this.matches.tags.list : []; }, - showSectionHeaders() { - return this.enabledRefTypes.length > 1; + commits() { + return this.enabledRefTypes.includes(REF_TYPE_COMMITS) ? this.matches.commits.list : []; }, extendedToggleButtonClass() { const classes = [ @@ -142,7 +119,6 @@ export default { 'gl-inset-border-1-red-500!': !this.state, 'gl-font-monospace': Boolean(this.selectedRef), }, - 'gl-max-w-26', ]; if (Array.isArray(this.toggleButtonClass)) { @@ -160,6 +136,9 @@ export default { query: this.lastQuery, }; }, + errors() { + return formatErrors(this.matches.branches, this.matches.tags, this.matches.commits); + }, selectedRefForDisplay() { if (this.useSymbolicRefNames && this.selectedRef) { return this.selectedRef.replace(/^refs\/(tags|heads)\//, ''); @@ -170,11 +149,12 @@ export default { buttonText() { return this.selectedRefForDisplay || this.i18n.noRefSelected; }, - isTagRefType() { - return this.refType === TAG_REF_TYPE; - }, - isBranchRefType() { - return this.refType === BRANCH_REF_TYPE; + noResultsMessage() { + return this.lastQuery + ? sprintf(this.i18n.noResultsWithQuery, { + query: this.lastQuery, + }) + : this.i18n.noResults; }, }, watch: { @@ -202,9 +182,7 @@ export default { // because we need to access the .cancel() method // lodash attaches to the function, which is // made inaccessible by Vue. - this.debouncedSearch = debounce(function search() { - this.search(); - }, SEARCH_DEBOUNCE_MS); + this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS); this.setProjectId(this.projectId); @@ -231,14 +209,8 @@ export default { 'setSelectedRef', ]), ...mapActions({ storeSearch: 'search' }), - focusSearchBox() { - this.$refs.searchBox.$el.querySelector('input').focus(); - }, - onSearchBoxEnter() { - this.debouncedSearch.cancel(); - this.search(); - }, - onSearchBoxInput() { + onSearchBoxInput(searchQuery = '') { + this.query = searchQuery?.trim(); this.debouncedSearch(); }, selectRef(ref) { @@ -248,104 +220,55 @@ export default { search() { this.storeSearch(this.query); }, + totalCountText(count) { + return count > 999 ? this.i18n.totalCountLabel : `${count}`; + }, }, }; </script> <template> <div> - <gl-dropdown + <gl-collapsible-listbox + class="ref-selector gl-w-full" + block + searchable + :selected="selectedRef" :header-text="i18n.dropdownHeader" + :items="listBoxItems" + :no-results-text="noResultsMessage" + :searching="isLoading" + :search-placeholder="i18n.searchPlaceholder" :toggle-class="extendedToggleButtonClass" - :text="buttonText" - class="ref-selector" + :toggle-text="buttonText" v-bind="$attrs" v-on="$listeners" - @shown="focusSearchBox" + @hidden="$emit('hide')" + @search="onSearchBoxInput" + @select="selectRef" > - <template #header> - <gl-search-box-by-type - ref="searchBox" - v-model.trim="query" - :placeholder="i18n.searchPlaceholder" - autocomplete="off" - data-qa-selector="ref_selector_searchbox" - @input="onSearchBoxInput" - @keydown.enter.prevent="onSearchBoxEnter" - /> + <template #group-label="{ group }"> + {{ group.text }} <gl-badge size="sm">{{ totalCountText(group.options.length) }}</gl-badge> </template> - - <gl-loading-icon v-if="isLoading" size="lg" class="gl-my-3" /> - - <div - v-else-if="showNoResults" - class="gl-text-center gl-mx-3 gl-py-3" - data-testid="no-results" - > - <gl-sprintf v-if="lastQuery" :message="i18n.noResultsWithQuery"> - <template #query> - <b class="gl-word-break-all">{{ lastQuery }}</b> - </template> - </gl-sprintf> - - <span v-else>{{ i18n.noResults }}</span> - </div> - - <template v-else> - <template v-if="showBranchesSection"> - <ref-results-section - :section-title="i18n.branches" - :total-count="matches.branches.totalCount" - :items="matches.branches.list" - :selected-ref="selectedRef" - :error="matches.branches.error" - :error-message="i18n.branchesErrorMessage" - :show-header="showSectionHeaders" - data-testid="branches-section" - data-qa-selector="branches_section" - :should-show-check="!useSymbolicRefNames || isBranchRefType" - @selected="selectRef($event)" - /> - - <gl-dropdown-divider v-if="showTagsSection || showCommitsSection" /> - </template> - - <template v-if="showTagsSection"> - <ref-results-section - :section-title="i18n.tags" - :total-count="matches.tags.totalCount" - :items="matches.tags.list" - :selected-ref="selectedRef" - :error="matches.tags.error" - :error-message="i18n.tagsErrorMessage" - :show-header="showSectionHeaders" - data-testid="tags-section" - :should-show-check="!useSymbolicRefNames || isTagRefType" - @selected="selectRef($event)" - /> - - <gl-dropdown-divider v-if="showCommitsSection" /> - </template> - - <template v-if="showCommitsSection"> - <ref-results-section - :section-title="i18n.commits" - :total-count="matches.commits.totalCount" - :items="matches.commits.list" - :selected-ref="selectedRef" - :error="matches.commits.error" - :error-message="i18n.commitsErrorMessage" - :show-header="showSectionHeaders" - data-testid="commits-section" - @selected="selectRef($event)" - /> - </template> + <template #list-item="{ item }"> + {{ item.text }} + <gl-badge v-if="item.default" size="sm" variant="info">{{ + i18n.defaultLabelText + }}</gl-badge> </template> - <template #footer> <slot name="footer" v-bind="footerSlotProps"></slot> + <div + v-for="errorMessage in errors" + :key="errorMessage" + data-testid="red-selector-error-list" + class="gl-display-flex gl-align-items-flex-start gl-text-red-500 gl-mx-4 gl-my-3" + > + <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" /> + <span>{{ errorMessage }}</span> + </div> </template> - </gl-dropdown> + </gl-collapsible-listbox> <input v-if="name" data-testid="selected-ref-form-field" diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js index f4faa535166..4b5b18cf6c1 100644 --- a/app/assets/javascripts/ref/constants.js +++ b/app/assets/javascripts/ref/constants.js @@ -1,5 +1,5 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { __ } from '~/locale'; +import { s__, __ } from '~/locale'; export const REF_TYPE_BRANCHES = 'REF_TYPE_BRANCHES'; export const REF_TYPE_TAGS = 'REF_TYPE_TAGS'; @@ -13,6 +13,7 @@ export const X_TOTAL_HEADER = 'x-total'; export const SEARCH_DEBOUNCE_MS = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; export const DEFAULT_I18N = Object.freeze({ + defaultLabelText: __('default'), dropdownHeader: __('Select Git revision'), searchPlaceholder: __('Search by Git revision'), noResultsWithQuery: __('No matching results for "%{query}"'), @@ -24,4 +25,5 @@ export const DEFAULT_I18N = Object.freeze({ tags: __('Tags'), commits: __('Commits'), noRefSelected: __('No ref selected'), + totalCountLabel: s__('TotalRefCountIndicator|1000+'), }); diff --git a/app/assets/javascripts/ref/format_refs.js b/app/assets/javascripts/ref/format_refs.js new file mode 100644 index 00000000000..af310a35ef4 --- /dev/null +++ b/app/assets/javascripts/ref/format_refs.js @@ -0,0 +1,60 @@ +import { DEFAULT_I18N } from './constants'; + +function convertToListBoxItems(items) { + return items.map((item) => ({ + text: item.name, + value: item.value || item.name, + default: item.default, + })); +} + +/** + * Format multiple lists to array of group options for listbox + * @param branches list of branches + * @param tags list of tags + * @param commits list of commits + * @returns {*[]} array of group items with header and options + */ +export const formatListBoxItems = (branches, tags, commits) => { + const listBoxItems = []; + + const addToFinalResult = (items, header) => { + if (items && items.length > 0) { + listBoxItems.push({ + text: header, + options: convertToListBoxItems(items), + }); + } + }; + + addToFinalResult(branches, DEFAULT_I18N.branches); + addToFinalResult(tags, DEFAULT_I18N.tags); + addToFinalResult(commits, DEFAULT_I18N.commits); + + return listBoxItems; +}; + +/** + * Check error existence and add to final array + * @param branches list of branches + * @param tags list of tags + * @param commits list of commits + * @returns {*[]} array of error messages + */ +export const formatErrors = (branches, tags, commits) => { + const errorsList = []; + + if (branches && branches.error) { + errorsList.push(DEFAULT_I18N.branchesErrorMessage); + } + + if (tags && tags.error) { + errorsList.push(DEFAULT_I18N.tagsErrorMessage); + } + + if (commits && commits.error) { + errorsList.push(DEFAULT_I18N.commitsErrorMessage); + } + + return errorsList; +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue new file mode 100644 index 00000000000..092e8ba6c15 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue @@ -0,0 +1,133 @@ +<script> +import { GlIntersectionObserver } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { getPageParamValue, getPageSearchString } from '~/blob/utils'; + +/* + * We only highlight the chunk that is currently visible to the user. + * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly. + * + * Content that is not visible to the user (i.e. not highlighted) does not need to look nice, + * so by rendering raw (non-highlighted) text, the browser spends less resources on painting + * content that is not immediately relevant. + * Why use plaintext as opposed to hiding content entirely? + * If content is hidden entirely, native find text (⌘ + F) won't work. + */ +export default { + components: { + GlIntersectionObserver, + }, + directives: { + SafeHtml, + }, + mixins: [glFeatureFlagMixin()], + props: { + isHighlighted: { + type: Boolean, + required: true, + }, + chunkIndex: { + type: Number, + required: false, + default: 0, + }, + rawContent: { + type: String, + required: true, + }, + highlightedContent: { + type: String, + required: true, + }, + totalLines: { + type: Number, + required: false, + default: 0, + }, + startingFrom: { + type: Number, + required: false, + default: 0, + }, + blamePath: { + type: String, + required: true, + }, + }, + data() { + return { + hasAppeared: false, + isLoading: true, + }; + }, + computed: { + shouldHighlight() { + return Boolean(this.highlightedContent) && (this.hasAppeared || this.isHighlighted); + }, + lines() { + return this.content.split('\n'); + }, + pageSearchString() { + if (!this.glFeatures.fileLineBlame) return ''; + const page = getPageParamValue(this.number); + return getPageSearchString(this.blamePath, page); + }, + }, + created() { + if (this.chunkIndex === 0) { + // Display first chunk ASAP in order to improve perceived performance + this.isLoading = false; + return; + } + + window.requestIdleCallback(() => { + this.isLoading = false; + }); + }, + methods: { + handleChunkAppear() { + this.hasAppeared = true; + }, + calculateLineNumber(index) { + return this.startingFrom + index + 1; + }, + }, +}; +</script> +<template> + <gl-intersection-observer @appear="handleChunkAppear"> + <div class="gl-display-flex"> + <div v-if="shouldHighlight" class="gl-display-flex gl-flex-direction-column"> + <div + v-for="(n, index) in totalLines" + :key="index" + data-testid="line-numbers" + class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" + > + <a + v-if="glFeatures.fileLineBlame" + class="gl-user-select-none gl-shadow-none! file-line-blame" + :href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`" + ></a> + <a + :id="`L${calculateLineNumber(index)}`" + class="gl-user-select-none gl-shadow-none! file-line-num" + :href="`#L${calculateLineNumber(index)}`" + :data-line-number="calculateLineNumber(index)" + > + {{ calculateLineNumber(index) }} + </a> + </div> + </div> + + <div v-else-if="!isLoading" class="line-numbers gl-p-0! gl-mr-3 gl-text-transparent"> + <!-- Placeholder for line numbers while content is not highlighted --> + </div> + + <pre + class="gl-m-0 gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-0" + ><code v-if="shouldHighlight" v-once v-safe-html="highlightedContent" data-testid="content"></code><code v-else-if="!isLoading" v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre> + </div> + </gl-intersection-observer> +</template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue new file mode 100644 index 00000000000..11708b6f1f6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -0,0 +1,58 @@ +<script> +import SafeHtml from '~/vue_shared/directives/safe_html'; +import Tracking from '~/tracking'; +import addBlobLinksTracking from '~/blob/blob_links_tracking'; +import { EVENT_ACTION, EVENT_LABEL_VIEWER } from './constants'; +import Chunk from './components/chunk.vue'; + +export default { + components: { + Chunk, + }, + directives: { + SafeHtml, + }, + mixins: [Tracking.mixin()], + inject: { + highlightWorker: { default: null }, + }, + props: { + blob: { + type: Object, + required: true, + }, + chunks: { + type: Array, + required: false, + default: () => [], + }, + }, + created() { + this.track(EVENT_ACTION, { label: EVENT_LABEL_VIEWER, property: this.blob.language }); + addBlobLinksTracking(); + }, + userColorScheme: window.gon.user_color_scheme, +}; +</script> + +<template> + <div + class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto" + :class="$options.userColorScheme" + data-type="simple" + :data-path="blob.path" + data-qa-selector="blob_viewer_file_content" + > + <chunk + v-for="(chunk, _, index) in chunks" + :key="index" + :chunk-index="index" + :is-highlighted="Boolean(chunk.isHighlighted)" + :raw-content="chunk.rawContent" + :highlighted-content="chunk.highlightedContent" + :total-lines="chunk.totalLines" + :starting-from="chunk.startingFrom" + :blame-path="blob.blamePath" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue index 5eb3da3c62e..d78530239a5 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue @@ -173,6 +173,7 @@ export default { :can-edit="enableEdit" :task-list-update-path="taskListUpdatePath" /> + <slot name="secondary-content"></slot> <small v-if="isUpdated" class="edited-text gl-font-sm!"> {{ __('Edited') }} <time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" /> diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb index 3cab2b7aba7..10d18f9ad2a 100644 --- a/app/graphql/types/ci/runner_type.rb +++ b/app/graphql/types/ci/runner_type.rb @@ -142,7 +142,7 @@ module Types end def ephemeral_authentication_token - return unless runner.created_via_ui? + return unless runner.authenticated_user_registration_type? return unless runner.created_at > RUNNER_EPHEMERAL_TOKEN_AVAILABILITY_TIME.ago return if runner.runner_machines.any? diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 565df2ce621..09ac0fa69e7 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -30,6 +30,11 @@ module Ci project_type: 3 } + enum registration_type: { + registration_token: 0, + authenticated_user: 1 + }, _suffix: true + # Prefix assigned to runners created from the UI, instead of registered via the command line CREATED_RUNNER_TOKEN_PREFIX = 'glrt-' @@ -184,6 +189,7 @@ module Ci validate :tag_constraints validates :access_level, presence: true validates :runner_type, presence: true + validates :registration_type, presence: true validate :no_projects, unless: :project_type? validate :no_groups, unless: :group_type? @@ -196,8 +202,6 @@ module Ci cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at, :executor_type - attr_writer :legacy_registered - chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout, error_message: 'Maximum job timeout has a value which could not be accepted' @@ -297,13 +301,6 @@ module Ci end end - def initialize(params) - @legacy_registered = params&.delete(:legacy_registered) - @legacy_registered = true if @legacy_registered.nil? - - super(params) - end - def assign_to(project, current_user = nil) if instance_type? raise ArgumentError, 'Transitioning an instance runner to a project runner is not supported' @@ -389,7 +386,7 @@ module Ci def short_sha return unless token - start_index = created_via_ui? ? CREATED_RUNNER_TOKEN_PREFIX.length : 0 + start_index = authenticated_user_registration_type? ? CREATED_RUNNER_TOKEN_PREFIX.length : 0 token[start_index..start_index + 8] end @@ -493,15 +490,11 @@ module Ci override :format_token def format_token(token) - return token if @legacy_registered + return token if registration_token_registration_type? "#{CREATED_RUNNER_TOKEN_PREFIX}#{token}" end - def created_via_ui? - token.start_with?(CREATED_RUNNER_TOKEN_PREFIX) - end - def ensure_machine(system_xid, &blk) RunnerMachine.safe_find_or_create_by!(runner_id: id, system_xid: system_xid.to_s, &blk) # rubocop: disable Performance/ActiveRecordSubtransactionMethods end diff --git a/app/uploaders/object_storage/s3.rb b/app/uploaders/object_storage/s3.rb new file mode 100644 index 00000000000..063af8b5389 --- /dev/null +++ b/app/uploaders/object_storage/s3.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module ObjectStorage + module S3 + def self.signed_head_url(file) + fog_storage = ::Fog::Storage.new(file.fog_credentials) + fog_dir = fog_storage.directories.new(key: file.fog_directory) + fog_file = fog_dir.files.new(key: file.path) + expire_at = ::Fog::Time.now + file.fog_authenticated_url_expiration + + fog_file.collection.head_url(fog_file.key, expire_at) + end + end +end diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml index be67b317746..880bffc43ab 100644 --- a/app/views/projects/merge_requests/_page.html.haml +++ b/app/views/projects/merge_requests/_page.html.haml @@ -20,10 +20,9 @@ - add_page_startup_api_call @endpoint_diff_batch_url .merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version, diffs_batch_cache_key: @diffs_batch_cache_key } } + = render "projects/merge_requests/mr_title" - if moved_mr_sidebar_enabled? #js-merge-sticky-header{ data: { data: sticky_header_data.to_json } } - = render "projects/merge_requests/mr_title" - .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } = render "projects/merge_requests/mr_box" .merge-request-tabs-holder{ class: "#{'js-tabs-affix' unless ENV['RAILS_ENV'] == 'test'} #{'gl-static' if moved_mr_sidebar_enabled?}" } diff --git a/config/feature_flags/development/revoke_ssh_signatures.yml b/config/feature_flags/development/revoke_ssh_signatures.yml index 6232e699515..de50bae7d1d 100644 --- a/config/feature_flags/development/revoke_ssh_signatures.yml +++ b/config/feature_flags/development/revoke_ssh_signatures.yml @@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/388986 milestone: '15.9' type: development group: group::source code -default_enabled: false +default_enabled: true diff --git a/doc/api/product_analytics.md b/doc/api/product_analytics.md index 6a9f4774bce..c687acdb5db 100644 --- a/doc/api/product_analytics.md +++ b/doc/api/product_analytics.md @@ -34,6 +34,11 @@ POST /projects/:id/product_analytics/request/dry-run The body of the load request must be a valid Cube query. +NOTE: +When measuring `TrackedEvents`, you must use `TrackedEvents.*` for `dimensions` and `timeDimensions`. The same rule applies when measuring `Sessions`. + +#### Tracked events example + ```json { "query": { @@ -69,6 +74,29 @@ The body of the load request must be a valid Cube query. } ``` +#### Sessions example + +```json +{ + "query": { + "measures": [ + "Sessions.count" + ], + "timeDimensions": [ + { + "dimension": "Sessions.startAt", + "granularity": "day" + } + ], + "order": { + "Sessions.startAt": "asc" + }, + "limit": 100 + }, + "queryType": "multi" +} +``` + ## Send metadata request to Cube Return Cube Metadata for the Analytics data. For example: diff --git a/doc/api/projects.md b/doc/api/projects.md index 529e86d2c94..3d126eeadab 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -210,7 +210,10 @@ When the user is authenticated and `simple` is not set this returns something li "group_runners_enabled": true, "lfs_enabled": true, "creator_id": 1, + "import_url": null, + "import_type": null, "import_status": "none", + "import_error": null, "open_issues_count": 0, "ci_default_git_depth": 20, "ci_forward_deployment_enabled": true, @@ -381,6 +384,10 @@ GET /users/:user_id/projects "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "creator_id": 3, + "import_url": null, + "import_type": null, + "import_status": "none", + "import_error": null, "namespace": { "id": 3, "name": "Diaspora", @@ -482,6 +489,10 @@ GET /users/:user_id/projects "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "creator_id": 3, + "import_url": null, + "import_type": null, + "import_status": "none", + "import_error": null, "namespace": { "id": 4, "name": "Brightbox", @@ -898,6 +909,8 @@ GET /projects/:id "avatar_url": "http://localhost:3000/uploads/group/avatar/3/foo.jpg", "web_url": "http://localhost:3000/groups/diaspora" }, + "import_url": null, + "import_type": null, "import_status": "none", "import_error": null, "permissions": { diff --git a/doc/architecture/blueprints/runner_tokens/index.md b/doc/architecture/blueprints/runner_tokens/index.md index 4fda47a99a0..1c2ae3a56d8 100644 --- a/doc/architecture/blueprints/runner_tokens/index.md +++ b/doc/architecture/blueprints/runner_tokens/index.md @@ -421,6 +421,92 @@ scope. | 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 + +### Will my runner registration workflow break? + +If no action is taken before your GitLab instance is upgraded to 16.0, then your runner registration +worflow will break. +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. + +### What is the new runner registration process? + +When the new runner registration process is introduced, you will: + +1. Create a runner directly in the GitLab UI. +1. Receive an authentication token in return. +1. Use the authentication token instead of the registration token. + +This has added benefits such as preserved ownership records for runners, and minimizes +impact on users. +The addition of a unique system ID ensures that you can reuse the same authentication token across +multiple runners. +For example, in an auto-scaling scenario where a runner manager spawns a runner process with a +fixed authentication token. +This ID generates once at the runner's startup, persists in a sidecar file, and is sent to the +GitLab instance when requesting jobs. +This allows the GitLab instance to display which system executed a given job. + +### What is the estimated timeframe for the planned changes? + +- 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.0, 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? + +The `gitlab-runner register` command will stop accepting registration tokens and instead accept new +authentication tokens generated in the GitLab runners administration page. +These authentication tokens are recognizable by their `glrt-` prefix. + +Example command for GitLab 15.9: + +```shell +gitlab-runner register + --executor "shell" \ + --url "https://gitlab.com/" \ + --tag-list "shell,mac,gdk,test" \ + --run-untagged="false" \ + --locked="false" \ + --access-level="not_protected" \ + --non-interactive \ + --registration-token="GR1348941C6YcZVddc8kjtdU-yWYD" +``` + +In GitLab 16.0, the runner will be created in the UI where some of its attributes can be +pre-configured by the creator. +Examples are the tag list, locked status, or access level. These are no longer accepted as arguments +to `register`. The following example shows the new command: + +```shell +gitlab-runner register + --executor "shell" \ + --url "https://gitlab.com/" \ + --non-interactive \ + --registration-token="grlt-2CR8_eVxiioB1QmzPZwa" +``` + +### How does this change impact auto-scaling scenarios? + +In auto-scaling scenarios such as GitLab Runner Operator or GitLab Runner Helm Chart, the +registration token is replaced with the authentication token generated from the UI. +This means that the same runner configuration is reused across jobs, instead of creating a runner +for each job. +The specific runner can be identified by the unique system ID that is generated when the runner +process is started. + ## Status Status: RFC. diff --git a/doc/ci/environments/index.md b/doc/ci/environments/index.md index 8b2006b7ed4..60450692794 100644 --- a/doc/ci/environments/index.md +++ b/doc/ci/environments/index.md @@ -124,39 +124,41 @@ deploy_staging: ### Create a dynamic environment -To create a dynamic name and URL for an environment, you can use -[predefined CI/CD variables](../variables/predefined_variables.md). For example: +To create a dynamic environment, you use [CI/CD variables](../variables/index.md) that are unique to each pipeline. + +Prerequisites: + +- You must have at least the Developer role. + +To create a dynamic environment, in your `.gitlab-ci.yml` file: + +1. Define a job in the `deploy` stage. +1. In the job, define the following environment attributes: + - `name`: Use a related CI/CD variable like `$CI_COMMIT_REF_SLUG`. Optionally, add a static + prefix to the environment's name, which [groups in the UI](#group-similar-environments) all + environments with the same prefix. + - `url`: Optional. Prefix the hostname with a related CI/CD variable like `$CI_ENVIRONMENT_SLUG`. + +NOTE: +Some characters cannot be used in environment names. For more information about the +`environment` keywords, see the [`.gitlab-ci.yml` keyword reference](../yaml/index.md#environment). + +In the following example, every time the `deploy_review_app` job runs the environment's name and +URL are defined using unique values. ```yaml -deploy_review: +deploy_review_app: stage: deploy - script: - - echo "Deploy a review app" + script: make deploy environment: name: review/$CI_COMMIT_REF_SLUG url: https://$CI_ENVIRONMENT_SLUG.example.com - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - when: never - - if: $CI_COMMIT_BRANCH + only: + - branches + except: + - main ``` -In this example: - -- The `name` is `review/$CI_COMMIT_REF_SLUG`. Because the [environment name](../yaml/index.md#environmentname) - can contain slashes (`/`), you can use this pattern to distinguish between dynamic and static environments. -- For the `url`, you could use `$CI_COMMIT_REF_SLUG`, but because this value - may contain a `/` or other characters that would not be valid in a domain name or URL, - use `$CI_ENVIRONMENT_SLUG` instead. The `$CI_ENVIRONMENT_SLUG` variable is guaranteed to be unique. - -You do not have to use the same prefix or only slashes (`/`) in the dynamic environment name. -However, when you use this format, you can [group similar environments](#group-similar-environments). - -NOTE: -Some variables cannot be used as environment names or URLs. -For more information about the `environment` keywords, see -[the `.gitlab-ci.yml` keyword reference](../yaml/index.md#environment). - ## Deployment tier of environments > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/300741) in GitLab 13.10. diff --git a/doc/development/testing_guide/end_to_end/style_guide.md b/doc/development/testing_guide/end_to_end/style_guide.md index 419942d6b8f..32d8bf339ed 100644 --- a/doc/development/testing_guide/end_to_end/style_guide.md +++ b/doc/development/testing_guide/end_to_end/style_guide.md @@ -8,6 +8,8 @@ info: To determine the technical writer assigned to the Stage/Group associated w This document describes the conventions used at GitLab for writing End-to-end (E2E) tests using the GitLab QA project. +Please note that this guide is an extension of the primary [testing standards and style guidelines](../index.md). If this guide defines a rule that contradicts the primary guide, this guide takes precedence. + ## `click_` versus `go_to_` ### When to use `click_`? diff --git a/doc/user/packages/container_registry/index.md b/doc/user/packages/container_registry/index.md index e9cfae0fbdb..4d7b25e2d77 100644 --- a/doc/user/packages/container_registry/index.md +++ b/doc/user/packages/container_registry/index.md @@ -165,3 +165,9 @@ this setting. However, disabling the Container Registry disables all Container R | Private project with Container Registry visibility <br/> set to **Everyone With Access** (UI) or `enabled` (API) | View Container Registry <br/> and pull images | No | No | Yes | | Private project with Container Registry visibility <br/> set to **Only Project Members** (UI) or `private` (API) | View Container Registry <br/> and pull images | No | No | Yes | | Any project with Container Registry `disabled` | All operations on Container Registry | No | No | No | + +## Supported image types + +The Container Registry supports [Docker V2](https://docs.docker.com/registry/spec/manifest-v2-2/) and [Open Container Initiative (OCI)](https://github.com/opencontainers/image-spec/blob/main/spec.md) image formats. + +OCI support means that you can host OCI-based image formats in the registry, such as [Helm 3+ chart packages](https://helm.sh/docs/topics/registries/). There is no distinction between image formats in the GitLab [API](../../../api/container_registry.md) and the UI. [Issue 38047](https://gitlab.com/gitlab-org/gitlab/-/issues/38047) addresses this distinction, starting with Helm. diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 197bd420295..aadcbe38b15 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -608,7 +608,7 @@ module API if file.file_storage? present_disk_file!(file.path, file.filename) elsif supports_direct_download && file.class.direct_download_enabled? - return redirect(signed_head_url(file)) if head_request_on_aws_file?(file) + return redirect(ObjectStorage::S3.signed_head_url(file)) if request.head? && file.fog_credentials[:provider] == 'AWS' redirect(cdn_fronted_url(file)) else @@ -701,19 +701,6 @@ module API private - def head_request_on_aws_file?(file) - request.head? && file.fog_credentials[:provider] == 'AWS' - end - - def signed_head_url(file) - fog_storage = ::Fog::Storage.new(file.fog_credentials) - fog_dir = fog_storage.directories.new(key: file.fog_directory) - fog_file = fog_dir.files.new(key: file.path) - expire_at = ::Fog::Time.now + file.fog_authenticated_url_expiration - - fog_file.collection.head_url(fog_file.key, expire_at) - end - # rubocop:disable Gitlab/ModuleWithInstanceVariables def initial_current_user return @initial_current_user if defined?(@initial_current_user) diff --git a/lib/gitlab/pages/cache_control.rb b/lib/gitlab/pages/cache_control.rb index a24d958b7e5..a0b3ab87f38 100644 --- a/lib/gitlab/pages/cache_control.rb +++ b/lib/gitlab/pages/cache_control.rb @@ -47,8 +47,15 @@ module Gitlab # cached settings hash to build the payload cache key to be invalidated. def clear_cache keys = cached_settings_hashes - .map { |hash| payload_cache_key_for(hash) } - .push(settings_cache_key) + .map { |hash| payload_cache_key_for(hash) } + .push(settings_cache_key) + + ::Gitlab::AppLogger.info( + message: 'clear pages cache', + keys: keys, + type: @type, + id: @id + ) Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do Rails.cache.delete_multi(keys) diff --git a/lib/tasks/gitlab/cleanup.rake b/lib/tasks/gitlab/cleanup.rake index 49d2d9fed03..1753483b091 100644 --- a/lib/tasks/gitlab/cleanup.rake +++ b/lib/tasks/gitlab/cleanup.rake @@ -1,7 +1,8 @@ # frozen_string_literal: true -require 'set' namespace :gitlab do + require 'set' + namespace :cleanup do desc "GitLab | Cleanup | Block users that have been removed in LDAP" task block_removed_ldap_users: :gitlab_environment do diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c3d86ffbc57..720e9bf647a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -426,6 +426,11 @@ msgid_plural "%d unresolved threads" msgstr[0] "" msgstr[1] "" +msgid "%d version" +msgid_plural "%d versions" +msgstr[0] "" +msgstr[1] "" + msgid "%d vulnerability" msgid_plural "%d vulnerabilities" msgstr[0] "" @@ -36258,15 +36263,21 @@ msgstr "" msgid "Requirement %{reference} has been updated" msgstr "" -msgid "Requirement title cannot have more than %{limit} characters." -msgstr "" - msgid "Requirements" msgstr "" msgid "Requirements can be based on users, stakeholders, system, software, or anything else you find important to capture." msgstr "" +msgid "Requirement|Legacy requirement ID: %{legacyId}" +msgstr "" + +msgid "Requirement|Legacy requirement IDs are being deprecated. Update your links to reference this item's new ID %{id}. %{linkStart}Learn more%{linkEnd}." +msgstr "" + +msgid "Requirement|Requirements have become work items and the legacy requirement IDs are being deprecated. Update your links to reference this item's new ID %{id}. %{linkStart}Learn more%{linkEnd}." +msgstr "" + msgid "Requires %d approval from eligible users." msgid_plural "Requires %d approvals from eligible users." msgstr[0] "" diff --git a/qa/qa/page/project/settings/default_branch.rb b/qa/qa/page/project/settings/default_branch.rb index 69ac45ce72d..a59158966c1 100644 --- a/qa/qa/page/project/settings/default_branch.rb +++ b/qa/qa/page/project/settings/default_branch.rb @@ -5,6 +5,8 @@ module QA module Project module Settings class DefaultBranch < Page::Base + include ::QA::Page::Component::Dropdown + view 'app/views/projects/branch_defaults/_show.html.haml' do element :save_changes_button end @@ -13,14 +15,9 @@ module QA element :default_branch_dropdown end - view 'app/assets/javascripts/ref/components/ref_selector.vue' do - element :ref_selector_searchbox - end - def set_default_branch(branch) - find_element(:default_branch_dropdown, visible: false).click - find_element(:ref_selector_searchbox, visible: false).fill_in(with: branch) - click_button branch + expand_select_list + search_and_select(branch) end def click_save_changes_button diff --git a/qa/qa/specs/features/browser_ui/4_verify/pipeline/mr_event_rule_pipeline_spec.rb b/qa/qa/specs/features/browser_ui/4_verify/pipeline/mr_event_rule_pipeline_spec.rb deleted file mode 100644 index 5dff2db6f2b..00000000000 --- a/qa/qa/specs/features/browser_ui/4_verify/pipeline/mr_event_rule_pipeline_spec.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -module QA - RSpec.describe 'Verify', :runner, product_group: :pipeline_authoring do - context 'when job is configured to only run on merge_request_events' do - let(:mr_only_job_name) { 'mr_only_job' } - let(:non_mr_only_job_name) { 'non_mr_only_job' } - let(:executor) { "qa-runner-#{Faker::Alphanumeric.alphanumeric(number: 8)}" } - - let(:project) do - Resource::Project.fabricate_via_api! do |project| - project.name = 'merge-request-only-job' - end - end - - let!(:runner) do - Resource::ProjectRunner.fabricate! do |runner| - runner.project = project - runner.name = executor - runner.tags = [executor] - end - end - - let!(:ci_file) do - Resource::Repository::Commit.fabricate_via_api! do |commit| - commit.project = project - commit.commit_message = 'Add .gitlab-ci.yml' - commit.add_files( - [ - { - file_path: '.gitlab-ci.yml', - content: <<~YAML - #{mr_only_job_name}: - tags: ["#{executor}"] - script: echo 'OK' - rules: - - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - #{non_mr_only_job_name}: - tags: ["#{executor}"] - script: echo 'OK' - rules: - - if: '$CI_PIPELINE_SOURCE != "merge_request_event"' - YAML - } - ] - ) - end - end - - let(:merge_request) do - Resource::MergeRequest.fabricate_via_api! do |merge_request| - merge_request.project = project - merge_request.description = Faker::Lorem.sentence - merge_request.target_new_branch = false - merge_request.file_name = 'new.txt' - merge_request.file_content = Faker::Lorem.sentence - end - end - - before do - Flow::Login.sign_in - # TODO: We should remove (wait) revisiting logic when - # https://gitlab.com/gitlab-org/gitlab/-/issues/385332 is resolved - Support::Waiter.wait_until do - merge_request.visit! - Page::MergeRequest::Show.perform(&:click_pipeline_link) - Page::Project::Pipeline::Show.perform(&:has_merge_request_badge_tag?) - end - end - - after do - runner.remove_via_api! - end - - it 'only runs the job configured to run on merge requests', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347662' do - Page::Project::Pipeline::Show.perform do |pipeline| - aggregate_failures do - expect(pipeline).to have_job(mr_only_job_name) - expect(pipeline).to have_no_job(non_mr_only_job_name) - end - end - end - end - end -end diff --git a/spec/factories/ci/runners.rb b/spec/factories/ci/runners.rb index 1f4a8eebca2..4758986b47c 100644 --- a/spec/factories/ci/runners.rb +++ b/spec/factories/ci/runners.rb @@ -58,10 +58,6 @@ FactoryBot.define do end end - trait :created_via_ui do - legacy_registered { false } - end - trait :without_projects do # we use that to create invalid runner: # the one without projects diff --git a/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb b/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb index eb370cfc1fc..9afd8b3263a 100644 --- a/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb +++ b/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe 'New Branch Ref Dropdown', :js, feature_category: :projects do + include ListboxHelpers + let(:user) { create(:user) } let(:project) { create(:project, :public, :repository) } let(:sha) { project.commit.sha } @@ -18,15 +20,13 @@ RSpec.describe 'New Branch Ref Dropdown', :js, feature_category: :projects do it 'finds a tag in a list' do tag_name = 'v1.0.0' - toggle.click - filter_by(tag_name) wait_for_requests expect(items_count(tag_name)).to be(1) - item(tag_name).click + select_listbox_item tag_name expect(toggle).to have_content tag_name end @@ -34,22 +34,18 @@ RSpec.describe 'New Branch Ref Dropdown', :js, feature_category: :projects do it 'finds a branch in a list' do branch_name = 'audio' - toggle.click - filter_by(branch_name) wait_for_requests expect(items_count(branch_name)).to be(1) - item(branch_name).click + select_listbox_item branch_name expect(toggle).to have_content branch_name end it 'finds a commit in a list' do - toggle.click - filter_by(sha) wait_for_requests @@ -58,21 +54,19 @@ RSpec.describe 'New Branch Ref Dropdown', :js, feature_category: :projects do expect(items_count(sha_short)).to be(1) - item(sha_short).click + select_listbox_item sha_short expect(toggle).to have_content sha_short end it 'shows no results when there is no branch, tag or commit sha found' do non_existing_ref = 'non_existing_branch_name' - - toggle.click - filter_by(non_existing_ref) wait_for_requests - expect(find('.gl-dropdown-contents')).not_to have_content(non_existing_ref) + click_button 'master' + expect(toggle).not_to have_content(non_existing_ref) end def item(ref_name) @@ -84,6 +78,7 @@ RSpec.describe 'New Branch Ref Dropdown', :js, feature_category: :projects do end def filter_by(filter_text) - fill_in _('Search by Git revision'), with: filter_text + click_button 'master' + send_keys filter_text end end diff --git a/spec/features/projects/files/user_browses_files_spec.rb b/spec/features/projects/files/user_browses_files_spec.rb index 125f7209ab4..c797fd85555 100644 --- a/spec/features/projects/files/user_browses_files_spec.rb +++ b/spec/features/projects/files/user_browses_files_spec.rb @@ -4,6 +4,7 @@ require "spec_helper" RSpec.describe "User browses files", :js, feature_category: :projects do include RepoHelpers + include ListboxHelpers let(:fork_message) do "You're not allowed to make changes to this project directly. "\ @@ -282,17 +283,13 @@ RSpec.describe "User browses files", :js, feature_category: :projects do expect(page).to have_content(".gitignore").and have_content("LICENSE") end - it "shows files from a repository with apostroph in its name" do - ref_name = 'test' + it "shows files from a repository with apostrophe in its name" do + ref_name = 'fix' find(ref_selector).click wait_for_requests - page.within(ref_selector) do - fill_in 'Search by Git revision', with: ref_name - wait_for_requests - find('li', text: ref_name, match: :prefer_exact).click - end + filter_by(ref_name) expect(find(ref_selector)).to have_text(ref_name) @@ -307,11 +304,7 @@ RSpec.describe "User browses files", :js, feature_category: :projects do find(ref_selector).click wait_for_requests - page.within(ref_selector) do - fill_in 'Search by Git revision', with: ref_name - wait_for_requests - find('li', text: ref_name, match: :prefer_exact).click - end + filter_by(ref_name) visit(project_tree_path(project, "fix/.testdir")) @@ -394,4 +387,12 @@ RSpec.describe "User browses files", :js, feature_category: :projects do end end end + + def filter_by(filter_text) + send_keys filter_text + + wait_for_requests + + select_listbox_item filter_text + end end diff --git a/spec/features/projects/files/user_find_file_spec.rb b/spec/features/projects/files/user_find_file_spec.rb index 0a3b8c19cc4..9cc2ce6a7b4 100644 --- a/spec/features/projects/files/user_find_file_spec.rb +++ b/spec/features/projects/files/user_find_file_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe 'User find project file', feature_category: :projects do + include ListboxHelpers + let(:user) { create :user } let(:project) { create :project, :repository } @@ -22,7 +24,7 @@ RSpec.describe 'User find project file', feature_category: :projects do end def ref_selector_dropdown - find('.gl-dropdown-toggle > .gl-dropdown-button-text') + find('.gl-button-text') end it 'navigates to find file by shortcut', :js do @@ -99,7 +101,7 @@ RSpec.describe 'User find project file', feature_category: :projects do fill_in _('Switch branch/tag'), with: ref wait_for_requests - find('.gl-dropdown-item', text: ref).click + select_listbox_item(ref) end expect(ref_selector_dropdown).to have_text(ref) end diff --git a/spec/features/projects/graph_spec.rb b/spec/features/projects/graph_spec.rb index 9efbefdc7df..a1f047d9b43 100644 --- a/spec/features/projects/graph_spec.rb +++ b/spec/features/projects/graph_spec.rb @@ -59,7 +59,7 @@ RSpec.describe 'Project Graph', :js, feature_category: :projects do it 'HTML escapes branch name' do expect(page.body).to include("Commit statistics for <strong>#{ERB::Util.html_escape(branch_name)}</strong>") - expect(page.find('.gl-dropdown-button-text')['innerHTML']).to eq(ERB::Util.html_escape(branch_name)) + expect(page.find('.gl-new-dropdown-button-text')['innerHTML']).to include(ERB::Util.html_escape(branch_name)) end end @@ -69,18 +69,18 @@ RSpec.describe 'Project Graph', :js, feature_category: :projects do visit charts_project_graph_path(project, 'master') # Not a huge fan of using a HTML (CSS) selectors here as any change of them will cause a failed test - ref_selector = find('.ref-selector .gl-dropdown-toggle') + ref_selector = find('.ref-selector .gl-new-dropdown-toggle') scroll_to(ref_selector) ref_selector.click - page.within '[data-testid="branches-section"]' do - dropdown_branch_item = find('.gl-dropdown-item', text: 'add-pdf-file') + page.within '.gl-new-dropdown-contents' do + dropdown_branch_item = find('li', text: 'add-pdf-file') scroll_to(dropdown_branch_item) dropdown_branch_item.click end scroll_to(find('.tree-ref-header'), align: :center) - expect(page).to have_selector '.gl-dropdown-toggle', text: ref_name + expect(page).to have_selector '.gl-new-dropdown-toggle', text: ref_name page.within '.tree-ref-header' do expect(page).to have_selector('h4', text: ref_name) end diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb index 8beb8af1a8e..3ede76d3360 100644 --- a/spec/features/projects/pipeline_schedules_spec.rb +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -64,7 +64,7 @@ RSpec.describe 'Pipeline Schedules', :js, feature_category: :projects do it 'shows the pipeline schedule with default ref' do page.within('[data-testid="schedule-target-ref"]') do - expect(first('.gl-dropdown-button-text').text).to eq('master') + expect(first('.gl-button-text').text).to eq('master') end end end @@ -77,7 +77,7 @@ RSpec.describe 'Pipeline Schedules', :js, feature_category: :projects do it 'shows the pipeline schedule with default ref' do page.within('[data-testid="schedule-target-ref"]') do - expect(first('.gl-dropdown-button-text').text).to eq('master') + expect(first('.gl-button-text').text).to eq('master') end end end @@ -319,7 +319,6 @@ RSpec.describe 'Pipeline Schedules', :js, feature_category: :projects do end def select_target_branch - find('[data-testid="schedule-target-ref"] .dropdown-toggle').click click_button 'master' end diff --git a/spec/features/projects/settings/user_changes_default_branch_spec.rb b/spec/features/projects/settings/user_changes_default_branch_spec.rb index 39704fdbbb2..67ba16a2716 100644 --- a/spec/features/projects/settings/user_changes_default_branch_spec.rb +++ b/spec/features/projects/settings/user_changes_default_branch_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe 'Projects > Settings > User changes default branch', feature_category: :projects do + include ListboxHelpers + let(:user) { create(:user) } before do @@ -20,10 +22,10 @@ RSpec.describe 'Projects > Settings > User changes default branch', feature_cate wait_for_requests expect(page).to have_selector(dropdown_selector) - find(dropdown_selector).click + click_button 'master' + send_keys 'fix' - fill_in 'Search branch', with: 'fix' - click_button 'fix' + select_listbox_item 'fix' page.within '#branch-defaults-settings' do click_button 'Save changes' diff --git a/spec/features/projects/tree/tree_show_spec.rb b/spec/features/projects/tree/tree_show_spec.rb index 835a3cda65e..d73642a28bb 100644 --- a/spec/features/projects/tree/tree_show_spec.rb +++ b/spec/features/projects/tree/tree_show_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe 'Projects tree', :js, feature_category: :web_ide do include WebIdeSpecHelpers include RepoHelpers + include ListboxHelpers let(:user) { create(:user) } let(:project) { create(:project, :repository) } @@ -160,17 +161,13 @@ RSpec.describe 'Projects tree', :js, feature_category: :web_ide do context 'ref switcher', :js do it 'switches ref to branch' do ref_selector = '.ref-selector' - ref_name = 'feature' + ref_name = 'fix' visit project_tree_path(project, 'master') - find(ref_selector).click - wait_for_requests + click_button 'master' + send_keys ref_name - page.within(ref_selector) do - fill_in 'Search by Git revision', with: ref_name - wait_for_requests - find('li', text: ref_name, match: :prefer_exact).click - end + select_listbox_item ref_name expect(find(ref_selector)).to have_text(ref_name) end diff --git a/spec/features/search/user_searches_for_code_spec.rb b/spec/features/search/user_searches_for_code_spec.rb index 6c9db029683..b7d06a3a962 100644 --- a/spec/features/search/user_searches_for_code_spec.rb +++ b/spec/features/search/user_searches_for_code_spec.rb @@ -3,6 +3,9 @@ require 'spec_helper' RSpec.describe 'User searches for code', :js, :disable_rate_limiter, feature_category: :global_search do + using RSpec::Parameterized::TableSyntax + include ListboxHelpers + let_it_be(:user) { create(:user) } let_it_be_with_reload(:project) { create(:project, :repository, namespace: user.namespace) } @@ -83,14 +86,10 @@ RSpec.describe 'User searches for code', :js, :disable_rate_limiter, feature_cat expect(page).to have_selector('.results', text: expected_result) - find('.ref-selector').click + click_button 'master' wait_for_requests - page.within('.ref-selector') do - fill_in 'Search by Git revision', with: ref_selector - wait_for_requests - find('li', text: ref_selector, match: :prefer_exact).click - end + select_listbox_item(ref_selector) expect(page).to have_selector('.results', text: expected_result) @@ -137,18 +136,12 @@ RSpec.describe 'User searches for code', :js, :disable_rate_limiter, feature_cat end it 'search result changes when refs switched' do - ref = 'master' expect(find('.results')).not_to have_content('path = gitlab-grack') find('.ref-selector').click wait_for_requests - page.within('.ref-selector') do - fill_in _('Search by Git revision'), with: ref - wait_for_requests - - find('li', text: ref).click - end + select_listbox_item('add-ipython-files') expect(page).to have_selector('.results', text: 'path = gitlab-grack') end @@ -192,18 +185,12 @@ RSpec.describe 'User searches for code', :js, :disable_rate_limiter, feature_cat end it 'search result changes when refs switched' do - ref = 'master' expect(find('.results')).not_to have_content('path = gitlab-grack') find('.ref-selector').click wait_for_requests - page.within('.ref-selector') do - fill_in _('Search by Git revision'), with: ref - wait_for_requests - - find('li', text: ref).click - end + select_listbox_item('add-ipython-files') expect(page).to have_selector('.results', text: 'path = gitlab-grack') end diff --git a/spec/features/tags/developer_creates_tag_spec.rb b/spec/features/tags/developer_creates_tag_spec.rb index 111710ba325..6a1db051e87 100644 --- a/spec/features/tags/developer_creates_tag_spec.rb +++ b/spec/features/tags/developer_creates_tag_spec.rb @@ -34,7 +34,7 @@ RSpec.describe 'Developer creates tag', :js, feature_category: :source_code_mana page.within(ref_selector) do fill_in _('Search by Git revision'), with: ref_name wait_for_requests - expect(find('.gl-dropdown-contents')).not_to have_content(ref_name) + expect(find('.gl-new-dropdown-inner')).not_to have_content(ref_name) end end @@ -60,9 +60,9 @@ RSpec.describe 'Developer creates tag', :js, feature_category: :source_code_mana page.within ref_row do ref_input = find('[name="ref"]', visible: false) expect(ref_input.value).to eq 'master' - expect(find('.gl-dropdown-button-text')).to have_content 'master' + expect(find('.gl-button-text')).to have_content 'master' find('.ref-selector').click - expect(find('.dropdown-menu')).to have_content 'test' + expect(find('.gl-new-dropdown-inner')).to have_content 'test' end end end diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js index d8bb03404f3..508af964ca3 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js @@ -438,42 +438,24 @@ describe('Ci variable modal', () => { raw: true, }; - describe('and FF is enabled', () => { - beforeEach(() => { - createComponent({ - mountFn: mountExtended, - props: { selectedVariable: validRawMaskedVariable }, - }); - }); - - it('should not show an error with symbols', async () => { - await findMaskedVariableCheckbox().trigger('click'); - - expect(findModal().text()).not.toContain(maskError); + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { selectedVariable: validRawMaskedVariable }, }); + }); - it('should not show an error when length is less than 8', async () => { - await findValueField().vm.$emit('input', 'a'); - await findMaskedVariableCheckbox().trigger('click'); + it('should not show an error with symbols', async () => { + await findMaskedVariableCheckbox().trigger('click'); - expect(findModal().text()).toContain(maskError); - }); + expect(findModal().text()).not.toContain(maskError); }); - describe('and FF is disabled', () => { - beforeEach(() => { - createComponent({ - mountFn: mountExtended, - props: { selectedVariable: validRawMaskedVariable }, - provide: { glFeatures: { ciRemoveCharacterLimitationRawMaskedVar: false } }, - }); - }); - - it('should show an error with symbols', async () => { - await findMaskedVariableCheckbox().trigger('click'); + it('should not show an error when length is less than 8', async () => { + await findValueField().vm.$emit('input', 'a'); + await findMaskedVariableCheckbox().trigger('click'); - expect(findModal().text()).toContain(maskError); - }); + expect(findModal().text()).toContain(maskError); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js index 20a459e2c1a..27c0ab96cfc 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js @@ -1,8 +1,16 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; +import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue'; import PackageVersionsList from '~/packages_and_registries/package_registry/components/details/package_versions_list.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; +import Tracking from '~/tracking'; +import { + CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, + DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, +} from '~/packages_and_registries/package_registry/constants'; import { packageData } from '../../mock_data'; describe('PackageVersionsList', () => { @@ -24,6 +32,7 @@ describe('PackageVersionsList', () => { findRegistryList: () => wrapper.findComponent(RegistryList), findEmptySlot: () => wrapper.findComponent(EmptySlotStub), findListRow: () => wrapper.findAllComponents(VersionRow), + findDeletePackagesModal: () => wrapper.findComponent(DeleteModal), }; const mountComponent = (props) => { wrapper = shallowMountExtended(PackageVersionsList, { @@ -35,6 +44,11 @@ describe('PackageVersionsList', () => { }, stubs: { RegistryList, + DeleteModal: stubComponent(DeleteModal, { + methods: { + show: jest.fn(), + }, + }), }, slots: { 'empty-state': EmptySlotStub, @@ -144,4 +158,80 @@ describe('PackageVersionsList', () => { expect(wrapper.emitted('next-page')).toHaveLength(1); }); }); + + describe('when the user can bulk destroy versions', () => { + let eventSpy; + const { findDeletePackagesModal, findRegistryList } = uiElements; + + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); + mountComponent({ canDestroy: true }); + }); + + it('binds the right props', () => { + expect(uiElements.findRegistryList().props()).toMatchObject({ + items: packageList, + pagination: {}, + isLoading: false, + hiddenDelete: false, + title: '2 versions', + }); + }); + + describe('upon deletion', () => { + beforeEach(() => { + findRegistryList().vm.$emit('delete', packageList); + }); + + it('passes itemsToBeDeleted to the modal', () => { + expect(findDeletePackagesModal().props('itemsToBeDeleted')).toStrictEqual(packageList); + expect(wrapper.emitted('delete')).toBeUndefined(); + }); + + it('requesting delete tracks the right action', () => { + expect(eventSpy).toHaveBeenCalledWith( + undefined, + REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, + expect.any(Object), + ); + }); + + describe('when modal confirms', () => { + beforeEach(() => { + findDeletePackagesModal().vm.$emit('confirm'); + }); + + it('emits delete event', () => { + expect(wrapper.emitted('delete')[0]).toEqual([packageList]); + }); + + it('tracks the right action', () => { + expect(eventSpy).toHaveBeenCalledWith( + undefined, + DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, + expect.any(Object), + ); + }); + }); + + it.each(['confirm', 'cancel'])( + 'resets itemsToBeDeleted when modal emits %s', + async (event) => { + await findDeletePackagesModal().vm.$emit(event); + + expect(findDeletePackagesModal().props('itemsToBeDeleted')).toHaveLength(0); + }, + ); + + it('canceling delete tracks the right action', () => { + findDeletePackagesModal().vm.$emit('cancel'); + + expect(eventSpy).toHaveBeenCalledWith( + undefined, + CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, + expect.any(Object), + ); + }); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js index 0d74b59ae5b..67340822fa5 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js @@ -1,6 +1,7 @@ -import { GlIcon, GlLink, GlSprintf, GlTruncate } from '@gitlab/ui'; +import { GlFormCheckbox, GlIcon, GlLink, GlSprintf, GlTruncate } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; import PublishMethod from '~/packages_and_registries/shared/components/publish_method.vue'; import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; @@ -15,17 +16,20 @@ const packageVersion = packageVersions()[0]; describe('VersionRow', () => { let wrapper; + const findListItem = () => wrapper.findComponent(ListItem); const findLink = () => wrapper.findComponent(GlLink); const findPackageTags = () => wrapper.findComponent(PackageTags); const findPublishMethod = () => wrapper.findComponent(PublishMethod); const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); const findPackageName = () => wrapper.findComponent(GlTruncate); const findWarningIcon = () => wrapper.findComponent(GlIcon); + const findBulkDeleteAction = () => wrapper.findComponent(GlFormCheckbox); - function createComponent(packageEntity = packageVersion) { + function createComponent({ packageEntity = packageVersion, selected = false } = {}) { wrapper = shallowMountExtended(VersionRow, { propsData: { packageEntity, + selected, }, stubs: { GlSprintf, @@ -76,13 +80,47 @@ describe('VersionRow', () => { expect(findTimeAgoTooltip().props('time')).toBe(packageVersion.createdAt); }); + describe('left action template', () => { + it('does not render checkbox if not permitted', () => { + createComponent({ packageEntity: { ...packageVersion, canDestroy: false } }); + + expect(findBulkDeleteAction().exists()).toBe(false); + }); + + it('renders checkbox', () => { + createComponent(); + + expect(findBulkDeleteAction().exists()).toBe(true); + expect(findBulkDeleteAction().attributes('checked')).toBeUndefined(); + }); + + it('emits select when checked', () => { + createComponent(); + + findBulkDeleteAction().vm.$emit('change'); + + expect(wrapper.emitted('select')).toHaveLength(1); + }); + + it('renders checkbox in selected state if selected', () => { + createComponent({ + selected: true, + }); + + expect(findBulkDeleteAction().attributes('checked')).toBe('true'); + expect(findListItem().props('selected')).toBe(true); + }); + }); + describe(`when the package is in ${PACKAGE_ERROR_STATUS} status`, () => { beforeEach(() => { createComponent({ - ...packageVersion, - status: PACKAGE_ERROR_STATUS, - _links: { - webPath: null, + packageEntity: { + ...packageVersion, + status: PACKAGE_ERROR_STATUS, + _links: { + webPath: null, + }, }, }); }); @@ -109,10 +147,12 @@ describe('VersionRow', () => { describe('disabled status', () => { beforeEach(() => { createComponent({ - ...packageVersion, - status: 'something', - _links: { - webPath: null, + packageEntity: { + ...packageVersion, + status: 'something', + _links: { + webPath: null, + }, }, }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js index f3d02e4e6ae..2a78cfb13f9 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js @@ -47,6 +47,7 @@ describe('packages_list_row', () => { const findPublishMethod = () => wrapper.findComponent(PublishMethod); const findCreatedDateText = () => wrapper.findByTestId('created-date'); const findTimeAgoTooltip = () => wrapper.findComponent(TimeagoTooltip); + const findListItem = () => wrapper.findComponent(ListItem); const findBulkDeleteAction = () => wrapper.findComponent(GlFormCheckbox); const findPackageName = () => wrapper.findComponent(GlTruncate); @@ -212,6 +213,9 @@ describe('packages_list_row', () => { }); expect(findBulkDeleteAction().attributes('checked')).toBe('true'); + expect(findListItem().props()).toMatchObject({ + selected: true, + }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index ae8cd54a00a..d897be1f344 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -109,6 +109,7 @@ export const packageVersions = () => [ id: 'gid://gitlab/Packages::Package/243', name: '@gitlab-org/package-15', status: 'DEFAULT', + canDestroy: true, tags: { nodes: packageTags() }, version: '1.0.1', ...linksData, @@ -119,6 +120,7 @@ export const packageVersions = () => [ id: 'gid://gitlab/Packages::Package/244', name: '@gitlab-org/package-15', status: 'DEFAULT', + canDestroy: true, tags: { nodes: packageTags() }, version: '1.0.2', ...linksData, diff --git a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js index 6ea78de1bfb..b494965a3cb 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js @@ -128,6 +128,7 @@ describe('PackagesApp', () => { const findDependenciesCountBadge = () => wrapper.findByTestId('dependencies-badge'); const findNoDependenciesMessage = () => wrapper.findByTestId('no-dependencies-message'); const findDependencyRows = () => wrapper.findAllComponents(DependencyRow); + const findDeletePackageModal = () => wrapper.findAllComponents(DeletePackages).at(1); const findDeletePackages = () => wrapper.findComponent(DeletePackages); afterEach(() => { @@ -267,7 +268,7 @@ describe('PackagesApp', () => { await waitForPromises(); - findDeletePackages().vm.$emit('end'); + findDeletePackageModal().vm.$emit('end'); expect(window.location.replace).toHaveBeenCalledWith( 'projectListUrl?showSuccessDeleteAlert=true', @@ -281,7 +282,7 @@ describe('PackagesApp', () => { await waitForPromises(); - findDeletePackages().vm.$emit('end'); + findDeletePackageModal().vm.$emit('end'); expect(window.location.replace).toHaveBeenCalledWith( 'groupListUrl?showSuccessDeleteAlert=true', @@ -600,9 +601,51 @@ describe('PackagesApp', () => { await waitForPromises(); expect(findVersionsList().props()).toMatchObject({ + canDestroy: true, versions: expect.arrayContaining(versionNodes), }); }); + + describe('delete packages', () => { + it('exists and has the correct props', async () => { + createComponent(); + + await waitForPromises(); + + expect(findDeletePackages().props()).toMatchObject({ + refetchQueries: [{ query: getPackageDetails, variables: {} }], + showSuccessAlert: true, + }); + }); + + it('deletePackages is bound to package-versions-list delete event', async () => { + createComponent(); + + await waitForPromises(); + + findVersionsList().vm.$emit('delete', [{ id: 1 }]); + + expect(findDeletePackages().emitted('start')).toEqual([[]]); + }); + + it('start and end event set loading correctly', async () => { + createComponent(); + + await waitForPromises(); + + findDeletePackages().vm.$emit('start'); + + await nextTick(); + + expect(findVersionsList().props('isLoading')).toBe(true); + + findDeletePackages().vm.$emit('end'); + + await nextTick(); + + expect(findVersionsList().props('isLoading')).toBe(false); + }); + }); }); describe('dependency links', () => { diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js index bfff0c2fedf..40d3a291074 100644 --- a/spec/frontend/ref/components/ref_selector_spec.js +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -1,5 +1,4 @@ -import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem, GlDropdown, GlIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { GlLoadingIcon, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; @@ -8,13 +7,13 @@ import Vuex from 'vuex'; import commit from 'test_fixtures/api/commits/commit.json'; import branches from 'test_fixtures/api/branches/branches.json'; import tags from 'test_fixtures/api/tags/tags.json'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { trimText } from 'helpers/text_helper'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK, } from '~/lib/utils/http_status'; -import { ENTER_KEY } from '~/lib/utils/keys'; import { sprintf } from '~/locale'; import RefSelector from '~/ref/components/ref_selector.vue'; import { @@ -42,7 +41,7 @@ describe('Ref selector component', () => { let requestSpies; const createComponent = (mountOverrides = {}, propsData = {}) => { - wrapper = mount( + wrapper = mountExtended( RefSelector, merge( { @@ -57,9 +56,6 @@ describe('Ref selector component', () => { wrapper.setProps({ value: selectedRef }); }, }, - stubs: { - GlSearchBoxByType: true, - }, store: createStore(), }, mountOverrides, @@ -91,76 +87,63 @@ describe('Ref selector component', () => { .reply((config) => commitApiCallSpy(config)); }); - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - // // Finders // - const findButtonContent = () => wrapper.find('button'); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + + const findButtonToggle = () => wrapper.findByTestId('base-dropdown-toggle'); - const findNoResults = () => wrapper.find('[data-testid="no-results"]'); + const findNoResults = () => wrapper.findByTestId('listbox-no-results-text'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findListBoxSection = (section) => { + const foundSections = wrapper + .findAll('[role="group"]') + .filter((ul) => ul.text().includes(section)); + return foundSections.length > 0 ? foundSections.at(0) : foundSections; + }; + + const findErrorListWrapper = () => wrapper.findByTestId('red-selector-error-list'); - const findBranchesSection = () => wrapper.find('[data-testid="branches-section"]'); - const findBranchDropdownItems = () => findBranchesSection().findAllComponents(GlDropdownItem); - const findFirstBranchDropdownItem = () => findBranchDropdownItems().at(0); + const findBranchesSection = () => findListBoxSection('Branches'); + const findBranchDropdownItems = () => wrapper.findAllComponents(GlListboxItem); - const findTagsSection = () => wrapper.find('[data-testid="tags-section"]'); - const findTagDropdownItems = () => findTagsSection().findAllComponents(GlDropdownItem); - const findFirstTagDropdownItem = () => findTagDropdownItems().at(0); + const findTagsSection = () => findListBoxSection('Tags'); - const findCommitsSection = () => wrapper.find('[data-testid="commits-section"]'); - const findCommitDropdownItems = () => findCommitsSection().findAllComponents(GlDropdownItem); - const findFirstCommitDropdownItem = () => findCommitDropdownItems().at(0); + const findCommitsSection = () => findListBoxSection('Commits'); - const findHiddenInputField = () => wrapper.find('[data-testid="selected-ref-form-field"]'); + const findHiddenInputField = () => wrapper.findByTestId('selected-ref-form-field'); // // Expecters // - const branchesSectionContainsErrorMessage = () => { - const branchesSection = findBranchesSection(); + const sectionContainsErrorMessage = (message) => { + const errorSection = findErrorListWrapper(); - return branchesSection.text().includes(DEFAULT_I18N.branchesErrorMessage); - }; - - const tagsSectionContainsErrorMessage = () => { - const tagsSection = findTagsSection(); - - return tagsSection.text().includes(DEFAULT_I18N.tagsErrorMessage); - }; - - const commitsSectionContainsErrorMessage = () => { - const commitsSection = findCommitsSection(); - - return commitsSection.text().includes(DEFAULT_I18N.commitsErrorMessage); + return errorSection ? errorSection.text().includes(message) : false; }; // // Convenience methods // const updateQuery = (newQuery) => { - findSearchBox().vm.$emit('input', newQuery); + findListbox().vm.$emit('search', newQuery); }; const selectFirstBranch = async () => { - findFirstBranchDropdownItem().vm.$emit('click'); + findListbox().vm.$emit('select', fixtures.branches[0].name); await nextTick(); }; const selectFirstTag = async () => { - findFirstTagDropdownItem().vm.$emit('click'); + findListbox().vm.$emit('select', fixtures.tags[0].name); await nextTick(); }; const selectFirstCommit = async () => { - findFirstCommitDropdownItem().vm.$emit('click'); + findListbox().vm.$emit('select', fixtures.commit.id); await nextTick(); }; @@ -195,7 +178,7 @@ describe('Ref selector component', () => { }); describe('when name property is provided', () => { - it('renders an forrm input hidden field', () => { + it('renders an form input hidden field', () => { const name = 'default_tag'; createComponent({ propsData: { name } }); @@ -205,7 +188,7 @@ describe('Ref selector component', () => { }); describe('when name property is not provided', () => { - it('renders an forrm input hidden field', () => { + it('renders an form input hidden field', () => { createComponent(); expect(findHiddenInputField().exists()).toBe(false); @@ -224,7 +207,7 @@ describe('Ref selector component', () => { }); it('adds the provided ID to the GlDropdown instance', () => { - expect(wrapper.findComponent(GlDropdown).attributes().id).toBe(id); + expect(findListbox().attributes().id).toBe(id); }); }); @@ -238,7 +221,7 @@ describe('Ref selector component', () => { }); it('renders the pre-selected ref name', () => { - expect(findButtonContent().text()).toBe(preselectedRef); + expect(findButtonToggle().text()).toBe(preselectedRef); }); it('binds hidden input field to the pre-selected ref', () => { @@ -259,7 +242,7 @@ describe('Ref selector component', () => { wrapper.setProps({ value: updatedRef }); await nextTick(); - expect(findButtonContent().text()).toBe(updatedRef); + expect(findButtonToggle().text()).toBe(updatedRef); }); }); @@ -296,23 +279,6 @@ describe('Ref selector component', () => { }); }); - describe('when the Enter is pressed', () => { - beforeEach(() => { - createComponent(); - - return waitForRequests({ andClearMocks: true }); - }); - - it('requeries the endpoints when Enter is pressed', () => { - findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - - return waitForRequests().then(() => { - expect(branchesApiCallSpy).toHaveBeenCalledTimes(1); - expect(tagsApiCallSpy).toHaveBeenCalledTimes(1); - }); - }); - }); - describe('when no results are found', () => { beforeEach(() => { branchesApiCallSpy = jest @@ -357,27 +323,10 @@ describe('Ref selector component', () => { it('renders the branches section in the dropdown', () => { expect(findBranchesSection().exists()).toBe(true); - expect(findBranchesSection().props('shouldShowCheck')).toBe(true); - }); - - it('renders the "Branches" heading with a total number indicator', () => { - expect( - findBranchesSection().find('[data-testid="section-header"]').text(), - ).toMatchInterpolatedText('Branches 123'); }); it("does not render an error message in the branches section's body", () => { - expect(branchesSectionContainsErrorMessage()).toBe(false); - }); - - it('renders each non-default branch as a selectable item', () => { - const dropdownItems = findBranchDropdownItems(); - - fixtures.branches.forEach((b, i) => { - if (!b.default) { - expect(dropdownItems.at(i).text()).toBe(b.name); - } - }); + expect(findErrorListWrapper().exists()).toBe(false); }); it('renders the default branch as a selectable item with a "default" badge', () => { @@ -418,11 +367,11 @@ describe('Ref selector component', () => { }); it('renders the branches section in the dropdown', () => { - expect(findBranchesSection().exists()).toBe(true); + expect(findBranchesSection().exists()).toBe(false); }); it("renders an error message in the branches section's body", () => { - expect(branchesSectionContainsErrorMessage()).toBe(true); + expect(sectionContainsErrorMessage(DEFAULT_I18N.branchesErrorMessage)).toBe(true); }); }); }); @@ -437,25 +386,16 @@ describe('Ref selector component', () => { it('renders the tags section in the dropdown', () => { expect(findTagsSection().exists()).toBe(true); - expect(findTagsSection().props('shouldShowCheck')).toBe(true); }); it('renders the "Tags" heading with a total number indicator', () => { - expect( - findTagsSection().find('[data-testid="section-header"]').text(), - ).toMatchInterpolatedText('Tags 456'); + expect(findTagsSection().find('[role="presentation"]').text()).toMatchInterpolatedText( + `Tags ${fixtures.tags.length}`, + ); }); it("does not render an error message in the tags section's body", () => { - expect(tagsSectionContainsErrorMessage()).toBe(false); - }); - - it('renders each tag as a selectable item', () => { - const dropdownItems = findTagDropdownItems(); - - fixtures.tags.forEach((t, i) => { - expect(dropdownItems.at(i).text()).toBe(t.name); - }); + expect(findErrorListWrapper().exists()).toBe(false); }); }); @@ -485,11 +425,11 @@ describe('Ref selector component', () => { }); it('renders the tags section in the dropdown', () => { - expect(findTagsSection().exists()).toBe(true); + expect(findTagsSection().exists()).toBe(false); }); it("renders an error message in the tags section's body", () => { - expect(tagsSectionContainsErrorMessage()).toBe(true); + expect(sectionContainsErrorMessage(DEFAULT_I18N.tagsErrorMessage)).toBe(true); }); }); }); @@ -509,19 +449,13 @@ describe('Ref selector component', () => { }); it('renders the "Commits" heading with a total number indicator', () => { - expect( - findCommitsSection().find('[data-testid="section-header"]').text(), - ).toMatchInterpolatedText('Commits 1'); - }); - - it("does not render an error message in the comits section's body", () => { - expect(commitsSectionContainsErrorMessage()).toBe(false); + expect(findCommitsSection().find('[role="presentation"]').text()).toMatchInterpolatedText( + `Commits 1`, + ); }); - it('renders each commit as a selectable item with the short SHA and commit title', () => { - const dropdownItems = findCommitDropdownItems(); - - expect(dropdownItems.at(0).text()).toBe(`${commit.short_id} ${commit.title}`); + it("does not render an error message in the commits section's body", () => { + expect(findErrorListWrapper().exists()).toBe(false); }); }); @@ -553,11 +487,11 @@ describe('Ref selector component', () => { }); it('renders the commits section in the dropdown', () => { - expect(findCommitsSection().exists()).toBe(true); + expect(findCommitsSection().exists()).toBe(false); }); it("renders an error message in the commits section's body", () => { - expect(commitsSectionContainsErrorMessage()).toBe(true); + expect(sectionContainsErrorMessage(DEFAULT_I18N.commitsErrorMessage)).toBe(true); }); }); }); @@ -571,26 +505,13 @@ describe('Ref selector component', () => { return waitForRequests(); }); - it('renders a checkmark by the selected item', async () => { - expect(findFirstBranchDropdownItem().findComponent(GlIcon).element).toHaveClass( - 'gl-visibility-hidden', - ); - - await selectFirstBranch(); - - expect(findFirstBranchDropdownItem().findComponent(GlIcon).element).not.toHaveClass( - 'gl-visibility-hidden', - ); - }); - - describe('when a branch is seleceted', () => { + describe('when a branch is selected', () => { it("displays the branch name in the dropdown's button", async () => { - expect(findButtonContent().text()).toBe(DEFAULT_I18N.noRefSelected); + expect(findButtonToggle().text()).toBe(DEFAULT_I18N.noRefSelected); await selectFirstBranch(); - await nextTick(); - expect(findButtonContent().text()).toBe(fixtures.branches[0].name); + expect(findButtonToggle().text()).toBe(fixtures.branches[0].name); }); it("updates the v-model binding with the branch's name", async () => { @@ -604,12 +525,11 @@ describe('Ref selector component', () => { describe('when a tag is seleceted', () => { it("displays the tag name in the dropdown's button", async () => { - expect(findButtonContent().text()).toBe(DEFAULT_I18N.noRefSelected); + expect(findButtonToggle().text()).toBe(DEFAULT_I18N.noRefSelected); await selectFirstTag(); - await nextTick(); - expect(findButtonContent().text()).toBe(fixtures.tags[0].name); + expect(findButtonToggle().text()).toBe(fixtures.tags[0].name); }); it("updates the v-model binding with the tag's name", async () => { @@ -623,12 +543,11 @@ describe('Ref selector component', () => { describe('when a commit is selected', () => { it("displays the full SHA in the dropdown's button", async () => { - expect(findButtonContent().text()).toBe(DEFAULT_I18N.noRefSelected); + expect(findButtonToggle().text()).toBe(DEFAULT_I18N.noRefSelected); await selectFirstCommit(); - await nextTick(); - expect(findButtonContent().text()).toBe(fixtures.commit.id); + expect(findButtonToggle().text()).toBe(fixtures.commit.id); }); it("updates the v-model binding with the commit's full SHA", async () => { @@ -688,21 +607,6 @@ describe('Ref selector component', () => { expect(tagsApiCallSpy).toHaveBeenCalledTimes(1); }); - it('if a ref type becomes disabled, its section is hidden, even if it had some results in store', async () => { - createComponent({ propsData: { enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_COMMITS] } }); - updateQuery('abcd1234'); - await waitForRequests(); - - expect(findBranchesSection().exists()).toBe(true); - expect(findCommitsSection().exists()).toBe(true); - - wrapper.setProps({ enabledRefTypes: [REF_TYPE_COMMITS] }); - await waitForRequests(); - - expect(findBranchesSection().exists()).toBe(false); - expect(findCommitsSection().exists()).toBe(true); - }); - it.each` enabledRefType | findVisibleSection | findHiddenSections ${REF_TYPE_BRANCHES} | ${findBranchesSection} | ${[findTagsSection, findCommitsSection]} @@ -726,8 +630,7 @@ describe('Ref selector component', () => { describe('validation state', () => { const invalidClass = 'gl-inset-border-1-red-500!'; - const isInvalidClassApplied = () => - wrapper.findComponent(GlDropdown).props('toggleClass')[0][invalidClass]; + const isInvalidClassApplied = () => findListbox().props('toggleClass')[0][invalidClass]; describe('valid state', () => { describe('when the state prop is not provided', () => { diff --git a/spec/frontend/ref/format_refs_spec.js b/spec/frontend/ref/format_refs_spec.js new file mode 100644 index 00000000000..6dd49574721 --- /dev/null +++ b/spec/frontend/ref/format_refs_spec.js @@ -0,0 +1,38 @@ +import { formatListBoxItems, formatErrors } from '~/ref/format_refs'; +import { DEFAULT_I18N } from '~/ref/constants'; +import { + MOCK_BRANCHES, + MOCK_COMMITS, + MOCK_ERROR, + MOCK_TAGS, + FORMATTED_BRANCHES, + FORMATTED_TAGS, + FORMATTED_COMMITS, +} from './mock_data'; + +describe('formatListBoxItems', () => { + it.each` + branches | tags | commits | expectedResult + ${MOCK_BRANCHES} | ${MOCK_TAGS} | ${MOCK_COMMITS} | ${[FORMATTED_BRANCHES, FORMATTED_TAGS, FORMATTED_COMMITS]} + ${MOCK_BRANCHES} | ${[]} | ${MOCK_COMMITS} | ${[FORMATTED_BRANCHES, FORMATTED_COMMITS]} + ${[]} | ${[]} | ${MOCK_COMMITS} | ${[FORMATTED_COMMITS]} + ${undefined} | ${undefined} | ${MOCK_COMMITS} | ${[FORMATTED_COMMITS]} + ${MOCK_BRANCHES} | ${undefined} | ${null} | ${[FORMATTED_BRANCHES]} + `('should correctly format listbox items', ({ branches, tags, commits, expectedResult }) => { + expect(formatListBoxItems(branches, tags, commits)).toEqual(expectedResult); + }); +}); + +describe('formatErrors', () => { + const { branchesErrorMessage, tagsErrorMessage, commitsErrorMessage } = DEFAULT_I18N; + it.each` + branches | tags | commits | expectedResult + ${MOCK_ERROR} | ${MOCK_ERROR} | ${MOCK_ERROR} | ${[branchesErrorMessage, tagsErrorMessage, commitsErrorMessage]} + ${MOCK_ERROR} | ${[]} | ${MOCK_ERROR} | ${[branchesErrorMessage, commitsErrorMessage]} + ${[]} | ${[]} | ${MOCK_ERROR} | ${[commitsErrorMessage]} + ${undefined} | ${undefined} | ${MOCK_ERROR} | ${[commitsErrorMessage]} + ${MOCK_ERROR} | ${undefined} | ${null} | ${[branchesErrorMessage]} + `('should correctly format listbox errors', ({ branches, tags, commits, expectedResult }) => { + expect(formatErrors(branches, tags, commits)).toEqual(expectedResult); + }); +}); diff --git a/spec/frontend/ref/mock_data.js b/spec/frontend/ref/mock_data.js new file mode 100644 index 00000000000..c02d4da7aed --- /dev/null +++ b/spec/frontend/ref/mock_data.js @@ -0,0 +1,87 @@ +export const MOCK_BRANCHES = [ + { + default: true, + name: 'main', + value: undefined, + }, + { + default: false, + name: 'test1', + value: undefined, + }, + { + default: false, + name: 'test2', + value: undefined, + }, +]; + +export const MOCK_TAGS = [ + { + name: 'test_tag', + value: undefined, + }, + { + name: 'test_tag2', + value: undefined, + }, +]; + +export const MOCK_COMMITS = [ + { + name: 'test_commit', + value: undefined, + }, +]; + +export const FORMATTED_BRANCHES = { + text: 'Branches', + options: [ + { + default: true, + text: 'main', + value: 'main', + }, + { + default: false, + text: 'test1', + value: 'test1', + }, + { + default: false, + text: 'test2', + value: 'test2', + }, + ], +}; + +export const FORMATTED_TAGS = { + text: 'Tags', + options: [ + { + text: 'test_tag', + value: 'test_tag', + default: undefined, + }, + { + text: 'test_tag2', + value: 'test_tag2', + default: undefined, + }, + ], +}; + +export const FORMATTED_COMMITS = { + text: 'Commits', + options: [ + { + text: 'test_commit', + value: 'test_commit', + default: undefined, + }, + ], +}; + +export const MOCK_ERROR = { + error: new Error('test_error'), +}; diff --git a/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_spec.js.snap b/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_spec.js.snap new file mode 100644 index 00000000000..26c9a6f8d5a --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_spec.js.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Chunk component rendering isHighlighted is true renders line numbers 1`] = ` +<div + class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" + data-testid="line-numbers" +> + <a + class="gl-user-select-none gl-shadow-none! file-line-blame" + href="some/blame/path.js#L71" + /> + + <a + class="gl-user-select-none gl-shadow-none! file-line-num" + data-line-number="71" + href="#L71" + id="L71" + > + + 71 + + </a> +</div> +`; diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js new file mode 100644 index 00000000000..95ef11d776a --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js @@ -0,0 +1,87 @@ +import { nextTick } from 'vue'; +import { GlIntersectionObserver } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; +import { CHUNK_1, CHUNK_2 } from '../mock_data'; + +describe('Chunk component', () => { + let wrapper; + let idleCallbackSpy; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(Chunk, { + propsData: { ...CHUNK_1, ...props }, + provide: { glFeatures: { fileLineBlame: true } }, + }); + }; + + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); + const findLineNumbers = () => wrapper.findAllByTestId('line-numbers'); + const findContent = () => wrapper.findByTestId('content'); + + beforeEach(() => { + idleCallbackSpy = jest.spyOn(window, 'requestIdleCallback').mockImplementation((fn) => fn()); + createComponent(); + }); + + afterEach(() => wrapper.destroy()); + + describe('Intersection observer', () => { + it('renders an Intersection observer component', () => { + expect(findIntersectionObserver().exists()).toBe(true); + }); + + it('renders highlighted content if appear event is emitted', async () => { + createComponent({ chunkIndex: 1, isHighlighted: false }); + findIntersectionObserver().vm.$emit('appear'); + + await nextTick(); + + expect(findContent().exists()).toBe(true); + }); + }); + + describe('rendering', () => { + it('does not register window.requestIdleCallback for the first chunk, renders content immediately', () => { + jest.clearAllMocks(); + + expect(window.requestIdleCallback).not.toHaveBeenCalled(); + expect(findContent().text()).toBe(CHUNK_1.highlightedContent); + }); + + it('does not render content if browser is not in idle state', () => { + idleCallbackSpy.mockRestore(); + createComponent({ chunkIndex: 1, ...CHUNK_2 }); + + expect(findLineNumbers()).toHaveLength(0); + expect(findContent().exists()).toBe(false); + }); + + describe('isHighlighted is false', () => { + beforeEach(() => createComponent(CHUNK_2)); + + it('does not render line numbers', () => { + expect(findLineNumbers()).toHaveLength(0); + }); + + it('renders raw content', () => { + expect(findContent().text()).toBe(CHUNK_2.rawContent); + }); + }); + + describe('isHighlighted is true', () => { + beforeEach(() => createComponent({ ...CHUNK_2, isHighlighted: true })); + + it('renders line numbers', () => { + expect(findLineNumbers()).toHaveLength(CHUNK_2.totalLines); + + // Opted for a snapshot test here since the output is simple and verifies native HTML elements + expect(findLineNumbers().at(0).element).toMatchSnapshot(); + }); + + it('renders highlighted content', () => { + expect(findContent().text()).toBe(CHUNK_2.highlightedContent); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/mock_data.js b/spec/frontend/vue_shared/components/source_viewer/mock_data.js new file mode 100644 index 00000000000..f35e9607d5c --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/mock_data.js @@ -0,0 +1,24 @@ +const path = 'some/path.js'; +const blamePath = 'some/blame/path.js'; + +export const LANGUAGE_MOCK = 'docker'; + +export const BLOB_DATA_MOCK = { language: LANGUAGE_MOCK, path, blamePath }; + +export const CHUNK_1 = { + isHighlighted: true, + rawContent: 'chunk 1 raw', + highlightedContent: 'chunk 1 highlighted', + totalLines: 70, + startingFrom: 0, + blamePath, +}; + +export const CHUNK_2 = { + isHighlighted: false, + rawContent: 'chunk 2 raw', + highlightedContent: 'chunk 2 highlighted', + totalLines: 40, + startingFrom: 70, + blamePath, +}; diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js new file mode 100644 index 00000000000..1c75442b4a8 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js @@ -0,0 +1,47 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; +import { EVENT_ACTION, EVENT_LABEL_VIEWER } from '~/vue_shared/components/source_viewer/constants'; +import Tracking from '~/tracking'; +import addBlobLinksTracking from '~/blob/blob_links_tracking'; +import { BLOB_DATA_MOCK, CHUNK_1, CHUNK_2, LANGUAGE_MOCK } from './mock_data'; + +jest.mock('~/blob/blob_links_tracking'); + +describe('Source Viewer component', () => { + let wrapper; + const CHUNKS_MOCK = [CHUNK_1, CHUNK_2]; + + const createComponent = () => { + wrapper = shallowMountExtended(SourceViewer, { + propsData: { blob: BLOB_DATA_MOCK, chunks: CHUNKS_MOCK }, + }); + }; + + const findChunks = () => wrapper.findAllComponents(Chunk); + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + return createComponent(); + }); + + afterEach(() => wrapper.destroy()); + + describe('event tracking', () => { + it('fires a tracking event when the component is created', () => { + const eventData = { label: EVENT_LABEL_VIEWER, property: LANGUAGE_MOCK }; + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + }); + + it('adds blob links tracking', () => { + expect(addBlobLinksTracking).toHaveBeenCalled(); + }); + }); + + describe('rendering', () => { + it('renders a Chunk component for each chunk', () => { + expect(findChunks().at(0).props()).toMatchObject(CHUNK_1); + expect(findChunks().at(1).props()).toMatchObject(CHUNK_2); + }); + }); +}); diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb index a0f5ee1ea95..0fcf36ca9dd 100644 --- a/spec/lib/api/helpers_spec.rb +++ b/spec/lib/api/helpers_spec.rb @@ -821,7 +821,7 @@ RSpec.describe API::Helpers, feature_category: :not_owned do it 'redirects to a CDN-fronted URL' do expect(helper).to receive(:redirect) - expect(helper).to receive(:signed_head_url).and_call_original + expect(ObjectStorage::S3).to receive(:signed_head_url).and_call_original expect(Gitlab::ApplicationContext).to receive(:push).with(artifact: artifact.file.model).and_call_original subject diff --git a/spec/lib/gitlab/pages/cache_control_spec.rb b/spec/lib/gitlab/pages/cache_control_spec.rb index dd15aa87441..3ffc1998ebc 100644 --- a/spec/lib/gitlab/pages/cache_control_spec.rb +++ b/spec/lib/gitlab/pages/cache_control_spec.rb @@ -13,15 +13,23 @@ RSpec.describe Gitlab::Pages::CacheControl, feature_category: :pages do end it 'clears the cache' do + cached_keys = [ + "pages_domain_for_#{type}_1_settings-hash", + "pages_domain_for_#{type}_1" + ] + + expect(::Gitlab::AppLogger) + .to receive(:info) + .with( + message: 'clear pages cache', + keys: cached_keys, + type: type, + id: 1 + ) + expect(Rails.cache) .to receive(:delete_multi) - .with( - array_including( - [ - "pages_domain_for_#{type}_1", - "pages_domain_for_#{type}_1_settings-hash" - ] - )) + .with(cached_keys) subject.clear_cache end @@ -31,13 +39,13 @@ RSpec.describe Gitlab::Pages::CacheControl, feature_category: :pages do describe '.for_namespace' do subject(:cache_control) { described_class.for_namespace(1) } - it_behaves_like 'cache_control', 'namespace' + it_behaves_like 'cache_control', :namespace end describe '.for_domain' do subject(:cache_control) { described_class.for_domain(1) } - it_behaves_like 'cache_control', 'domain' + it_behaves_like 'cache_control', :domain end describe '#cache_key' do diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index 95b87c26c01..01d5fe7f90b 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Ci::Runner, feature_category: :runner do +RSpec.describe Ci::Runner, type: :model, feature_category: :runner do include StubGitlabCalls it_behaves_like 'having unique enum values' @@ -85,6 +85,7 @@ RSpec.describe Ci::Runner, feature_category: :runner do describe 'validation' do it { is_expected.to validate_presence_of(:access_level) } it { is_expected.to validate_presence_of(:runner_type) } + it { is_expected.to validate_presence_of(:registration_type) } context 'when runner is not allowed to pick untagged jobs' do context 'when runner does not have tags' do @@ -1748,7 +1749,7 @@ RSpec.describe Ci::Runner, feature_category: :runner do end context 'when creating new runner via UI' do - let(:runner) { create(:ci_runner, :created_via_ui) } + let(:runner) { create(:ci_runner, registration_type: :authenticated_user) } specify { expect(runner.token).to start_with(described_class::CREATED_RUNNER_TOKEN_PREFIX) } it { is_expected.not_to start_with(described_class::CREATED_RUNNER_TOKEN_PREFIX) } @@ -1765,7 +1766,7 @@ RSpec.describe Ci::Runner, feature_category: :runner do end context 'when runner is created via UI' do - let(:runner) { create(:ci_runner, :created_via_ui) } + let(:runner) { create(:ci_runner, registration_type: :authenticated_user) } it { is_expected.to start_with('glrt-') } end @@ -1993,20 +1994,4 @@ RSpec.describe Ci::Runner, feature_category: :runner do end end end - - describe '#created_via_ui?' do - subject(:created_via_ui) { runner.created_via_ui? } - - context 'when runner registered from command line' do - let(:runner) { create(:ci_runner) } - - it { is_expected.to eq false } - end - - context 'when runner created via UI' do - let(:runner) { create(:ci_runner, :created_via_ui) } - - it { is_expected.to eq true } - end - end end diff --git a/spec/requests/api/graphql/ci/runner_spec.rb b/spec/requests/api/graphql/ci/runner_spec.rb index 89a0e793431..986e3ce9e52 100644 --- a/spec/requests/api/graphql/ci/runner_spec.rb +++ b/spec/requests/api/graphql/ci/runner_spec.rb @@ -525,7 +525,8 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do let_it_be(:creator) { create(:user) } let(:created_at) { Time.current } - let(:token_prefix) { '' } + let(:token_prefix) { registration_type == :authenticated_user ? 'glrt-' : '' } + let(:registration_type) {} let(:query) do %( query { @@ -539,7 +540,8 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do let(:runner) do create(:ci_runner, :group, - groups: [group], creator: creator, created_at: created_at, token: "#{token_prefix}abc123") + groups: [group], creator: creator, created_at: created_at, + registration_type: registration_type, token: "#{token_prefix}abc123") end before_all do @@ -570,7 +572,7 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do let(:user) { creator } context 'with runner created in UI' do - let(:token_prefix) { ::Ci::Runner::CREATED_RUNNER_TOKEN_PREFIX } + let(:registration_type) { :authenticated_user } context 'with runner created in last 3 hours' do let(:created_at) { (3.hours - 1.second).ago } @@ -600,7 +602,7 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do end context 'with runner registered from command line' do - let(:token_prefix) { '' } + let(:registration_type) { :registration_token } context 'with runner created in last 3 hours' do let(:created_at) { (3.hours - 1.second).ago } @@ -614,7 +616,7 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do let(:user) { create(:admin) } context 'with runner created in UI' do - let(:token_prefix) { ::Ci::Runner::CREATED_RUNNER_TOKEN_PREFIX } + let(:registration_type) { :authenticated_user } it_behaves_like 'a protected ephemeral_authentication_token' end diff --git a/spec/requests/api/maven_packages_spec.rb b/spec/requests/api/maven_packages_spec.rb index 092eb442f1f..20aa660d95b 100644 --- a/spec/requests/api/maven_packages_spec.rb +++ b/spec/requests/api/maven_packages_spec.rb @@ -125,6 +125,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do expect_any_instance_of(Fog::AWS::Storage::Files).not_to receive(:head_url) subject + + expect(response).to have_gitlab_http_status(:redirect) end end diff --git a/spec/services/auto_merge_service_spec.rb b/spec/services/auto_merge_service_spec.rb index 043b413acff..7584e44152e 100644 --- a/spec/services/auto_merge_service_spec.rb +++ b/spec/services/auto_merge_service_spec.rb @@ -131,7 +131,7 @@ RSpec.describe AutoMergeService do subject end - context 'when the head piipeline succeeded' do + context 'when the head pipeline succeeded' do let(:pipeline_status) { :success } it 'returns failed' do diff --git a/spec/support/helpers/features/branches_helpers.rb b/spec/support/helpers/features/branches_helpers.rb index d4f96718cc0..dc4fa448167 100644 --- a/spec/support/helpers/features/branches_helpers.rb +++ b/spec/support/helpers/features/branches_helpers.rb @@ -22,15 +22,10 @@ module Spec end def select_branch(branch_name) - ref_selector = '.ref-selector' - find(ref_selector).click wait_for_requests - page.within(ref_selector) do - fill_in _('Search by Git revision'), with: branch_name - wait_for_requests - find('li', text: branch_name, match: :prefer_exact).click - end + click_button branch_name + send_keys branch_name end end end diff --git a/spec/support/helpers/features/releases_helpers.rb b/spec/support/helpers/features/releases_helpers.rb index a24b99bbe61..545e12341ef 100644 --- a/spec/support/helpers/features/releases_helpers.rb +++ b/spec/support/helpers/features/releases_helpers.rb @@ -15,17 +15,18 @@ module Spec module Helpers module Features module ReleasesHelpers + include ListboxHelpers + def select_new_tag_name(tag_name) page.within '[data-testid="tag-name-field"]' do find('button').click - wait_for_all_requests find('input[aria-label="Search or create tag"]').set(tag_name) - wait_for_all_requests click_button("Create tag #{tag_name}") + click_button tag_name end end @@ -39,7 +40,7 @@ module Spec wait_for_all_requests - click_button(branch_name.to_s) + select_listbox_item(branch_name.to_s, exact_text: true) end end diff --git a/spec/uploaders/object_storage/s3_spec.rb b/spec/uploaders/object_storage/s3_spec.rb new file mode 100644 index 00000000000..de86642c58d --- /dev/null +++ b/spec/uploaders/object_storage/s3_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ObjectStorage::S3, feature_category: :source_code_management do + describe '.signed_head_url' do + subject { described_class.signed_head_url(package_file.file) } + + let(:package_file) { create(:package_file) } + + context 'when the provider is AWS' do + before do + stub_lfs_object_storage(config: Gitlab.config.lfs.object_store.merge( + connection: { + provider: 'AWS', + aws_access_key_id: 'test', + aws_secret_access_key: 'test' + } + )) + end + + it 'generates a signed url' do + expect_next_instance_of(Fog::AWS::Storage::Files) do |instance| + expect(instance).to receive(:head_url).and_return(a_valid_url) + end + + subject + end + + it 'delegates to Fog::AWS::Storage::Files#head_url' do + expect_next_instance_of(Fog::AWS::Storage::Files) do |instance| + expect(instance).to receive(:head_url).and_return('stubbed_url') + end + + expect(subject).to eq('stubbed_url') + end + end + end +end |