diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 14352baf9ba..e754d61ec81 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -70d6aa021ebfc05d9d727a7eb4c9ff4782db4c30 +30b922784b9d0492ba525a35ec09782dd2bcace3 diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index aa735df7da5..a030797c698 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -3,9 +3,9 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; import { uniq } from 'lodash'; +import { getEmojiScoreWithIntent } from '~/emoji/utils'; import { getCookie, setCookie, scrollToElement } from '~/lib/utils/common_utils'; import * as Emoji from '~/emoji'; - import { dispose, fixTitle } from '~/tooltips'; import createFlash from './flash'; import axios from './lib/utils/axios_utils'; @@ -559,13 +559,45 @@ export class AwardsHandler { } } + getEmojiScore(emojis, value) { + const elem = $(value).find('[data-name]').get(0); + const emoji = emojis.filter((x) => x.emoji.name === elem.dataset.name)[0]; + elem.dataset.score = emoji.score; + + return emoji.score; + } + + sortEmojiElements(emojis, $elements) { + const scores = new WeakMap(); + + return $elements.sort((a, b) => { + let aScore = scores.get(a); + let bScore = scores.get(b); + + if (!aScore) { + aScore = this.getEmojiScore(emojis, a); + scores.set(a, aScore); + } + + if (!bScore) { + bScore = this.getEmojiScore(emojis, b); + scores.set(b, bScore); + } + + return aScore - bScore; + }); + } + findMatchingEmojiElements(query) { - const emojiMatches = this.emoji.searchEmoji(query).map((x) => x.emoji.name); + const matchingEmoji = this.emoji + .searchEmoji(query) + .map((x) => ({ ...x, score: getEmojiScoreWithIntent(x.emoji.name, x.score) })); + const matchingEmojiNames = matchingEmoji.map((x) => x.emoji.name); const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]'); const $matchingElements = $emojiElements.filter( - (i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0, + (i, elm) => matchingEmojiNames.indexOf(elm.dataset.name) >= 0, ); - return $matchingElements.closest('li').clone(); + return this.sortEmojiElements(matchingEmoji, $matchingElements.closest('li').clone()); } /* showMenuElement and hideMenuElement are performance optimizations. We use diff --git a/app/assets/javascripts/emoji/constants.js b/app/assets/javascripts/emoji/constants.js index a6eb4256561..7970a932095 100644 --- a/app/assets/javascripts/emoji/constants.js +++ b/app/assets/javascripts/emoji/constants.js @@ -19,3 +19,5 @@ export const CATEGORY_ROW_HEIGHT = 37; export const CACHE_VERSION_KEY = 'gl-emoji-map-version'; export const CACHE_KEY = 'gl-emoji-map'; + +export const NEUTRAL_INTENT_MULTIPLIER = 1; diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index 4fdcdcc1b04..b9392fabcbd 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -2,6 +2,7 @@ import { escape, minBy } from 'lodash'; import emojiRegexFactory from 'emoji-regex'; import emojiAliases from 'emojis/aliases.json'; import { setAttributes } from '~/lib/utils/dom_utils'; +import { getEmojiScoreWithIntent } from '~/emoji/utils'; import AccessorUtilities from '../lib/utils/accessor'; import axios from '../lib/utils/axios_utils'; import { CACHE_KEY, CACHE_VERSION_KEY, CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants'; @@ -144,6 +145,11 @@ function getNameMatch(emoji, query) { return null; } +// Sort emoji by emoji score falling back to a string comparison +export function sortEmoji(a, b) { + return a.score - b.score || a.fieldValue.localeCompare(b.fieldValue); +} + export function searchEmoji(query) { const lowercaseQuery = query ? `${query}`.toLowerCase() : ''; @@ -156,16 +162,14 @@ export function searchEmoji(query) { getDescriptionMatch(emoji, lowercaseQuery), getAliasMatch(emoji, matchingAliases), getNameMatch(emoji, lowercaseQuery), - ].filter(Boolean); + ] + .filter(Boolean) + .map((x) => ({ ...x, score: getEmojiScoreWithIntent(x.emoji.name, x.score) })); return minBy(matches, (x) => x.score); }) - .filter(Boolean); -} - -export function sortEmoji(items) { - // Sort results by index of and string comparison - return [...items].sort((a, b) => a.score - b.score || a.fieldValue.localeCompare(b.fieldValue)); + .filter(Boolean) + .sort(sortEmoji); } export const CATEGORY_NAMES = Object.keys(CATEGORY_ICON_MAP); diff --git a/app/assets/javascripts/emoji/utils.js b/app/assets/javascripts/emoji/utils.js new file mode 100644 index 00000000000..eb3dcea73c0 --- /dev/null +++ b/app/assets/javascripts/emoji/utils.js @@ -0,0 +1,8 @@ +import emojiIntents from 'emojis/intents.json'; +import { NEUTRAL_INTENT_MULTIPLIER } from '~/emoji/constants'; + +export function getEmojiScoreWithIntent(emojiName, baseScore) { + const intentMultiplier = emojiIntents[emojiName] || NEUTRAL_INTENT_MULTIPLIER; + + return 2 ** baseScore * intentMultiplier; +} diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 146255df31f..d4dafbdc94f 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -897,7 +897,7 @@ GfmAutoComplete.Emoji = { return Emoji.searchEmoji(query); }, sorter(items) { - return Emoji.sortEmoji(items); + return items.sort(Emoji.sortEmoji); }, }; // Team Members diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue index 43bf2e1a90c..0a8fec49aac 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue @@ -1,5 +1,5 @@ - diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue index 870355e884e..bd5d28dbb56 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -1,8 +1,20 @@ - diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index c2bb635e056..a90ef2d3530 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -10,6 +10,7 @@ import RegistrationDropdown from '../components/registration/registration_dropdo import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerBulkDelete from '../components/runner_bulk_delete.vue'; import RunnerList from '../components/runner_list.vue'; +import RunnerListEmptyState from '../components/runner_list_empty_state.vue'; import RunnerName from '../components/runner_name.vue'; import RunnerStats from '../components/stat/runner_stats.vue'; import RunnerPagination from '../components/runner_pagination.vue'; @@ -35,6 +36,7 @@ import { fromUrlQueryToSearch, fromSearchToUrl, fromSearchToVariables, + isSearchFiltered, } from '../runner_search_utils'; import { captureException } from '../sentry_utils'; @@ -91,6 +93,7 @@ export default { RunnerFilteredSearchBar, RunnerBulkDelete, RunnerList, + RunnerListEmptyState, RunnerName, RunnerStats, RunnerPagination, @@ -98,7 +101,7 @@ export default { RunnerActionsCell, }, mixins: [glFeatureFlagMixin()], - inject: ['localMutations'], + inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath', 'localMutations'], props: { registrationToken: { type: String, @@ -190,6 +193,9 @@ export default { // Rollout issue: https://gitlab.com/gitlab-org/gitlab/-/issues/353981 return this.glFeatures.adminRunnersBulkDelete; }, + isSearchFiltered() { + return isSearchFiltered(this.search); + }, }, watch: { search: { @@ -298,9 +304,13 @@ export default { :stale-runners-count="staleRunnersTotal" /> -
foo bar
- static: | -foo bar
- wysiwyg: | -foo bar
+ canonical: | +foo bar
+ static: | +foo bar
+ wysiwyg: | +foo bar
``` NOTE: diff --git a/doc/operations/incident_management/alerts.md b/doc/operations/incident_management/alerts.md index 008e41f5d64..af42571f82f 100644 --- a/doc/operations/incident_management/alerts.md +++ b/doc/operations/incident_management/alerts.md @@ -205,7 +205,7 @@ To assign an alert: ![Alert Details View Assignees](img/alert_details_assignees_v13_1.png) 1. If the right sidebar is not expanded, select - **Expand sidebar** (**{angle-double-right}**) to expand it. + **Expand sidebar** (**{chevron-double-lg-right}**) to expand it. 1. On the right sidebar, locate the **Assignee**, and then select **Edit**. From the list, select each user you want to assign to the alert. diff --git a/doc/user/project/integrations/pipeline_status_emails.md b/doc/user/project/integrations/pipeline_status_emails.md index 742ab977090..c58f5a13613 100644 --- a/doc/user/project/integrations/pipeline_status_emails.md +++ b/doc/user/project/integrations/pipeline_status_emails.md @@ -21,3 +21,6 @@ To enable pipeline status emails: **Notify only broken pipelines**. 1. Select the branches to send notifications for. 1. Select **Save changes**. + +In [GitLab 15.1](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89546) +and later, pipeline notifications triggered by blocked users are not delivered. diff --git a/doc/user/project/integrations/webhook_events.md b/doc/user/project/integrations/webhook_events.md index 465a51def7c..1d0cd3cdbbd 100644 --- a/doc/user/project/integrations/webhook_events.md +++ b/doc/user/project/integrations/webhook_events.md @@ -1050,6 +1050,9 @@ Pipeline events are triggered when the status of a pipeline changes. In [GitLab 13.9](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53159) and later, the pipeline webhook returns only the latest jobs. +In [GitLab 15.1](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89546) +and later, pipeline webhooks triggered by blocked users are not processed. + Request header: ```plaintext @@ -1310,6 +1313,9 @@ Job events are triggered when the status of a job changes. The `commit.id` in the payload is the ID of the pipeline, not the ID of the commit. +In [GitLab 15.1](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89546) +and later, job events triggered by blocked users are not processed. + Request header: ```plaintext diff --git a/doc/user/project/merge_requests/img/ff_merge_rebase_v14_9.png b/doc/user/project/merge_requests/img/ff_merge_rebase_v14_9.png deleted file mode 100644 index 17ce42e7a69..00000000000 Binary files a/doc/user/project/merge_requests/img/ff_merge_rebase_v14_9.png and /dev/null differ diff --git a/doc/user/project/merge_requests/methods/index.md b/doc/user/project/merge_requests/methods/index.md index 12e49e43fae..d8b4644b1b8 100644 --- a/doc/user/project/merge_requests/methods/index.md +++ b/doc/user/project/merge_requests/methods/index.md @@ -87,8 +87,6 @@ method selected, you can accept it **only if a fast-forward merge is possible**. ## Rebasing in (semi-)linear merge methods -> Rebasing without running a CI/CD pipeline [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/118825) in GitLab 14.7. - In these merge methods, you can merge only when your source branch is up-to-date with the target branch: - Merge commit with semi-linear history. @@ -96,11 +94,7 @@ In these merge methods, you can merge only when your source branch is up-to-date If a fast-forward merge is not possible but a conflict-free rebase is possible, GitLab offers you the [`/rebase` quick action](../../../../topics/git/git_rebase.md#rebase-from-the-gitlab-ui), -and the ability to **Rebase** from the user interface: - -![Fast forward merge request](../img/ff_merge_rebase_v14_9.png) - -In [GitLab 14.7](https://gitlab.com/gitlab-org/gitlab/-/issues/118825) and later, you can also rebase without running a CI/CD pipeline. +and the ability to select **Rebase** from the user interface. If the target branch is ahead of the source branch and a conflict-free rebase is not possible, you must rebase the source branch locally before you can do a fast-forward merge. @@ -110,6 +104,23 @@ not possible, you must rebase the source branch locally before you can do a fast Rebasing may be required before squashing, even though squashing can itself be considered equivalent to rebasing. +### Rebase without CI/CD pipeline + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/118825) in GitLab 14.7 [with a flag](../../../../administration/feature_flags.md) named `rebase_without_ci_ui`. Disabled by default. + +FLAG: +On GitLab.com and self-managed GitLab, by default this feature is not available. To make it available, +ask an administrator to [enable the feature flag](../../../../administration/feature_flags.md) named `rebase_without_ci_ui`. +The feature is not ready for production use. + +To rebase a merge request's branch without triggering a CI/CD pipeline, select +**Rebase without pipeline** from the merge request reports section. +This option is available when fast-forward merge is not possible but a conflict-free +rebase is possible. + +Rebasing without a CI/CD pipeline saves resources in projects with a semi-linear +workflow that requires frequent rebases. + ## Related topics - [Commits history](../commits.md) diff --git a/fixtures/emojis/intents.json b/fixtures/emojis/intents.json new file mode 100644 index 00000000000..8e8b02aaddb --- /dev/null +++ b/fixtures/emojis/intents.json @@ -0,0 +1,16 @@ +{ + "thumbsdown": 1.5, + "thumbsdown_tone1": 1.5, + "thumbsdown_tone2": 1.5, + "thumbsdown_tone3": 1.5, + "thumbsdown_tone4": 1.5, + "thumbsdown_tone5": 1.5, + "thumbsup": 0.5, + "thumbsup_tone1": 0.5, + "thumbsup_tone2": 0.5, + "thumbsup_tone3": 0.5, + "thumbsup_tone4": 0.5, + "thumbsup_tone5": 0.5, + "slight_frown": 1.5, + "slight_smile": 0.5 +} diff --git a/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects.rb b/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects.rb new file mode 100644 index 00000000000..e85b1bc402a --- /dev/null +++ b/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # Set `project_settings.legacy_open_source_license_available` to false for non-public projects + class SetLegacyOpenSourceLicenseAvailableForNonPublicProjects < ::Gitlab::BackgroundMigration::BatchedMigrationJob + PUBLIC = 20 + + # Migration only version of `project_settings` table + class ProjectSetting < ApplicationRecord + self.table_name = 'project_settings' + end + + def perform + each_sub_batch( + operation_name: :set_legacy_open_source_license_available, + batching_scope: ->(relation) { relation.where.not(visibility_level: PUBLIC) } + ) do |sub_batch| + ProjectSetting.where(project_id: sub_batch).update_all(legacy_open_source_license_available: false) + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/chain/limit/rate_limit.rb b/lib/gitlab/ci/pipeline/chain/limit/rate_limit.rb index c6d6c5dc14d..af5cc7fe523 100644 --- a/lib/gitlab/ci/pipeline/chain/limit/rate_limit.rb +++ b/lib/gitlab/ci/pipeline/chain/limit/rate_limit.rb @@ -7,6 +7,7 @@ module Gitlab module Limit class RateLimit < Chain::Base include Chain::Helpers + include ::Gitlab::Utils::StrongMemoize def perform! # We exclude child-pipelines from the rate limit because they represent @@ -41,7 +42,9 @@ module Gitlab commit_sha: command.sha, current_user_id: current_user.id, subscription_plan: project.actual_plan_name, - message: 'Activated pipeline creation rate limit' + message: 'Activated pipeline creation rate limit', + throttled: enforce_throttle?, + throttle_override: throttle_override? ) end @@ -50,9 +53,16 @@ module Gitlab end def enforce_throttle? - ::Feature.enabled?( - :ci_enforce_throttle_pipelines_creation, - project) + strong_memoize(:enforce_throttle) do + ::Feature.enabled?(:ci_enforce_throttle_pipelines_creation, project) && + !throttle_override? + end + end + + def throttle_override? + strong_memoize(:throttle_override) do + ::Feature.enabled?(:ci_enforce_throttle_pipelines_creation_override, project) + end end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index fa56971d6f6..d7cd925aff6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -17232,7 +17232,7 @@ msgstr "" msgid "GitLab is open source software to collaborate on code." msgstr "" -msgid "GitLab is undergoing maintenance and is operating in read-only mode." +msgid "GitLab is undergoing maintenance" msgstr "" msgid "GitLab logo" @@ -19088,6 +19088,9 @@ msgstr "" msgid "IDE|Review" msgstr "" +msgid "IDE|Start a new merge request" +msgstr "" + msgid "IDE|Successful commit" msgstr "" @@ -25758,9 +25761,6 @@ msgstr "" msgid "No runner executable" msgstr "" -msgid "No runners found" -msgstr "" - msgid "No schedules" msgstr "" @@ -32765,6 +32765,9 @@ msgstr[1] "" msgid "Runners|A capacity of 1 enables warm HA through Auto Scaling group re-spawn. A capacity of 2 enables hot HA because the service is available even when a node is lost. A capacity of 3 or more enables hot HA and manual scaling of runner fleet." msgstr "" +msgid "Runners|A new version is available" +msgstr "" + msgid "Runners|A periodic background task deletes runners that haven't contacted GitLab in more than %{elapsedTime}. %{linkStart}Can I view how many runners were deleted?%{linkEnd}" msgstr "" @@ -32866,6 +32869,9 @@ msgstr "" msgid "Runners|Download latest binary" msgstr "" +msgid "Runners|Edit your search and try again" +msgstr "" + msgid "Runners|Enable stale runner cleanup" msgstr "" @@ -32878,6 +32884,9 @@ msgstr "" msgid "Runners|Executor" msgstr "" +msgid "Runners|Get started with runners" +msgstr "" + msgid "Runners|Group" msgstr "" @@ -32926,6 +32935,9 @@ msgstr "" msgid "Runners|New registration token generated!" msgstr "" +msgid "Runners|No results found" +msgstr "" + msgid "Runners|No spot. Default choice for Windows Shell executor." msgstr "" @@ -33069,6 +33081,9 @@ msgstr "" msgid "Runners|Runners" msgstr "" +msgid "Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner." +msgstr "" + msgid "Runners|Runs untagged jobs" msgstr "" @@ -33134,6 +33149,9 @@ msgstr "" msgid "Runners|This runner is available to all projects and subgroups in a group." msgstr "" +msgid "Runners|This runner is outdated, an upgrade is recommended" +msgstr "" + msgid "Runners|To install Runner in Kubernetes follow the instructions described in the GitLab documentation." msgstr "" @@ -33203,6 +33221,12 @@ msgstr "" msgid "Runners|stale" msgstr "" +msgid "Runners|upgrade available" +msgstr "" + +msgid "Runners|upgrade recommended" +msgstr "" + msgid "Running" msgstr "" @@ -36268,9 +36292,6 @@ msgstr "" msgid "Start a new discussion…" msgstr "" -msgid "Start a new merge request" -msgstr "" - msgid "Start a new merge request with these changes" msgstr "" @@ -38748,9 +38769,6 @@ msgstr "" msgid "This GitLab instance is licensed at the %{insufficient_license} tier. Geo is only available for users who have at least a Premium license." msgstr "" -msgid "This GitLab instance is undergoing maintenance and is operating in read-only mode." -msgstr "" - msgid "This PDF is too large to display. Please download to view." msgstr "" diff --git a/package.json b/package.json index 7abec7eacf7..1ba5944e93d 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@babel/preset-env": "^7.10.1", "@gitlab/at.js": "1.5.7", "@gitlab/favicon-overlay": "2.0.0", - "@gitlab/svgs": "2.17.0", + "@gitlab/svgs": "2.18.0", "@gitlab/ui": "40.7.1", "@gitlab/visual-review-tools": "1.7.3", "@rails/actioncable": "6.1.4-7", diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb index e1a1e2bbb2d..d312965f6cf 100644 --- a/spec/features/admin/admin_runners_spec.rb +++ b/spec/features/admin/admin_runners_spec.rb @@ -115,13 +115,17 @@ RSpec.describe "Admin Runners" do expect(page).not_to have_content("runner-bar") end - it 'shows no runner when description does not match' do - input_filtered_search_keys('runner-baz') + context 'when description does not match' do + before do + input_filtered_search_keys('runner-baz') + end - expect(page).to have_link('All 0') - expect(page).to have_link('Instance 0') + it_behaves_like 'shows no runners found' - expect(page).to have_text 'No runners found' + it 'shows no runner' do + expect(page).to have_link('All 0') + expect(page).to have_link('Instance 0') + end end end @@ -190,14 +194,6 @@ RSpec.describe "Admin Runners" do expect(page).not_to have_content 'runner-never-contacted' end - it 'shows no runner when status does not match' do - input_filtered_search_filter_is_only('Status', 'Stale') - - expect(page).to have_link('All 0') - - expect(page).to have_text 'No runners found' - end - it 'shows correct runner when status is selected and search term is entered' do input_filtered_search_filter_is_only('Status', 'Online') input_filtered_search_keys('runner-1') @@ -225,6 +221,18 @@ RSpec.describe "Admin Runners" do expect(page).to have_selector '.badge', text: 'never contacted' end end + + context 'when status does not match' do + before do + input_filtered_search_filter_is_only('Status', 'Stale') + end + + it_behaves_like 'shows no runners found' + + it 'shows no runner' do + expect(page).to have_link('All 0') + end + end end describe 'filter by type' do @@ -273,21 +281,6 @@ RSpec.describe "Admin Runners" do end end - it 'shows no runner when type does not match' do - visit admin_runners_path - - page.within('[data-testid="runner-type-tabs"]') do - click_on 'Instance' - - expect(page).to have_link('Instance', class: 'active') - end - - expect(page).not_to have_content 'runner-project' - expect(page).not_to have_content 'runner-group' - - expect(page).to have_text 'No runners found' - end - it 'shows correct runner when type is selected and search term is entered' do create(:ci_runner, :project, description: 'runner-2-project', projects: [project]) @@ -327,6 +320,24 @@ RSpec.describe "Admin Runners" do expect(page).not_to have_content 'runner-group' expect(page).not_to have_content 'runner-paused-project' end + + context 'when type does not match' do + before do + visit admin_runners_path + page.within('[data-testid="runner-type-tabs"]') do + click_on 'Instance' + + expect(page).to have_link('Instance', class: 'active') + end + end + + it_behaves_like 'shows no runners found' + + it 'shows no runner' do + expect(page).not_to have_content 'runner-project' + expect(page).not_to have_content 'runner-group' + end + end end describe 'filter by tag' do @@ -358,15 +369,6 @@ RSpec.describe "Admin Runners" do expect(page).not_to have_content 'runner-red' end - it 'shows no runner when tag does not match' do - visit admin_runners_path - - input_filtered_search_filter_is_only('Tags', 'green') - - expect(page).not_to have_content 'runner-blue' - expect(page).to have_text 'No runners found' - end - it 'shows correct runner when tag is selected and search term is entered' do create(:ci_runner, :instance, description: 'runner-2-blue', tag_list: ['blue']) @@ -384,6 +386,19 @@ RSpec.describe "Admin Runners" do expect(page).not_to have_content 'runner-blue' expect(page).not_to have_content 'runner-red' end + + context 'when tag does not match' do + before do + visit admin_runners_path + input_filtered_search_filter_is_only('Tags', 'green') + end + + it_behaves_like 'shows no runners found' + + it 'shows no runner' do + expect(page).not_to have_content 'runner-blue' + end + end end it 'sorts by last contact date' do @@ -419,7 +434,7 @@ RSpec.describe "Admin Runners" do visit admin_runners_path end - it_behaves_like "shows no runners" + it_behaves_like 'shows no runners registered' it 'shows tabs with total counts equal to 0' do expect(page).to have_link('All 0') diff --git a/spec/features/groups/group_runners_spec.rb b/spec/features/groups/group_runners_spec.rb index 1d821edefa3..ec088f60b80 100644 --- a/spec/features/groups/group_runners_spec.rb +++ b/spec/features/groups/group_runners_spec.rb @@ -33,7 +33,7 @@ RSpec.describe "Group Runners" do visit group_runners_path(group) end - it_behaves_like "shows no runners" + it_behaves_like 'shows no runners registered' it 'shows tabs with total counts equal to 0' do expect(page).to have_link('All 0') @@ -70,6 +70,18 @@ RSpec.describe "Group Runners" do expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, group_runner)) end end + + context 'when description does not match' do + before do + input_filtered_search_keys('runner-baz') + end + + it_behaves_like 'shows no runners found' + + it 'shows no runner' do + expect(page).not_to have_content 'runner-foo' + end + end end context "with an online project runner" do diff --git a/spec/finders/crm/contacts_finder_spec.rb b/spec/finders/crm/contacts_finder_spec.rb index 151af1ad825..14f838812a6 100644 --- a/spec/finders/crm/contacts_finder_spec.rb +++ b/spec/finders/crm/contacts_finder_spec.rb @@ -66,5 +66,83 @@ RSpec.describe Crm::ContactsFinder do expect(subject).to be_empty end end + + context 'with search informations' do + let_it_be(:search_test_group) { create(:group, :crm_enabled) } + + let_it_be(:search_test_a) do + create( + :contact, + group: search_test_group, + first_name: "ABC", + last_name: "DEF", + email: "ghi@test.com", + description: "LMNO", + state: "inactive" + ) + end + + let_it_be(:search_test_b) do + create( + :contact, + group: search_test_group, + first_name: "PQR", + last_name: "STU", + email: "vwx@test.com", + description: "YZ", + state: "active" + ) + end + + before do + search_test_group.add_developer(user) + end + + context 'when search term is empty' do + it 'returns all group contacts alphabetically ordered' do + finder = described_class.new(user, group: search_test_group, search: "") + expect(finder.execute).to eq([search_test_a, search_test_b]) + end + end + + context 'when search term is not empty' do + it 'searches for first name ignoring casing' do + finder = described_class.new(user, group: search_test_group, search: "aBc") + expect(finder.execute).to match_array([search_test_a]) + end + + it 'searches for last name ignoring casing' do + finder = described_class.new(user, group: search_test_group, search: "StU") + expect(finder.execute).to match_array([search_test_b]) + end + + it 'searches for email' do + finder = described_class.new(user, group: search_test_group, search: "ghi") + expect(finder.execute).to match_array([search_test_a]) + end + + it 'searches for description ignoring casing' do + finder = described_class.new(user, group: search_test_group, search: "Yz") + expect(finder.execute).to match_array([search_test_b]) + end + + it 'fuzzy searches for email and last name' do + finder = described_class.new(user, group: search_test_group, search: "s") + expect(finder.execute).to match_array([search_test_a, search_test_b]) + end + end + + context 'when searching for contacts state' do + it 'returns only inactive contacts' do + finder = described_class.new(user, group: search_test_group, state: :inactive) + expect(finder.execute).to match_array([search_test_a]) + end + + it 'returns only active contacts' do + finder = described_class.new(user, group: search_test_group, state: :active) + expect(finder.execute).to match_array([search_test_b]) + end + end + end end end diff --git a/spec/finders/crm/organizations_finder_spec.rb b/spec/finders/crm/organizations_finder_spec.rb new file mode 100644 index 00000000000..94b5d9e5874 --- /dev/null +++ b/spec/finders/crm/organizations_finder_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Crm::OrganizationsFinder do + let_it_be(:user) { create(:user) } + + describe '#execute' do + subject { described_class.new(user, group: group).execute } + + context 'when customer relations feature is enabled for the group' do + let_it_be(:root_group) { create(:group, :crm_enabled) } + let_it_be(:group) { create(:group, parent: root_group) } + + let_it_be(:organization_1) { create(:organization, group: root_group) } + let_it_be(:organization_2) { create(:organization, group: root_group) } + + context 'when user does not have permissions to see organizations in the group' do + it 'returns an empty array' do + expect(subject).to be_empty + end + end + + context 'when user is member of the root group' do + before do + root_group.add_developer(user) + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(customer_relations: false) + end + + it 'returns an empty array' do + expect(subject).to be_empty + end + end + + context 'when feature flag is enabled' do + it 'returns all group organizations' do + expect(subject).to match_array([organization_1, organization_2]) + end + end + end + + context 'when user is member of the sub group' do + before do + group.add_developer(user) + end + + it 'returns an empty array' do + expect(subject).to be_empty + end + end + end + + context 'when customer relations feature is disabled for the group' do + let_it_be(:group) { create(:group) } + let_it_be(:organization) { create(:organization, group: group) } + + before do + group.add_developer(user) + end + + it 'returns an empty array' do + expect(subject).to be_empty + end + end + + context 'with search informations' do + let_it_be(:search_test_group) { create(:group, :crm_enabled) } + + let_it_be(:search_test_a) do + create( + :organization, + group: search_test_group, + name: "DEF", + description: "ghi_st", + state: "inactive" + ) + end + + let_it_be(:search_test_b) do + create( + :organization, + group: search_test_group, + name: "ABC_st", + description: "JKL", + state: "active" + ) + end + + before do + search_test_group.add_developer(user) + end + + context 'when search term is empty' do + it 'returns all group organizations alphabetically ordered' do + finder = described_class.new(user, group: search_test_group, search: "") + expect(finder.execute).to eq([search_test_b, search_test_a]) + end + end + + context 'when search term is not empty' do + it 'searches for name' do + finder = described_class.new(user, group: search_test_group, search: "aBc") + expect(finder.execute).to match_array([search_test_b]) + end + + it 'searches for description' do + finder = described_class.new(user, group: search_test_group, search: "ghI") + expect(finder.execute).to match_array([search_test_a]) + end + + it 'searches for name and description' do + finder = described_class.new(user, group: search_test_group, search: "_st") + expect(finder.execute).to eq([search_test_b, search_test_a]) + end + end + + context 'when searching for organizations state' do + it 'returns only inactive organizations' do + finder = described_class.new(user, group: search_test_group, state: :inactive) + expect(finder.execute).to match_array([search_test_a]) + end + + it 'returns only active organizations' do + finder = described_class.new(user, group: search_test_group, state: :active) + expect(finder.execute).to match_array([search_test_b]) + end + end + end + end +end diff --git a/spec/frontend/__helpers__/emoji.js b/spec/frontend/__helpers__/emoji.js index 014a7854024..6c9291bdc8f 100644 --- a/spec/frontend/__helpers__/emoji.js +++ b/spec/frontend/__helpers__/emoji.js @@ -58,6 +58,16 @@ export const validEmoji = { unicodeVersion: '6.0', description: 'because it contains multiple zero width joiners', }, + thumbsup: { + moji: '👍', + unicodeVersion: '6.0', + description: 'thumbs up sign', + }, + thumbsdown: { + moji: '👎', + description: 'thumbs down sign', + unicodeVersion: '6.0', + }, }; export const invalidEmoji = { diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js index 5d657745615..b14bc5122b9 100644 --- a/spec/frontend/awards_handler_spec.js +++ b/spec/frontend/awards_handler_spec.js @@ -57,6 +57,18 @@ describe('AwardsHandler', () => { d: 'white question mark ornament', u: '6.0', }, + thumbsup: { + c: 'people', + e: '👍', + d: 'thumbs up sign', + u: '6.0', + }, + thumbsdown: { + c: 'people', + e: '👎', + d: 'thumbs down sign', + u: '6.0', + }, }; const openAndWaitForEmojiMenu = (sel = '.js-add-award') => { @@ -296,6 +308,23 @@ describe('AwardsHandler', () => { awardsHandler.searchEmojis('👼'); expect($('[data-name=angel]').is(':visible')).toBe(true); }); + + it('should show positive intent emoji first', async () => { + await openAndWaitForEmojiMenu(); + + awardsHandler.searchEmojis('thumb'); + + const $menu = $('.emoji-menu'); + const $thumbsUpItem = $menu.find('[data-name=thumbsup]'); + const $thumbsDownItem = $menu.find('[data-name=thumbsdown]'); + + expect($thumbsUpItem.is(':visible')).toBe(true); + expect($thumbsDownItem.is(':visible')).toBe(true); + + expect($thumbsUpItem.parents('.emoji-menu-list-item').index()).toBeLessThan( + $thumbsDownItem.parents('.emoji-menu-list-item').index(), + ); + }); }); describe('emoji menu', () => { diff --git a/spec/frontend/emoji/index_spec.js b/spec/frontend/emoji/index_spec.js index cc037586496..dc8f50e0e4b 100644 --- a/spec/frontend/emoji/index_spec.js +++ b/spec/frontend/emoji/index_spec.js @@ -24,6 +24,7 @@ import isEmojiUnicodeSupported, { isHorceRacingSkinToneComboEmoji, isPersonZwjEmoji, } from '~/emoji/support/is_emoji_unicode_supported'; +import { NEUTRAL_INTENT_MULTIPLIER } from '~/emoji/constants'; const emptySupportMap = { personZwj: false, @@ -436,14 +437,28 @@ describe('emoji', () => { it.each([undefined, null, ''])("should return all emoji when the input is '%s'", (input) => { const search = searchEmoji(input); - const expected = Object.keys(validEmoji).map((name) => { - return { - emoji: mockEmojiData[name], - field: 'd', - fieldValue: mockEmojiData[name].d, - score: 0, - }; - }); + const expected = Object.keys(validEmoji) + .map((name) => { + let score = NEUTRAL_INTENT_MULTIPLIER; + + // Positive intent value retrieved from ~/emoji/intents.json + if (name === 'thumbsup') { + score = 0.5; + } + + // Negative intent value retrieved from ~/emoji/intents.json + if (name === 'thumbsdown') { + score = 1.5; + } + + return { + emoji: mockEmojiData[name], + field: 'd', + fieldValue: mockEmojiData[name].d, + score, + }; + }) + .sort(sortEmoji); expect(search).toEqual(expected); }); @@ -457,7 +472,7 @@ describe('emoji', () => { name: 'atom', field: 'e', fieldValue: 'atom', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, }, ], ], @@ -469,7 +484,7 @@ describe('emoji', () => { name: 'atom', field: 'alias', fieldValue: 'atom_symbol', - score: 4, + score: 16, }, ], ], @@ -481,7 +496,7 @@ describe('emoji', () => { name: 'atom', field: 'alias', fieldValue: 'atom_symbol', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, }, ], ], @@ -509,7 +524,7 @@ describe('emoji', () => { { name: 'atom', field: 'd', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, }, ], ], @@ -521,7 +536,7 @@ describe('emoji', () => { { name: 'atom', field: 'd', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, }, ], ], @@ -533,7 +548,7 @@ describe('emoji', () => { { name: 'grey_question', field: 'name', - score: 5, + score: 32, }, ], ], @@ -544,7 +559,7 @@ describe('emoji', () => { { name: 'grey_question', field: 'd', - score: 24, + score: 16777216, }, ], ], @@ -552,15 +567,15 @@ describe('emoji', () => { 'searching with query "heart"', 'heart', [ - { - name: 'black_heart', - field: 'd', - score: 6, - }, { name: 'heart', field: 'name', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, + }, + { + name: 'black_heart', + field: 'd', + score: 64, }, ], ], @@ -568,15 +583,15 @@ describe('emoji', () => { 'searching with query "HEART"', 'HEART', [ - { - name: 'black_heart', - field: 'd', - score: 6, - }, { name: 'heart', field: 'name', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, + }, + { + name: 'black_heart', + field: 'd', + score: 64, }, ], ], @@ -584,15 +599,31 @@ describe('emoji', () => { 'searching with query "star"', 'star', [ - { - name: 'custard', - field: 'd', - score: 2, - }, { name: 'star', field: 'name', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, + }, + { + name: 'custard', + field: 'd', + score: 4, + }, + ], + ], + [ + 'searching for emoji with intentions assigned', + 'thumbs', + [ + { + name: 'thumbsup', + field: 'd', + score: 0.5, + }, + { + name: 'thumbsdown', + field: 'd', + score: 1.5, }, ], ], @@ -619,10 +650,10 @@ describe('emoji', () => { [ { score: 10, fieldValue: '', emoji: { name: 'a' } }, { score: 5, fieldValue: '', emoji: { name: 'b' } }, - { score: 0, fieldValue: '', emoji: { name: 'c' } }, + { score: 1, fieldValue: '', emoji: { name: 'c' } }, ], [ - { score: 0, fieldValue: '', emoji: { name: 'c' } }, + { score: 1, fieldValue: '', emoji: { name: 'c' } }, { score: 5, fieldValue: '', emoji: { name: 'b' } }, { score: 10, fieldValue: '', emoji: { name: 'a' } }, ], @@ -630,25 +661,25 @@ describe('emoji', () => { [ 'should correctly sort by fieldValue', [ - { score: 0, fieldValue: 'y', emoji: { name: 'b' } }, - { score: 0, fieldValue: 'x', emoji: { name: 'a' } }, - { score: 0, fieldValue: 'z', emoji: { name: 'c' } }, + { score: 1, fieldValue: 'y', emoji: { name: 'b' } }, + { score: 1, fieldValue: 'x', emoji: { name: 'a' } }, + { score: 1, fieldValue: 'z', emoji: { name: 'c' } }, ], [ - { score: 0, fieldValue: 'x', emoji: { name: 'a' } }, - { score: 0, fieldValue: 'y', emoji: { name: 'b' } }, - { score: 0, fieldValue: 'z', emoji: { name: 'c' } }, + { score: 1, fieldValue: 'x', emoji: { name: 'a' } }, + { score: 1, fieldValue: 'y', emoji: { name: 'b' } }, + { score: 1, fieldValue: 'z', emoji: { name: 'c' } }, ], ], [ 'should correctly sort by score and then by fieldValue (in order)', [ { score: 5, fieldValue: 'y', emoji: { name: 'c' } }, - { score: 0, fieldValue: 'z', emoji: { name: 'a' } }, + { score: 1, fieldValue: 'z', emoji: { name: 'a' } }, { score: 5, fieldValue: 'x', emoji: { name: 'b' } }, ], [ - { score: 0, fieldValue: 'z', emoji: { name: 'a' } }, + { score: 1, fieldValue: 'z', emoji: { name: 'a' } }, { score: 5, fieldValue: 'x', emoji: { name: 'b' } }, { score: 5, fieldValue: 'y', emoji: { name: 'c' } }, ], @@ -656,7 +687,7 @@ describe('emoji', () => { ]; it.each(testCases)('%s', (_, scoredItems, expected) => { - expect(sortEmoji(scoredItems)).toEqual(expected); + expect(scoredItems.sort(sortEmoji)).toEqual(expected); }); }); }); diff --git a/spec/frontend/emoji/utils_spec.js b/spec/frontend/emoji/utils_spec.js new file mode 100644 index 00000000000..397388ca0ae --- /dev/null +++ b/spec/frontend/emoji/utils_spec.js @@ -0,0 +1,15 @@ +import { getEmojiScoreWithIntent } from '~/emoji/utils'; + +describe('Utils', () => { + describe('getEmojiScoreWithIntent', () => { + it.each` + emojiName | baseScore | finalScore + ${'thumbsup'} | ${1} | ${1} + ${'thumbsdown'} | ${1} | ${3} + ${'neutralemoji'} | ${1} | ${2} + ${'zerobaseemoji'} | ${0} | ${1} + `('returns the correct score for $emojiName', ({ emojiName, baseScore, finalScore }) => { + expect(getEmojiScoreWithIntent(emojiName, baseScore)).toBe(finalScore); + }); + }); +}); diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index e17e73a93c4..a79982fa647 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -26,6 +26,12 @@ RSpec.describe 'Runner (JavaScript fixtures)' do remove_repository(project) end + before do + allow(Gitlab::Ci::RunnerUpgradeCheck.instance) + .to receive(:check_runner_upgrade_status) + .and_return(:not_available) + end + describe do before do sign_in(admin) diff --git a/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js b/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js index 64b53264b4d..2a455c9d7c1 100644 --- a/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js @@ -1,193 +1,97 @@ -import Vue, { nextTick } from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import { projectData, branches } from 'jest/ide/mock_data'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { GlFormCheckbox } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import NewMergeRequestOption from '~/ide/components/commit_sidebar/new_merge_request_option.vue'; -import { PERMISSION_CREATE_MR } from '~/ide/constants'; import { createStore } from '~/ide/stores'; -import { - COMMIT_TO_CURRENT_BRANCH, - COMMIT_TO_NEW_BRANCH, -} from '~/ide/stores/modules/commit/constants'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -describe('create new MR checkbox', () => { +Vue.use(Vuex); + +describe('NewMergeRequestOption component', () => { let store; - let vm; + let wrapper; - const setMR = () => { - vm.$store.state.currentMergeRequestId = '1'; - vm.$store.state.projects[store.state.currentProjectId].mergeRequests[ - store.state.currentMergeRequestId - ] = { foo: 'bar' }; - }; + const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findFieldset = () => wrapper.findByTestId('new-merge-request-fieldset'); + const findTooltip = () => getBinding(findFieldset().element, 'gl-tooltip'); - const setPermissions = (permissions) => { - store.state.projects[store.state.currentProjectId].userPermissions = permissions; - }; - - const createComponent = ({ currentBranchId = 'main', createNewBranch = false } = {}) => { - const Component = Vue.extend(NewMergeRequestOption); - - vm = createComponentWithStore(Component, store); - - vm.$store.state.commit.commitAction = createNewBranch - ? COMMIT_TO_NEW_BRANCH - : COMMIT_TO_CURRENT_BRANCH; - - vm.$store.state.currentBranchId = currentBranchId; - - store.state.projects.abcproject.branches[currentBranchId] = branches.find( - (branch) => branch.name === currentBranchId, - ); - - return vm.$mount(); - }; - - const findInput = () => vm.$el.querySelector('input[type="checkbox"]'); - const findLabel = () => vm.$el.querySelector('.js-ide-commit-new-mr'); - - beforeEach(() => { + const createComponent = ({ + shouldHideNewMrOption = false, + shouldDisableNewMrOption = false, + shouldCreateMR = false, + } = {}) => { store = createStore(); - store.state.currentProjectId = 'abcproject'; - - const proj = JSON.parse(JSON.stringify(projectData)); - proj.userPermissions[PERMISSION_CREATE_MR] = true; - Vue.set(store.state.projects, 'abcproject', proj); - }); + wrapper = shallowMountExtended(NewMergeRequestOption, { + store: { + ...store, + getters: { + 'commit/shouldHideNewMrOption': shouldHideNewMrOption, + 'commit/shouldDisableNewMrOption': shouldDisableNewMrOption, + 'commit/shouldCreateMR': shouldCreateMR, + }, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - describe('for default branch', () => { - describe('is rendered when pushing to a new branch', () => { - beforeEach(() => { - createComponent({ - currentBranchId: 'main', - createNewBranch: true, - }); - }); - - it('has NO new MR', () => { - expect(vm.$el.textContent).not.toBe(''); - }); - - it('has new MR', async () => { - setMR(); - - await nextTick(); - expect(vm.$el.textContent).not.toBe(''); - }); + describe('when the `shouldHideNewMrOption` getter returns false', () => { + beforeEach(() => { + createComponent(); + jest.spyOn(store, 'dispatch').mockImplementation(); }); - describe('is NOT rendered when pushing to the same branch', () => { + it('renders an enabled new MR checkbox', () => { + expect(findCheckbox().attributes('disabled')).toBeUndefined(); + }); + + it("doesn't add `is-disabled` class to the fieldset", () => { + expect(findFieldset().classes()).not.toContain('is-disabled'); + }); + + it('dispatches toggleShouldCreateMR when clicking checkbox', () => { + findCheckbox().vm.$emit('change'); + + expect(store.dispatch).toHaveBeenCalledWith('commit/toggleShouldCreateMR', undefined); + }); + + describe('when user cannot create an MR', () => { beforeEach(() => { createComponent({ - currentBranchId: 'main', - createNewBranch: false, + shouldDisableNewMrOption: true, }); }); - it('has NO new MR', () => { - expect(vm.$el.textContent).toBe(''); + it('disables the new MR checkbox', () => { + expect(findCheckbox().attributes('disabled')).toBe('true'); }); - it('has new MR', async () => { - setMR(); + it('adds `is-disabled` class to the fieldset', () => { + expect(findFieldset().classes()).toContain('is-disabled'); + }); - await nextTick(); - expect(vm.$el.textContent).toBe(''); + it('shows a tooltip', () => { + expect(findTooltip().value).toBe(wrapper.vm.$options.i18n.tooltipText); }); }); }); - describe('for protected branch', () => { - describe('when user does not have the write access', () => { - beforeEach(() => { - createComponent({ - currentBranchId: 'protected/no-access', - }); - }); - - it('is rendered if MR does not exists', () => { - expect(vm.$el.textContent).not.toBe(''); - }); - - it('is rendered if MR exists', async () => { - setMR(); - - await nextTick(); - expect(vm.$el.textContent).not.toBe(''); - }); - }); - - describe('when user has the write access', () => { - beforeEach(() => { - createComponent({ - currentBranchId: 'protected/access', - }); - }); - - it('is rendered if MR does not exist', () => { - expect(vm.$el.textContent).not.toBe(''); - }); - - it('is hidden if MR exists', async () => { - setMR(); - - await nextTick(); - expect(vm.$el.textContent).toBe(''); - }); - }); - }); - - describe('for regular branch', () => { + describe('when the `shouldHideNewMrOption` getter returns true', () => { beforeEach(() => { createComponent({ - currentBranchId: 'regular', + shouldHideNewMrOption: true, }); }); - it('is rendered if no MR exists', () => { - expect(vm.$el.textContent).not.toBe(''); + it("doesn't render the new MR checkbox", () => { + expect(findCheckbox().exists()).toBe(false); }); - - it('is hidden if MR exists', async () => { - setMR(); - - await nextTick(); - expect(vm.$el.textContent).toBe(''); - }); - - it('shows enablded checkbox', () => { - expect(findLabel().classList.contains('is-disabled')).toBe(false); - expect(findInput().disabled).toBe(false); - }); - }); - - describe('when user cannot create MR', () => { - beforeEach(() => { - setPermissions({ [PERMISSION_CREATE_MR]: false }); - - createComponent({ currentBranchId: 'regular' }); - }); - - it('disabled checkbox', () => { - expect(findLabel().classList.contains('is-disabled')).toBe(true); - expect(findInput().disabled).toBe(true); - }); - }); - - it('dispatches toggleShouldCreateMR when clicking checkbox', () => { - createComponent({ - currentBranchId: 'regular', - }); - const el = vm.$el.querySelector('input[type="checkbox"]'); - jest.spyOn(vm.$store, 'dispatch').mockImplementation(() => {}); - el.dispatchEvent(new Event('change')); - - expect(vm.$store.dispatch.mock.calls).toEqual( - expect.arrayContaining([['commit/toggleShouldCreateMR', expect.any(Object)]]), - ); }); }); diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index 405813be4e3..3d25ad075de 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -18,6 +18,7 @@ import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; +import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue'; import RunnerStats from '~/runner/components/stat/runner_stats.vue'; import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; @@ -50,6 +51,8 @@ import { runnersDataPaginated, onlineContactTimeoutSecs, staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, } from '../mock_data'; const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; @@ -78,6 +81,7 @@ describe('AdminRunnersApp', () => { const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerList = () => wrapper.findComponent(RunnerList); + const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); @@ -106,6 +110,8 @@ describe('AdminRunnersApp', () => { localMutations, onlineContactTimeoutSecs, staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, ...provide, }, ...options, @@ -457,12 +463,28 @@ describe('AdminRunnersApp', () => { runners: { nodes: [] }, }, }); + createComponent(); await waitForPromises(); }); - it('shows a message for no results', async () => { - expect(wrapper.text()).toContain('No runners found'); + it('shows an empty state', () => { + expect(findRunnerListEmptyState().props('isSearchFiltered')).toBe(false); + }); + + describe('when a filter is selected by the user', () => { + beforeEach(async () => { + findRunnerFilteredSearchBar().vm.$emit('input', { + runnerType: null, + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], + sort: CREATED_ASC, + }); + await waitForPromises(); + }); + + it('shows an empty state for a filtered search', () => { + expect(findRunnerListEmptyState().props('isSearchFiltered')).toBe(true); + }); }); }); diff --git a/spec/frontend/runner/components/cells/runner_status_cell_spec.js b/spec/frontend/runner/components/cells/runner_status_cell_spec.js index 20a1cdf7236..0f5133d0ae2 100644 --- a/spec/frontend/runner/components/cells/runner_status_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_status_cell_spec.js @@ -1,12 +1,15 @@ -import { GlBadge } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import RunnerStatusCell from '~/runner/components/cells/runner_status_cell.vue'; + +import RunnerStatusBadge from '~/runner/components/runner_status_badge.vue'; +import RunnerPausedBadge from '~/runner/components/runner_paused_badge.vue'; import { INSTANCE_TYPE, STATUS_ONLINE, STATUS_OFFLINE } from '~/runner/constants'; -describe('RunnerTypeCell', () => { +describe('RunnerStatusCell', () => { let wrapper; - const findBadgeAt = (i) => wrapper.findAllComponents(GlBadge).at(i); + const findStatusBadge = () => wrapper.findComponent(RunnerStatusBadge); + const findPausedBadge = () => wrapper.findComponent(RunnerPausedBadge); const createComponent = ({ runner = {} } = {}) => { wrapper = mount(RunnerStatusCell, { @@ -29,7 +32,7 @@ describe('RunnerTypeCell', () => { createComponent(); expect(wrapper.text()).toMatchInterpolatedText('online'); - expect(findBadgeAt(0).text()).toBe('online'); + expect(findStatusBadge().text()).toBe('online'); }); it('Displays offline status', () => { @@ -40,7 +43,7 @@ describe('RunnerTypeCell', () => { }); expect(wrapper.text()).toMatchInterpolatedText('offline'); - expect(findBadgeAt(0).text()).toBe('offline'); + expect(findStatusBadge().text()).toBe('offline'); }); it('Displays paused status', () => { @@ -52,9 +55,7 @@ describe('RunnerTypeCell', () => { }); expect(wrapper.text()).toMatchInterpolatedText('online paused'); - - expect(findBadgeAt(0).text()).toBe('online'); - expect(findBadgeAt(1).text()).toBe('paused'); + expect(findPausedBadge().text()).toBe('paused'); }); it('Is empty when data is missing', () => { diff --git a/spec/frontend/runner/components/runner_list_empty_state_spec.js b/spec/frontend/runner/components/runner_list_empty_state_spec.js new file mode 100644 index 00000000000..59cff863106 --- /dev/null +++ b/spec/frontend/runner/components/runner_list_empty_state_spec.js @@ -0,0 +1,76 @@ +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; + +import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue'; + +const mockSvgPath = 'mock-svg-path.svg'; +const mockFilteredSvgPath = 'mock-filtered-svg-path.svg'; + +describe('RunnerListEmptyState', () => { + let wrapper; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findLink = () => wrapper.findComponent(GlLink); + const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal); + + const createComponent = ({ props, mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(RunnerListEmptyState, { + propsData: { + svgPath: mockSvgPath, + filteredSvgPath: mockFilteredSvgPath, + ...props, + }, + directives: { + GlModal: createMockDirective(), + }, + stubs: { + GlEmptyState, + GlSprintf, + GlLink, + }, + }); + }; + + describe('when search is not filtered', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders an illustration', () => { + expect(findEmptyState().props('svgPath')).toBe(mockSvgPath); + }); + + it('displays "no results" text', () => { + const title = s__('Runners|Get started with runners'); + const desc = s__( + 'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.', + ); + + expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`); + }); + + it('opens a runner registration instructions modal with a link', () => { + const { value } = getBinding(findLink().element, 'gl-modal'); + + expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + }); + }); + + describe('when search is filtered', () => { + beforeEach(() => { + createComponent({ props: { isSearchFiltered: true } }); + }); + + it('renders a "filtered search" illustration', () => { + expect(findEmptyState().props('svgPath')).toBe(mockFilteredSvgPath); + }); + + it('displays "no filtered results" text', () => { + expect(findEmptyState().text()).toContain(s__('Runners|No results found')); + expect(findEmptyState().text()).toContain(s__('Runners|Edit your search and try again')); + }); + }); +}); diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js index 52bd51a974b..eb9f85a7d0f 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -16,6 +16,7 @@ import { updateHistory } from '~/lib/utils/url_utility'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; +import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue'; import RunnerStats from '~/runner/components/stat/runner_stats.vue'; import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; @@ -48,6 +49,8 @@ import { groupRunnersCountData, onlineContactTimeoutSecs, staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, } from '../mock_data'; Vue.use(VueApollo); @@ -75,6 +78,7 @@ describe('GroupRunnersApp', () => { const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerList = () => wrapper.findComponent(RunnerList); + const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); const findRunnerRow = (id) => extendedWrapper(wrapper.findByTestId(`runner-row-${id}`)); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); @@ -103,6 +107,8 @@ describe('GroupRunnersApp', () => { provide: { onlineContactTimeoutSecs, staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, }, }); }; @@ -388,8 +394,8 @@ describe('GroupRunnersApp', () => { await waitForPromises(); }); - it('shows a message for no results', async () => { - expect(wrapper.text()).toContain('No runners found'); + it('shows an empty state', async () => { + expect(findRunnerListEmptyState().exists()).toBe(true); }); }); diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index 40854dae57a..3368fc21544 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -21,6 +21,9 @@ import groupRunnersCountData from 'test_fixtures/graphql/runner/list/group_runne export const onlineContactTimeoutSecs = 2 * 60 * 60; export const staleTimeoutSecs = 7889238; // Ruby's `3.months` +export const emptyStateSvgPath = 'emptyStateSvgPath.svg'; +export const emptyStateFilteredSvgPath = 'emptyStateFilteredSvgPath.svg'; + export { runnersData, runnersDataPaginated, diff --git a/spec/frontend/runner/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js index a3c1458ed26..1f102f86b2a 100644 --- a/spec/frontend/runner/runner_search_utils_spec.js +++ b/spec/frontend/runner/runner_search_utils_spec.js @@ -5,6 +5,7 @@ import { fromUrlQueryToSearch, fromSearchToUrl, fromSearchToVariables, + isSearchFiltered, } from '~/runner/runner_search_utils'; describe('search_params.js', () => { @@ -14,6 +15,7 @@ describe('search_params.js', () => { urlQuery: '', search: { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }, graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + isDefault: true, }, { name: 'a single status', @@ -268,7 +270,7 @@ describe('search_params.js', () => { describe('fromSearchToUrl', () => { examples.forEach(({ name, urlQuery, search }) => { it(`Converts ${name} to a url`, () => { - expect(fromSearchToUrl(search)).toEqual(`http://test.host/${urlQuery}`); + expect(fromSearchToUrl(search)).toBe(`http://test.host/${urlQuery}`); }); }); @@ -280,7 +282,7 @@ describe('search_params.js', () => { const search = { filters: [], sort: 'CREATED_DESC' }; const expectedUrl = `http://test.host/`; - expect(fromSearchToUrl(search, initalUrl)).toEqual(expectedUrl); + expect(fromSearchToUrl(search, initalUrl)).toBe(expectedUrl); }); it('When unrelated search parameter is present, it does not get removed', () => { @@ -288,7 +290,7 @@ describe('search_params.js', () => { const search = { filters: [], sort: 'CREATED_DESC' }; const expectedUrl = `http://test.host/?unrelated=UNRELATED`; - expect(fromSearchToUrl(search, initialUrl)).toEqual(expectedUrl); + expect(fromSearchToUrl(search, initialUrl)).toBe(expectedUrl); }); }); @@ -331,4 +333,16 @@ describe('search_params.js', () => { }); }); }); + + describe('isSearchFiltered', () => { + examples.forEach(({ name, search, isDefault }) => { + it(`Given ${name}, evaluates to ${isDefault ? 'not ' : ''}filtered`, () => { + expect(isSearchFiltered(search)).toBe(!isDefault); + }); + }); + + it('given a missing pagination, evaluates as not filtered', () => { + expect(isSearchFiltered({ pagination: null })).toBe(false); + }); + }); }); diff --git a/spec/frontend_integration/ide/helpers/ide_helper.js b/spec/frontend_integration/ide/helpers/ide_helper.js index 0c3786929d8..20a1e5aceb2 100644 --- a/spec/frontend_integration/ide/helpers/ide_helper.js +++ b/spec/frontend_integration/ide/helpers/ide_helper.js @@ -207,10 +207,10 @@ export const commit = async ({ newBranch = false, newMR = false, newBranchName = if (!newBranch) { const option = await screen.findByLabelText(/Commit to .+ branch/); - option.click(); + await option.click(); } else { const option = await screen.findByLabelText('Create a new branch'); - option.click(); + await option.click(); const branchNameInput = await screen.findByTestId('ide-new-branch-name'); fireEvent.input(branchNameInput, { target: { value: newBranchName } }); diff --git a/spec/graphql/resolvers/concerns/resolves_groups_spec.rb b/spec/graphql/resolvers/concerns/resolves_groups_spec.rb index bfbbae29e92..d15c8f2ee42 100644 --- a/spec/graphql/resolvers/concerns/resolves_groups_spec.rb +++ b/spec/graphql/resolvers/concerns/resolves_groups_spec.rb @@ -27,11 +27,9 @@ RSpec.describe ResolvesGroups do let_it_be(:lookahead_fields) do <<~FIELDS - contacts { nodes { id } } containerRepositoriesCount customEmoji { nodes { id } } fullPath - organizations { nodes { id } } path dependencyProxyBlobCount dependencyProxyBlobs { nodes { fileName } } diff --git a/spec/graphql/resolvers/crm/contacts_resolver_spec.rb b/spec/graphql/resolvers/crm/contacts_resolver_spec.rb new file mode 100644 index 00000000000..eba26c8c71f --- /dev/null +++ b/spec/graphql/resolvers/crm/contacts_resolver_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Crm::ContactsResolver do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group, :crm_enabled) } + + let_it_be(:contact_a) do + create( + :contact, + group: group, + first_name: "ABC", + last_name: "DEF", + email: "ghi@test.com", + description: "LMNO", + state: "inactive" + ) + end + + let_it_be(:contact_b) do + create( + :contact, + group: group, + first_name: "PQR", + last_name: "STU", + email: "vwx@test.com", + description: "YZ", + state: "active" + ) + end + + describe '#resolve' do + context 'with unauthorized user' do + it 'does not rise an error and returns no contacts' do + expect { resolve_contacts(group) }.not_to raise_error + expect(resolve_contacts(group)).to be_empty + end + end + + context 'with authorized user' do + it 'does not rise an error and returns all contacts' do + group.add_reporter(user) + + expect { resolve_contacts(group) }.not_to raise_error + expect(resolve_contacts(group)).to eq([contact_a, contact_b]) + end + end + + context 'without parent' do + it 'returns no contacts' do + expect(resolve_contacts(nil)).to be_empty + end + end + + context 'with a group parent' do + before do + group.add_developer(user) + end + + context 'when no filter is provided' do + it 'returns all the contacts' do + expect(resolve_contacts(group)).to match_array([contact_a, contact_b]) + end + end + + context 'when search term is provided' do + it 'returns the correct contacts' do + expect(resolve_contacts(group, { search: "x@test.com" })).to match_array([contact_b]) + end + end + + context 'when state is provided' do + it 'returns the correct contacts' do + expect(resolve_contacts(group, { state: :inactive })).to match_array([contact_a]) + end + end + end + end + + def resolve_contacts(parent, args = {}, context = { current_user: user }) + resolve(described_class, obj: parent, args: args, ctx: context) + end +end diff --git a/spec/graphql/resolvers/crm/organizations_resolver_spec.rb b/spec/graphql/resolvers/crm/organizations_resolver_spec.rb new file mode 100644 index 00000000000..c80caf91f90 --- /dev/null +++ b/spec/graphql/resolvers/crm/organizations_resolver_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Crm::OrganizationsResolver do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group, :crm_enabled) } + + let_it_be(:organization_a) do + create( + :organization, + group: group, + name: "ABC", + state: "inactive" + ) + end + + let_it_be(:organization_b) do + create( + :organization, + group: group, + name: "DEF", + state: "active" + ) + end + + describe '#resolve' do + context 'with unauthorized user' do + it 'does not rise an error and returns no organizations' do + expect { resolve_organizations(group) }.not_to raise_error + expect(resolve_organizations(group)).to be_empty + end + end + + context 'with authorized user' do + it 'does not rise an error and returns all organizations' do + group.add_reporter(user) + + expect { resolve_organizations(group) }.not_to raise_error + expect(resolve_organizations(group)).to eq([organization_a, organization_b]) + end + end + + context 'without parent' do + it 'returns no organizations' do + expect(resolve_organizations(nil)).to be_empty + end + end + + context 'with a group parent' do + before do + group.add_developer(user) + end + + context 'when no filter is provided' do + it 'returns all the organizations' do + expect(resolve_organizations(group)).to match_array([organization_a, organization_b]) + end + end + + context 'when search term is provided' do + it 'returns the correct organizations' do + expect(resolve_organizations(group, { search: "def" })).to match_array([organization_b]) + end + end + + context 'when state is provided' do + it 'returns the correct organizations' do + expect(resolve_organizations(group, { state: :inactive })).to match_array([organization_a]) + end + end + end + end + + def resolve_organizations(parent, args = {}, context = { current_user: user }) + resolve(described_class, obj: parent, args: args, ctx: context) + end +end diff --git a/spec/helpers/ci/runners_helper_spec.rb b/spec/helpers/ci/runners_helper_spec.rb index cf62579338f..4d1b1c7682c 100644 --- a/spec/helpers/ci/runners_helper_spec.rb +++ b/spec/helpers/ci/runners_helper_spec.rb @@ -84,12 +84,14 @@ RSpec.describe Ci::RunnersHelper do end it 'returns the data in format' do - expect(helper.admin_runners_data_attributes).to eq({ + expect(helper.admin_runners_data_attributes).to include( runner_install_help_page: 'https://docs.gitlab.com/runner/install/', registration_token: Gitlab::CurrentSettings.runners_registration_token, online_contact_timeout_secs: 7200, - stale_timeout_secs: 7889238 - }) + stale_timeout_secs: 7889238, + empty_state_svg_path: start_with('/assets/illustrations/pipelines_empty'), + empty_state_filtered_svg_path: start_with('/assets/illustrations/magnifying-glass') + ) end end @@ -130,14 +132,16 @@ RSpec.describe Ci::RunnersHelper do let(:group) { create(:group) } it 'returns group data to render a runner list' do - expect(helper.group_runners_data_attributes(group)).to eq({ + expect(helper.group_runners_data_attributes(group)).to include( registration_token: group.runners_token, group_id: group.id, group_full_path: group.full_path, runner_install_help_page: 'https://docs.gitlab.com/runner/install/', online_contact_timeout_secs: 7200, - stale_timeout_secs: 7889238 - }) + stale_timeout_secs: 7889238, + empty_state_svg_path: start_with('/assets/illustrations/pipelines_empty'), + empty_state_filtered_svg_path: start_with('/assets/illustrations/magnifying-glass') + ) end end diff --git a/spec/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects_spec.rb b/spec/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects_spec.rb new file mode 100644 index 00000000000..035ea6eadcf --- /dev/null +++ b/spec/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::SetLegacyOpenSourceLicenseAvailableForNonPublicProjects, + :migration, + schema: 20220520040416 do + let(:namespaces_table) { table(:namespaces) } + let(:projects_table) { table(:projects) } + let(:project_settings_table) { table(:project_settings) } + + subject(:perform_migration) do + described_class.new(start_id: 1, + end_id: 30, + batch_table: :projects, + batch_column: :id, + sub_batch_size: 2, + pause_ms: 0, + connection: ActiveRecord::Base.connection) + .perform + end + + let(:queries) { ActiveRecord::QueryRecorder.new { perform_migration } } + + before do + namespaces_table.create!(id: 1, name: 'namespace', path: 'namespace-path-1') + namespaces_table.create!(id: 2, name: 'namespace', path: 'namespace-path-2', type: 'Project') + namespaces_table.create!(id: 3, name: 'namespace', path: 'namespace-path-3', type: 'Project') + namespaces_table.create!(id: 4, name: 'namespace', path: 'namespace-path-4', type: 'Project') + + projects_table + .create!(id: 11, name: 'proj-1', path: 'path-1', namespace_id: 1, project_namespace_id: 2, visibility_level: 0) + projects_table + .create!(id: 12, name: 'proj-2', path: 'path-2', namespace_id: 1, project_namespace_id: 3, visibility_level: 10) + projects_table + .create!(id: 13, name: 'proj-3', path: 'path-3', namespace_id: 1, project_namespace_id: 4, visibility_level: 20) + + project_settings_table.create!(project_id: 11, legacy_open_source_license_available: true) + project_settings_table.create!(project_id: 12, legacy_open_source_license_available: true) + project_settings_table.create!(project_id: 13, legacy_open_source_license_available: true) + end + + it 'sets `legacy_open_source_license_available` attribute to false for non-public projects', :aggregate_failures do + expect(queries.count).to eq(3) + + expect(migrated_attribute(11)).to be_falsey + expect(migrated_attribute(12)).to be_falsey + expect(migrated_attribute(13)).to be_truthy + end + + def migrated_attribute(project_id) + project_settings_table.find(project_id).legacy_open_source_license_available + end +end diff --git a/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb index 6ac07d10aba..69d809aee85 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb @@ -31,6 +31,7 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::RateLimit, :freeze_time, :c context 'when the limit is exceeded' do before do stub_application_setting(pipeline_limit_per_project_user_sha: 1) + stub_feature_flags(ci_enforce_throttle_pipelines_creation_override: false) end it 'does not persist the pipeline' do @@ -52,7 +53,9 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::RateLimit, :freeze_time, :c class: described_class.name, project_id: project.id, subscription_plan: project.actual_plan_name, - commit_sha: command.sha + commit_sha: command.sha, + throttled: true, + throttle_override: false ) ) @@ -121,7 +124,42 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::RateLimit, :freeze_time, :c class: described_class.name, project_id: project.id, subscription_plan: project.actual_plan_name, - commit_sha: command.sha + commit_sha: command.sha, + throttled: false, + throttle_override: false + ) + ) + + perform + end + end + + context 'when ci_enforce_throttle_pipelines_creation_override is enabled' do + before do + stub_feature_flags(ci_enforce_throttle_pipelines_creation_override: true) + end + + it 'does not break the chain' do + perform + + expect(step.break?).to be_falsey + end + + it 'does not invalidate the pipeline' do + perform + + expect(pipeline.errors).to be_empty + end + + it 'creates a log entry' do + expect(Gitlab::AppJsonLogger).to receive(:info).with( + a_hash_including( + class: described_class.name, + project_id: project.id, + subscription_plan: project.actual_plan_name, + commit_sha: command.sha, + throttled: false, + throttle_override: true ) ) diff --git a/spec/migrations/20220520040416_schedule_set_legacy_open_source_license_available_for_non_public_projects_spec.rb b/spec/migrations/20220520040416_schedule_set_legacy_open_source_license_available_for_non_public_projects_spec.rb new file mode 100644 index 00000000000..e3bc832a10b --- /dev/null +++ b/spec/migrations/20220520040416_schedule_set_legacy_open_source_license_available_for_non_public_projects_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe ScheduleSetLegacyOpenSourceLicenseAvailableForNonPublicProjects do + context 'on gitlab.com' do + let(:migration) { described_class::MIGRATION } + + before do + allow(Gitlab).to receive(:com?).and_return(true) + end + + describe '#up' do + it 'schedules background jobs for each batch of projects' do + migrate! + + expect(migration).to( + have_scheduled_batched_migration( + table_name: :projects, + column_name: :id, + interval: described_class::INTERVAL, + batch_size: described_class::BATCH_SIZE, + sub_batch_size: described_class::SUB_BATCH_SIZE + ) + ) + end + end + + describe '#down' do + it 'deletes all batched migration records' do + migrate! + schema_migrate_down! + + expect(migration).not_to have_scheduled_batched_migration + end + end + end + + context 'on self-managed instance' do + let(:migration) { described_class.new } + + before do + allow(Gitlab).to receive(:com?).and_return(false) + end + + describe '#up' do + it 'does not schedule background job' do + expect(migration).not_to receive(:queue_batched_background_migration) + + migration.up + end + end + + describe '#down' do + it 'does not delete background job' do + expect(migration).not_to receive(:delete_batched_background_migration) + + migration.down + end + end + end +end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index b0111ba0214..8a6f45e0712 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -5071,6 +5071,18 @@ RSpec.describe Ci::Build do build.execute_hooks end + + context 'with blocked users' do + before do + allow(build).to receive(:user) { FactoryBot.build(:user, :blocked) } + end + + it 'does not call project.execute_hooks' do + expect(build.project).not_to receive(:execute_hooks) + + build.execute_hooks + end + end end context 'without project hooks' do diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 37399f29b27..7f53b765270 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -3056,7 +3056,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe 'hooks trigerring' do - let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) } + let_it_be_with_reload(:pipeline) { create(:ci_empty_pipeline, :created) } %i[ enqueue @@ -3076,7 +3076,19 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do it 'schedules a new PipelineHooksWorker job' do expect(PipelineHooksWorker).to receive(:perform_async).with(pipeline.id) - pipeline.reload.public_send(pipeline_action) + pipeline.public_send(pipeline_action) + end + + context 'with blocked users' do + before do + allow(pipeline).to receive(:user) { build(:user, :blocked) } + end + + it 'does not schedule a new PipelineHooksWorker job' do + expect(PipelineHooksWorker).not_to receive(:perform_async) + + pipeline.public_send(pipeline_action) + end end end end @@ -3636,6 +3648,18 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do pipeline.succeed! end end + + context 'when the user is blocked' do + before do + pipeline.user.block! + end + + it 'does not enqueue PipelineNotificationWorker' do + expect(PipelineNotificationWorker).not_to receive(:perform_async) + + pipeline.succeed + end + end end context 'with failed pipeline' do @@ -3656,6 +3680,18 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do pipeline.drop end + + context 'when the user is blocked' do + before do + pipeline.user.block! + end + + it 'does not enqueue PipelineNotificationWorker' do + expect(PipelineNotificationWorker).not_to receive(:perform_async) + + pipeline.drop + end + end end context 'with skipped pipeline' do diff --git a/spec/services/ci/create_pipeline_service/rate_limit_spec.rb b/spec/services/ci/create_pipeline_service/rate_limit_spec.rb index fbd8b41de63..0000296230f 100644 --- a/spec/services/ci/create_pipeline_service/rate_limit_spec.rb +++ b/spec/services/ci/create_pipeline_service/rate_limit_spec.rb @@ -11,6 +11,7 @@ RSpec.describe Ci::CreatePipelineService, :freeze_time, :clean_gitlab_redis_rate before do stub_ci_pipeline_yaml_file(gitlab_ci_yaml) stub_application_setting(pipeline_limit_per_project_user_sha: 1) + stub_feature_flags(ci_enforce_throttle_pipelines_creation_override: false) end context 'when user is under the limit' do diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb index 777162b6196..cbbed82aa0b 100644 --- a/spec/services/projects/update_pages_service_spec.rb +++ b/spec/services/projects/update_pages_service_spec.rb @@ -205,6 +205,25 @@ RSpec.describe Projects::UpdatePagesService do include_examples 'fails with outdated reference message' end end + + context 'when uploaded deployment size is wrong' do + it 'raises an error' do + allow_next_instance_of(PagesDeployment) do |deployment| + allow(deployment) + .to receive(:size) + .and_return(file.size + 1) + end + + expect do + expect(execute).not_to eq(:success) + + expect(GenericCommitStatus.last.description).to eq("Error: The uploaded artifact size does not match the expected value.") + project.pages_metadatum.reload + expect(project.pages_metadatum).not_to be_deployed + expect(project.pages_metadatum.pages_deployment).to be_ni + end.to raise_error(Projects::UpdatePagesService::WrongUploadedDeploymentSizeError) + end + end end end diff --git a/spec/support/shared_examples/features/runners_shared_examples.rb b/spec/support/shared_examples/features/runners_shared_examples.rb index 1f5d6ed5586..52f3fd60c07 100644 --- a/spec/support/shared_examples/features/runners_shared_examples.rb +++ b/spec/support/shared_examples/features/runners_shared_examples.rb @@ -62,7 +62,7 @@ RSpec.shared_examples 'shows and resets runner registration token' do end end -RSpec.shared_examples 'shows no runners' do +RSpec.shared_examples 'shows no runners registered' do it 'shows counts with 0' do expect(page).to have_text "Online runners 0" expect(page).to have_text "Offline runners 0" @@ -70,13 +70,19 @@ RSpec.shared_examples 'shows no runners' do end it 'shows "no runners" message' do - expect(page).to have_text 'No runners found' + expect(page).to have_text s_('Runners|Get started with runners') + end +end + +RSpec.shared_examples 'shows no runners found' do + it 'shows "no runners" message' do + expect(page).to have_text s_('Runners|No results found') end end RSpec.shared_examples 'shows runner in list' do it 'does not show empty state' do - expect(page).not_to have_content 'No runners found' + expect(page).not_to have_content s_('Runners|Get started with runners') end it 'shows runner row' do diff --git a/spec/workers/pipeline_hooks_worker_spec.rb b/spec/workers/pipeline_hooks_worker_spec.rb index 13a86c3d4fe..5d28b1e129a 100644 --- a/spec/workers/pipeline_hooks_worker_spec.rb +++ b/spec/workers/pipeline_hooks_worker_spec.rb @@ -25,6 +25,16 @@ RSpec.describe PipelineHooksWorker do .not_to raise_error end end + + context 'when the user is blocked' do + let(:pipeline) { create(:ci_pipeline, user: create(:user, :blocked)) } + + it 'returns early without executing' do + expect(Ci::Pipelines::HookService).not_to receive(:new) + + described_class.new.perform(pipeline.id) + end + end end it_behaves_like 'worker with data consistency', diff --git a/spec/workers/pipeline_notification_worker_spec.rb b/spec/workers/pipeline_notification_worker_spec.rb index 583c4bf1c0c..672debd0501 100644 --- a/spec/workers/pipeline_notification_worker_spec.rb +++ b/spec/workers/pipeline_notification_worker_spec.rb @@ -21,6 +21,20 @@ RSpec.describe PipelineNotificationWorker, :mailer do subject.perform(non_existing_record_id) end + context 'when the user is blocked' do + before do + expect_next_found_instance_of(Ci::Pipeline) do |pipeline| + allow(pipeline).to receive(:user) { build(:user, :blocked) } + end + end + + it 'does nothing' do + expect(NotificationService).not_to receive(:new) + + subject.perform(pipeline.id) + end + end + it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed diff --git a/yarn.lock b/yarn.lock index 1fca83a17b1..23f4d6a3a4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -963,10 +963,10 @@ stylelint-declaration-strict-value "1.8.0" stylelint-scss "4.1.0" -"@gitlab/svgs@2.17.0": - version "2.17.0" - resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-2.17.0.tgz#56d0d11744859b3e1da80dedab2396a95cd01a02" - integrity sha512-+cmn4ptdOFjSC8ByqD41kj1xSQ9/YFYLq/Es+jy5t12HmUtvYL8YRfNTlvApReSJ8SM7scwleVy4S19M15Siqw== +"@gitlab/svgs@2.18.0": + version "2.18.0" + resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-2.18.0.tgz#aafff929bc5365f7cad736b6d061895b3f9aa381" + integrity sha512-Okbm4dAAf/aiaRojUT57yfqY/TVka/zAXN4T+hOx/Yho6wUT2eAJ8CcFpctPdt3kUNM4bHU2CZYoGqklbtXkmg== "@gitlab/ui@40.7.1": version "40.7.1"