diff --git a/.gitlab/CODEOWNERS b/.gitlab/CODEOWNERS index ab9805bda7d..48bfd867d13 100644 --- a/.gitlab/CODEOWNERS +++ b/.gitlab/CODEOWNERS @@ -626,7 +626,7 @@ lib/gitlab/checks/** @proglottis @toon @zj-gitlab /doc/raketasks/x509_signatures.md @aqualls /doc/security/ @eread /doc/ssh/index.md @eread -/doc/subscriptions/ @sselhorn +/doc/subscriptions/ @fneill /doc/system_hooks/system_hooks.md @kpaizee /doc/topics/authentication/index.md @eread /doc/topics/autodevops/customize.md @marcia @@ -791,5 +791,5 @@ lib/gitlab/checks/** @proglottis @toon @zj-gitlab /doc/user/snippets.md @aqualls /doc/user/tasks.md @msedlakjakubowski /doc/user/todos.md @msedlakjakubowski -/doc/user/usage_quotas.md @sselhorn +/doc/user/usage_quotas.md @fneill /doc/user/workspace/index.md @fneill diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index fa6dcb674d7..551f0d5cc31 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -63abf93ad828f7a7924f3e0bb1fea8ea43d7c6af +c35fba1c073deed91d1a1f9f11dd668856841d80 diff --git a/app/assets/javascripts/branches/ajax_loading_spinner.js b/app/assets/javascripts/branches/ajax_loading_spinner.js deleted file mode 100644 index 79f4f919f3d..00000000000 --- a/app/assets/javascripts/branches/ajax_loading_spinner.js +++ /dev/null @@ -1,31 +0,0 @@ -import $ from 'jquery'; - -export default class AjaxLoadingSpinner { - static init() { - const $elements = $('.js-ajax-loading-spinner'); - $elements.on('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend); - } - - static ajaxBeforeSend(e) { - const button = e.target; - const newButton = document.createElement('button'); - newButton.classList.add('btn', 'btn-default', 'disabled', 'gl-button'); - newButton.setAttribute('disabled', 'disabled'); - - const spinner = document.createElement('span'); - spinner.classList.add('align-text-bottom', 'gl-spinner', 'gl-spinner-sm', 'gl-spinner-orange'); - newButton.appendChild(spinner); - - button.classList.add('hidden'); - button.parentNode.insertBefore(newButton, button.nextSibling); - - $(button).one('ajax:error', () => { - newButton.remove(); - button.classList.remove('hidden'); - }); - - $(button).one('ajax:success', () => { - $(button).off('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend); - }); - } -} diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index a8405fe37c7..7e9380ec553 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -1,7 +1,6 @@ + + diff --git a/app/models/namespace/traversal_hierarchy.rb b/app/models/namespace/traversal_hierarchy.rb index 34086a8af5d..93193cc2022 100644 --- a/app/models/namespace/traversal_hierarchy.rb +++ b/app/models/namespace/traversal_hierarchy.rb @@ -31,15 +31,21 @@ class Namespace # ActiveRecord. https://github.com/rails/rails/issues/13496 # Ideally it would be: # `incorrect_traversal_ids.update_all('traversal_ids = cte.traversal_ids')` - sql = """ - UPDATE namespaces - SET traversal_ids = cte.traversal_ids - FROM (#{recursive_traversal_ids}) as cte - WHERE namespaces.id = cte.id - AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids - """ + sql = <<-SQL + UPDATE namespaces + SET traversal_ids = cte.traversal_ids + FROM (#{recursive_traversal_ids}) as cte + WHERE namespaces.id = cte.id + AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids + SQL + Namespace.transaction do - @root.lock! + if Feature.enabled?(:for_no_key_update_lock, default_enabled: :yaml) + @root.lock!("FOR NO KEY UPDATE") + else + @root.lock! + end + Namespace.connection.exec_query(sql) end rescue ActiveRecord::Deadlocked diff --git a/app/services/error_tracking/collect_error_service.rb b/app/services/error_tracking/collect_error_service.rb index 50508c9810a..6376b743255 100644 --- a/app/services/error_tracking/collect_error_service.rb +++ b/app/services/error_tracking/collect_error_service.rb @@ -60,7 +60,7 @@ module ErrorTracking end def actor - return event['transaction'] if event['transaction'] + return event['transaction'] if event['transaction'].present? # Some SDKs do not have a transaction attribute. # So we build it by combining function name and module name from diff --git a/config/feature_flags/development/for_no_key_update_lock.yml b/config/feature_flags/development/for_no_key_update_lock.yml new file mode 100644 index 00000000000..a4ff426e876 --- /dev/null +++ b/config/feature_flags/development/for_no_key_update_lock.yml @@ -0,0 +1,8 @@ +--- +name: for_no_key_update_lock +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81239 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/353619 +milestone: '14.9' +type: development +group: group::workspaces +default_enabled: false diff --git a/config/feature_flags/development/use_received_header_for_incoming_emails.yml b/config/feature_flags/development/use_received_header_for_incoming_emails.yml new file mode 100644 index 00000000000..e466a266367 --- /dev/null +++ b/config/feature_flags/development/use_received_header_for_incoming_emails.yml @@ -0,0 +1,8 @@ +--- +name: use_received_header_for_incoming_emails +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81489 +rollout_issue_url: +milestone: '14.9' +type: development +group: group::certify +default_enabled: true diff --git a/doc/ci/examples/authenticating-with-hashicorp-vault/index.md b/doc/ci/examples/authenticating-with-hashicorp-vault/index.md index edc58684057..5fca3513ff7 100644 --- a/doc/ci/examples/authenticating-with-hashicorp-vault/index.md +++ b/doc/ci/examples/authenticating-with-hashicorp-vault/index.md @@ -121,8 +121,8 @@ Then create policies that allow you to read these secrets (one for each secret): $ vault policy write myproject-staging - < [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/27188) in GitLab 11.11. -Multiple manual actions in a single stage can be started at the same time using the "Play all manual" button. -After you click this button, each individual manual action is triggered and refreshed +Multiple manual actions in a single stage can be started at the same time using the "Play all manual" +After you select this action, each individual manual action is triggered and refreshed to an updated status. This functionality is only available: @@ -283,9 +283,9 @@ pipelines. Users with the Owner role for a project can delete a pipeline by clicking on the pipeline in the **CI/CD > Pipelines** to get to the **Pipeline Details** -page, then using the **Delete** button. +page, then selecting **Delete**. -![Pipeline Delete Button](img/pipeline-delete.png) +![Pipeline Delete](img/pipeline-delete.png) WARNING: Deleting a pipeline expires all pipeline caches, and deletes all related objects, @@ -314,7 +314,7 @@ sensitive information like deployment credentials and tokens. **Runners** marked as **protected** can run jobs only on protected branches, preventing untrusted code from executing on the protected runner and preserving deployment keys and other credentials from being unintentionally -accessed. In order to ensure that jobs intended to be executed on protected +accessed. To ensure that jobs intended to be executed on protected runners do not use regular runners, they must be tagged accordingly. ### How pipeline duration is calculated @@ -434,7 +434,7 @@ fix it. Pipeline mini graphs only display jobs by stage. -Stages in pipeline mini graphs are collapsible. Hover your mouse over them and click to expand their jobs. +Stages in pipeline mini graphs are expandable. Hover your mouse over each stage to see the name and status, and select a stage to expand its jobs list. | Mini graph | Mini graph expanded | |:-------------------------------------------------------------|:---------------------------------------------------------------| diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md index f0af65640eb..39162230cc2 100644 --- a/doc/raketasks/backup_restore.md +++ b/doc/raketasks/backup_restore.md @@ -1191,7 +1191,7 @@ has a longer discussion explaining the potential problems. To prevent writes to the Git repository data, there are two possible approaches: -- Use [maintenance mode](../administration/maintenance_mode/index.md) **(PREMIUM SELF)** to place GitLab in a read-only state. +- Use [maintenance mode](../administration/maintenance_mode/index.md) to place GitLab in a read-only state. - Create explicit downtime by stopping all Gitaly services before backing up the repositories: ```shell @@ -1354,15 +1354,13 @@ To prepare the new server: ```shell sudo rm -f /var/opt/gitlab/redis/dump.rdb - sudo chown /var/opt/gitlab/redis - sudo mkdir /var/opt/gitlab/backups - sudo chown /var/opt/gitlab/backups + sudo chown /var/opt/gitlab/redis /var/opt/gitlab/backups ``` ### Prepare and transfer content from the old server 1. Ensure you have an up-to-date system-level backup or snapshot of the old server. -1. Enable [maintenance mode](../administration/maintenance_mode/index.md) **(PREMIUM SELF)**, +1. Enable [maintenance mode](../administration/maintenance_mode/index.md), if supported by your GitLab edition. 1. Block new CI/CD jobs from starting: 1. Edit `/etc/gitlab/gitlab.rb`, and set the following: @@ -1465,7 +1463,7 @@ To prepare the new server: 1. While still under the Sidekiq dashboard, select **Cron** and then **Enable All** to re-enable periodic background jobs. 1. Test that read-only operations on the GitLab instance work as expected. For example, browse through project repository files, merge requests, and issues. -1. Disable [Maintenance Mode](../administration/maintenance_mode/index.md) **(PREMIUM SELF)**, if previously enabled. +1. Disable [Maintenance Mode](../administration/maintenance_mode/index.md), if previously enabled. 1. Test that the GitLab instance is working as expected. 1. If applicable, re-enable [incoming email](../administration/incoming_email.md) and test it is working as expected. 1. Update your DNS or load balancer to point at the new server. diff --git a/doc/raketasks/features.md b/doc/raketasks/features.md index 3248f7370e8..e2554c1c18d 100644 --- a/doc/raketasks/features.md +++ b/doc/raketasks/features.md @@ -1,34 +1,9 @@ --- -stage: Enablement -group: Distribution -info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments +redirect_to: 'index.md' +remove_date: '2022-05-24' --- -# Namespaces **(FREE SELF)** +This document was moved to [another location](index.md). -This Rake task enables [namespaces](../user/group/index.md#namespaces) for projects. - -## Enable usernames and namespaces for user projects - -This command enables the namespaces feature. It moves every project in its -namespace folder. - -The **repository location changes as part of this task**, so you must **update all your Git URLs** to -point to the new location. - -To change your username: - -1. In the top-right corner, select your avatar. -1. Select **Edit profile**. -1. On the left sidebar, select **Account**. -1. In the **Change username** section, type the new username. -1. Select **Update username**. - -For example: - -- Old path: `git@example.org:myrepo.git`. -- New path: `git@example.org:username/myrepo.git` or `git@example.org:groupname/myrepo.git`. - -```shell -bundle exec rake gitlab:enable_namespaces RAILS_ENV=production -``` + + diff --git a/doc/topics/offline/quick_start_guide.md b/doc/topics/offline/quick_start_guide.md index ecaa2fecf4d..82d7896d4d2 100644 --- a/doc/topics/offline/quick_start_guide.md +++ b/doc/topics/offline/quick_start_guide.md @@ -12,12 +12,49 @@ instance entirely offline. ## Installation NOTE: -This guide assumes the server is Ubuntu 18.04. Instructions for other servers may vary. -This guide also assumes the server host resolves as `my-host`, which you should replace with your -server's name. +This guide assumes the server is Ubuntu 20.04 using the [Omnibus installation method](https://docs.gitlab.com/omnibus/) and will be running GitLab [Enterprise Edition](https://about.gitlab.com/install/ce-or-ee/). Instructions for other servers may vary. +This guide also assumes the server host resolves as `my-host.internal`, which you should replace with your +server's FQDN, and that you have acess to a different server with Internet access to download the required package files. -Follow the installation instructions [as outlined in the omnibus install -guide](https://about.gitlab.com/install/#ubuntu), but make sure to specify an `http` + +For a video walkthrough of this process, see [Offline GitLab Installation: Downloading & Installing](https://www.youtube.com/watch?v=TJaq4ua2Prw). + +### Download the GitLab package + +You should [manually download the GitLab package](../../update/package/index.md#upgrade-using-a-manually-downloaded-package) and relevant dependencies using a server of the same operating system type that has access to the Internet. + +If your offline environment has no local network access, you must manually transport across the relevant package files through physical media, such as a USB drive or writable DVD. + +In Ubuntu, this can be performed on a server with Internet access using the following commands: + +```shell +# Download the bash script to prepare the repository +curl --silent "https://packages.gitlab.com/install/repositories/gitlab/gitlab-ee/script.deb.sh" | sudo bash + +# Download the gitlab-ee package and dependencies to /var/cache/apt/archives +sudo apt-get install --download-only gitlab-ee + +# Copy the contents of the apt download folder to a mounted media device +sudo cp /var/cache/apt/archives/*.deb /path/to/mount +``` + +### Install the GitLab package + +Prerequisites: + +- Before installing the GitLab package on your offline environment, ensure that you have installed all required dependencies first. + +If you are using Ubuntu, you can install the dependency `.deb` packages you copied across with `dpkg`. Do not install the GitLab package yet. + +```shell +# Navigate to the physical media device +sudo cd /path/to/mount + +# Install the dependency packages +sudo dpkg -i .deb +``` + +[Use the relevant commands for your operating system to install the package](../../update/package/index.md#upgrade-using-a-manually-downloaded-package) but make sure to specify an `http` URL for the `EXTERNAL_URL` installation step. Once installed, we can manually configure the SSL ourselves. @@ -25,8 +62,10 @@ It is strongly recommended to setup a domain for IP resolution rather than bind to the server's IP address. This better ensures a stable target for our certs' CN and makes long-term resolution simpler. +The following example for Ubuntu specifies the `EXTERNAL_URL` using HTTP and installs the GitLab package: + ```shell -sudo EXTERNAL_URL="http://my-host.internal" apt-get install gitlab-ee +sudo EXTERNAL_URL="http://my-host.internal" dpkg -i .deb ``` ## Enabling SSL @@ -38,7 +77,7 @@ Follow these steps to enable SSL for your fresh instance. Note that these steps ```ruby # Update external_url from "http" to "https" - external_url "https://gitlab.example.com" + external_url "https://my-host.internal" # Set Let's Encrypt to false letsencrypt['enable'] = false diff --git a/doc/user/project/issues/related_issues.md b/doc/user/project/issues/related_issues.md index f83ebc5e8a8..b8151ac873a 100644 --- a/doc/user/project/issues/related_issues.md +++ b/doc/user/project/issues/related_issues.md @@ -75,4 +75,9 @@ Access our [permissions](../../permissions.md) page for more information. When you [add a linked issue](#add-a-linked-issue), you can show that it **blocks** or **is blocked by** another issue. -Issues that block other issues have an icon (**{issue-block}**) shown in the issue lists and [boards](../issue_board.md). +Issues that block other issues have an icon (**{issue-block}**) next to their title, shown in the +issue lists and [boards](../issue_board.md). +The icon disappears when the blocking issue is closed or their relationship is changed or +[removed](#remove-a-linked-issue). + +If you try to close a blocked issue using the "Close issue" button, a confirmation message appears. diff --git a/lib/gitlab/email/receiver.rb b/lib/gitlab/email/receiver.rb index 5b2bbfbe66b..58e7b2f1b44 100644 --- a/lib/gitlab/email/receiver.rb +++ b/lib/gitlab/email/receiver.rb @@ -8,6 +8,8 @@ module Gitlab class Receiver include Gitlab::Utils::StrongMemoize + RECEIVED_HEADER_REGEX = /for\s+\<(.+)\>/.freeze + def initialize(raw) @raw = raw end @@ -37,6 +39,8 @@ module Gitlab delivered_to: delivered_to.map(&:value), envelope_to: envelope_to.map(&:value), x_envelope_to: x_envelope_to.map(&:value), + # reduced down to what looks like an email in the received headers + received_recipients: recipients_from_received_headers, meta: { client_id: "email/#{mail.from.first}", project: handler&.project&.full_path @@ -82,7 +86,8 @@ module Gitlab find_key_from_references || find_key_from_delivered_to_header || find_key_from_envelope_to_header || - find_key_from_x_envelope_to_header + find_key_from_x_envelope_to_header || + find_first_key_from_received_headers end def ensure_references_array(references) @@ -117,6 +122,10 @@ module Gitlab Array(mail[:x_envelope_to]) end + def received + Array(mail[:received]) + end + def find_key_from_delivered_to_header delivered_to.find do |header| key = email_class.key_from_address(header.value) @@ -138,6 +147,21 @@ module Gitlab end end + def find_first_key_from_received_headers + return unless ::Feature.enabled?(:use_received_header_for_incoming_emails, default_enabled: :yaml) + + recipients_from_received_headers.find do |email| + key = email_class.key_from_address(email) + break key if key + end + end + + def recipients_from_received_headers + strong_memoize :emails_from_received_headers do + received.map { |header| header.value[RECEIVED_HEADER_REGEX, 1] }.compact + end + end + def ignore_auto_reply! if auto_submitted? || auto_replied? raise AutoGeneratedEmailError diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 02d5965721c..b08b69dc275 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4738,9 +4738,6 @@ msgstr "" msgid "Are you sure you want to discard your changes?" msgstr "" -msgid "Are you sure you want to erase this build?" -msgstr "" - msgid "Are you sure you want to import %d repository?" msgid_plural "Are you sure you want to import %d repositories?" msgstr[0] "" @@ -21031,9 +21028,15 @@ msgstr "" msgid "Jobs|You're about to retry a job that failed because it attempted to deploy code that is older than the latest deployment. Retrying this job could result in overwriting the environment with the older source code." msgstr "" +msgid "Job|Are you sure you want to erase this job log and artifacts?" +msgstr "" + msgid "Job|Browse" msgstr "" +msgid "Job|Cancel" +msgstr "" + msgid "Job|Complete Raw" msgstr "" @@ -21061,6 +21064,9 @@ msgstr "" msgid "Job|Pipeline" msgstr "" +msgid "Job|Retry" +msgstr "" + msgid "Job|Scroll to bottom" msgstr "" @@ -26813,12 +26819,21 @@ msgstr "" msgid "PipelineWizardDefaultCommitMessage|Update %{filename}" msgstr "" +msgid "PipelineWizardInputValidation|At least one entry is required" +msgstr "" + msgid "PipelineWizardInputValidation|This field is required" msgstr "" msgid "PipelineWizardInputValidation|This value is not valid" msgstr "" +msgid "PipelineWizardListWidget|add another step" +msgstr "" + +msgid "PipelineWizardListWidget|remove step" +msgstr "" + msgid "PipelineWizard|Commit" msgstr "" diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index fbdba373843..90b64cde935 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -313,7 +313,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do context 'job is cancelable' do it 'shows cancel button' do - click_link 'Cancel' + find('[data-testid="cancel-button"]').click expect(page.current_path).to eq(job_url) end @@ -1031,7 +1031,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do end it 'loads the page and shows all needed controls' do - expect(page).to have_content 'Retry' + expect(page).to have_selector('[data-testid="retry-button"') end end end @@ -1049,7 +1049,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do it 'shows the right status and buttons' do page.within('aside.right-sidebar') do - expect(page).to have_content 'Cancel' + expect(page).to have_selector('[data-testid="cancel-button"') end end end diff --git a/spec/fixtures/emails/missing_delivered_to_header.eml b/spec/fixtures/emails/missing_delivered_to_header.eml new file mode 100644 index 00000000000..511f60ab719 --- /dev/null +++ b/spec/fixtures/emails/missing_delivered_to_header.eml @@ -0,0 +1,35 @@ +Return-Path: +Received: from myserver.example.com ([unix socket]) by myserver (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400 +Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700 +Received: from blabla.google.com (blabla.google.com. [1.1.1.1]) + by bla.google.com with SMTPS id something.1.1.1.1.1.1.1 + for + (Google Transport Security); + Mon, 21 Feb 2022 14:41:58 -0800 (PST) +Received: from mail.example.com (mail.example.com [IPv6:2607:f8b0:4001:c03::234]) by myserver.example.com (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for ; Thu, 13 Jun 2013 17:03:50 -0400 +From: "jake@example.com" +To: "support@example.com" +Subject: Insert hilarious subject line here +Date: Tue, 26 Nov 2019 14:22:41 +0000 +Message-ID: <7e2296f83dbf4de388cbf5f56f52c11f@EXDAG29-1.EXCHANGE.INT> +Accept-Language: de-DE, en-US +Content-Language: de-DE +X-MS-Has-Attach: +X-MS-TNEF-Correlator: +x-ms-exchange-transport-fromentityheader: Hosted +x-originating-ip: [62.96.54.178] +Content-Type: multipart/alternative; + boundary="_000_7e2296f83dbf4de388cbf5f56f52c11fEXDAG291EXCHANGEINT_" +MIME-Version: 1.0 + +--_000_7e2296f83dbf4de388cbf5f56f52c11fEXDAG291EXCHANGEINT_ +Content-Type: text/plain; charset="iso-8859-1" +Content-Transfer-Encoding: quoted-printable + + + +--_000_7e2296f83dbf4de388cbf5f56f52c11fEXDAG291EXCHANGEINT_ +Content-Type: text/html; charset="iso-8859-1" +Content-Transfer-Encoding: quoted-printable + +Look, a message with no Delivered-To header! Let's fallback to Received: in case it's there. diff --git a/spec/fixtures/emails/valid_note_on_issuable.eml b/spec/fixtures/emails/valid_note_on_issuable.eml index 29308c9d969..38b733b6a32 100644 --- a/spec/fixtures/emails/valid_note_on_issuable.eml +++ b/spec/fixtures/emails/valid_note_on_issuable.eml @@ -1,6 +1,6 @@ Return-Path: Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400 -Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for ; Thu, 13 Jun 2013 17:03:50 -0400 +Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for ; Thu, 13 Jun 2013 17:03:50 -0400 Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for ; Thu, 13 Jun 2013 14:03:48 -0700 Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700 Date: Thu, 13 Jun 2013 17:03:48 -0400 diff --git a/spec/fixtures/error_tracking/php_empty_transaction.json b/spec/fixtures/error_tracking/php_empty_transaction.json new file mode 100644 index 00000000000..fc51894145d --- /dev/null +++ b/spec/fixtures/error_tracking/php_empty_transaction.json @@ -0,0 +1,45 @@ +{ + "event_id": "dquJXuPF9sP1fMy5RpKo979xUALjNDQB", + "timestamp": 1645191605.123456, + "platform": "php", + "sdk": { + "name": "sentry.php", + "version": "3.3.7" + }, + "logger": "php", + "transaction": "", + "server_name": "oAjA5zTgIjqP", + "release": "C0FFEE", + "environment": "Development/Berlin", + "exception": { + "values": [ + { + "type": "TestException", + "value": "Sentry test exception", + "stacktrace": { + "frames": [ + { + "filename": "/src/Path/To/Class.php", + "lineno": 3, + "in_app": true, + "abs_path": "/var/www/html/src/Path/To/Class.php", + "function": "Path\\To\\Class::method", + "raw_function": "Path\\To\\Class::method", + "pre_context": [ + "// Pre-context" + ], + "context_line": "throw new TestException('Sentry test exception');", + "post_context": [ + "// Post-context" + ] + } + ] + }, + "mechanism": { + "type": "generic", + "handled": true + } + } + ] + } +} diff --git a/spec/frontend/branches/ajax_loading_spinner_spec.js b/spec/frontend/branches/ajax_loading_spinner_spec.js deleted file mode 100644 index 31cc7b99e42..00000000000 --- a/spec/frontend/branches/ajax_loading_spinner_spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner'; - -describe('Ajax Loading Spinner', () => { - let ajaxLoadingSpinnerElement; - let fauxEvent; - beforeEach(() => { - document.body.innerHTML = ` - `; - AjaxLoadingSpinner.init(); - ajaxLoadingSpinnerElement = document.querySelector('.js-ajax-loading-spinner'); - fauxEvent = { target: ajaxLoadingSpinnerElement }; - }); - - afterEach(() => { - document.body.innerHTML = ''; - }); - - it('`ajaxBeforeSend` event handler sets current icon to spinner and disables link', () => { - expect(ajaxLoadingSpinnerElement.parentNode.querySelector('.gl-spinner')).toBeNull(); - expect(ajaxLoadingSpinnerElement.classList.contains('hidden')).toBe(false); - - AjaxLoadingSpinner.ajaxBeforeSend(fauxEvent); - - expect(ajaxLoadingSpinnerElement.parentNode.querySelector('.gl-spinner')).not.toBeNull(); - expect(ajaxLoadingSpinnerElement.classList.contains('hidden')).toBe(true); - }); -}); diff --git a/spec/frontend/content_editor/components/content_editor_alert_spec.js b/spec/frontend/content_editor/components/content_editor_alert_spec.js index 2ddcd8f024e..12484cb13c6 100644 --- a/spec/frontend/content_editor/components/content_editor_alert_spec.js +++ b/spec/frontend/content_editor/components/content_editor_alert_spec.js @@ -3,20 +3,25 @@ import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue'; import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; -import { createTestEditor, emitEditorEvent } from '../test_utils'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import { ALERT_EVENT } from '~/content_editor/constants'; +import { createTestEditor } from '../test_utils'; describe('content_editor/components/content_editor_alert', () => { let wrapper; let tiptapEditor; + let eventHub; const findErrorAlert = () => wrapper.findComponent(GlAlert); const createWrapper = async () => { tiptapEditor = createTestEditor(); + eventHub = eventHubFactory(); wrapper = shallowMountExtended(ContentEditorAlert, { provide: { tiptapEditor, + eventHub, }, stubs: { EditorStateObserver, @@ -37,7 +42,9 @@ describe('content_editor/components/content_editor_alert', () => { async ({ message, variant }) => { createWrapper(); - await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message, variant } }); + eventHub.$emit(ALERT_EVENT, { message, variant }); + + await nextTick(); expect(findErrorAlert().text()).toBe(message); expect(findErrorAlert().attributes().variant).toBe(variant); @@ -48,11 +55,9 @@ describe('content_editor/components/content_editor_alert', () => { const message = 'error message'; createWrapper(); - - await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message } }); - + eventHub.$emit(ALERT_EVENT, { message }); + await nextTick(); findErrorAlert().vm.$emit('dismiss'); - await nextTick(); expect(findErrorAlert().exists()).toBe(false); diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 9a772c41e52..a713211b6f4 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -121,7 +121,7 @@ describe('ContentEditor', () => { beforeEach(async () => { createWrapper(); - contentEditor.emit(LOADING_CONTENT_EVENT); + contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT); await nextTick(); }); @@ -143,9 +143,9 @@ describe('ContentEditor', () => { beforeEach(async () => { createWrapper(); - contentEditor.emit(LOADING_CONTENT_EVENT); + contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT); await nextTick(); - contentEditor.emit(LOADING_SUCCESS_EVENT); + contentEditor.eventHub.$emit(LOADING_SUCCESS_EVENT); await nextTick(); }); @@ -164,9 +164,9 @@ describe('ContentEditor', () => { beforeEach(async () => { createWrapper(); - contentEditor.emit(LOADING_CONTENT_EVENT); + contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT); await nextTick(); - contentEditor.emit(LOADING_ERROR_EVENT, error); + contentEditor.eventHub.$emit(LOADING_ERROR_EVENT, error); await nextTick(); }); diff --git a/spec/frontend/content_editor/components/editor_state_observer_spec.js b/spec/frontend/content_editor/components/editor_state_observer_spec.js index 5e4bb348e1f..51a594a606b 100644 --- a/spec/frontend/content_editor/components/editor_state_observer_spec.js +++ b/spec/frontend/content_editor/components/editor_state_observer_spec.js @@ -3,6 +3,13 @@ import { each } from 'lodash'; import EditorStateObserver, { tiptapToComponentMap, } from '~/content_editor/components/editor_state_observer.vue'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import { + LOADING_CONTENT_EVENT, + LOADING_SUCCESS_EVENT, + LOADING_ERROR_EVENT, + ALERT_EVENT, +} from '~/content_editor/constants'; import { createTestEditor } from '../test_utils'; describe('content_editor/components/editor_state_observer', () => { @@ -11,19 +18,29 @@ describe('content_editor/components/editor_state_observer', () => { let onDocUpdateListener; let onSelectionUpdateListener; let onTransactionListener; + let onLoadingContentListener; + let onLoadingSuccessListener; + let onLoadingErrorListener; + let onAlertListener; + let eventHub; const buildEditor = () => { tiptapEditor = createTestEditor(); + eventHub = eventHubFactory(); jest.spyOn(tiptapEditor, 'on'); }; const buildWrapper = () => { wrapper = shallowMount(EditorStateObserver, { - provide: { tiptapEditor }, + provide: { tiptapEditor, eventHub }, listeners: { docUpdate: onDocUpdateListener, selectionUpdate: onSelectionUpdateListener, transaction: onTransactionListener, + [ALERT_EVENT]: onAlertListener, + [LOADING_CONTENT_EVENT]: onLoadingContentListener, + [LOADING_SUCCESS_EVENT]: onLoadingSuccessListener, + [LOADING_ERROR_EVENT]: onLoadingErrorListener, }, }); }; @@ -32,8 +49,11 @@ describe('content_editor/components/editor_state_observer', () => { onDocUpdateListener = jest.fn(); onSelectionUpdateListener = jest.fn(); onTransactionListener = jest.fn(); + onAlertListener = jest.fn(); + onLoadingSuccessListener = jest.fn(); + onLoadingContentListener = jest.fn(); + onLoadingErrorListener = jest.fn(); buildEditor(); - buildWrapper(); }); afterEach(() => { @@ -44,6 +64,8 @@ describe('content_editor/components/editor_state_observer', () => { it('emits update, selectionUpdate, and transaction events', () => { const content = '

My paragraph

'; + buildWrapper(); + tiptapEditor.commands.insertContent(content); expect(onDocUpdateListener).toHaveBeenCalledWith( @@ -58,10 +80,27 @@ describe('content_editor/components/editor_state_observer', () => { }); }); + it.each` + event | listener + ${ALERT_EVENT} | ${() => onAlertListener} + ${LOADING_CONTENT_EVENT} | ${() => onLoadingContentListener} + ${LOADING_SUCCESS_EVENT} | ${() => onLoadingSuccessListener} + ${LOADING_ERROR_EVENT} | ${() => onLoadingErrorListener} + `('listens to $event event in the eventBus object', ({ event, listener }) => { + const args = {}; + + buildWrapper(); + + eventHub.$emit(event, args); + expect(listener()).toHaveBeenCalledWith(args); + }); + describe('when component is destroyed', () => { it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => { jest.spyOn(tiptapEditor, 'off'); + buildWrapper(); + wrapper.destroy(); each(tiptapToComponentMap, (_, tiptapEvent) => { @@ -71,5 +110,25 @@ describe('content_editor/components/editor_state_observer', () => { ); }); }); + + it.each` + event + ${ALERT_EVENT} + ${LOADING_CONTENT_EVENT} + ${LOADING_SUCCESS_EVENT} + ${LOADING_ERROR_EVENT} + `('removes $event event hook from eventHub', ({ event }) => { + jest.spyOn(eventHub, '$off'); + jest.spyOn(eventHub, '$on'); + + buildWrapper(); + + wrapper.destroy(); + + expect(eventHub.$off).toHaveBeenCalledWith( + event, + eventHub.$on.mock.calls.find(([eventName]) => eventName === event)[1], + ); + }); }); }); diff --git a/spec/frontend/content_editor/components/toolbar_button_spec.js b/spec/frontend/content_editor/components/toolbar_button_spec.js index 60263c46bdd..ce50482302d 100644 --- a/spec/frontend/content_editor/components/toolbar_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_button_spec.js @@ -2,6 +2,7 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; import ToolbarButton from '~/content_editor/components/toolbar_button.vue'; +import eventHubFactory from '~/helpers/event_hub_factory'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; describe('content_editor/components/toolbar_button', () => { @@ -25,6 +26,7 @@ describe('content_editor/components/toolbar_button', () => { }, provide: { tiptapEditor, + eventHub: eventHubFactory(), }, propsData: { contentType: CONTENT_TYPE, diff --git a/spec/frontend/content_editor/components/toolbar_link_button_spec.js b/spec/frontend/content_editor/components/toolbar_link_button_spec.js index 0cf488260bd..fc26a9da471 100644 --- a/spec/frontend/content_editor/components/toolbar_link_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_link_button_spec.js @@ -1,6 +1,7 @@ import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue'; +import eventHubFactory from '~/helpers/event_hub_factory'; import Link from '~/content_editor/extensions/link'; import { hasSelection } from '~/content_editor/services/utils'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; @@ -15,6 +16,7 @@ describe('content_editor/components/toolbar_link_button', () => { wrapper = mountExtended(ToolbarLinkButton, { provide: { tiptapEditor: editor, + eventHub: eventHubFactory(), }, }); }; diff --git a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js index 65c1c8c8310..608be1bd693 100644 --- a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js +++ b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js @@ -4,6 +4,7 @@ import EditorStateObserver from '~/content_editor/components/editor_state_observ import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue'; import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants'; import Heading from '~/content_editor/extensions/heading'; +import eventHubFactory from '~/helpers/event_hub_factory'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; describe('content_editor/components/toolbar_text_style_dropdown', () => { @@ -27,6 +28,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => { }, provide: { tiptapEditor, + eventHub: eventHubFactory(), }, propsData: { ...propsData, diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js index d2d2cd98a78..e095a3d0b6a 100644 --- a/spec/frontend/content_editor/extensions/attachment_spec.js +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -5,6 +5,7 @@ import Image from '~/content_editor/extensions/image'; import Link from '~/content_editor/extensions/link'; import Loading from '~/content_editor/extensions/loading'; import httpStatus from '~/lib/utils/http_status'; +import eventHubFactory from '~/helpers/event_hub_factory'; import { createTestEditor, createDocBuilder } from '../test_utils'; const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `

@@ -25,6 +26,7 @@ describe('content_editor/extensions/attachment', () => { let link; let renderMarkdown; let mock; + let eventHub; const uploadsPath = '/uploads/'; const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' }); @@ -50,9 +52,15 @@ describe('content_editor/extensions/attachment', () => { beforeEach(() => { renderMarkdown = jest.fn(); + eventHub = eventHubFactory(); tiptapEditor = createTestEditor({ - extensions: [Loading, Link, Image, Attachment.configure({ renderMarkdown, uploadsPath })], + extensions: [ + Loading, + Link, + Image, + Attachment.configure({ renderMarkdown, uploadsPath, eventHub }), + ], }); ({ @@ -160,7 +168,7 @@ describe('content_editor/extensions/attachment', () => { it('emits an alert event that includes an error message', (done) => { tiptapEditor.commands.uploadAttachment({ file: imageFile }); - tiptapEditor.on('alert', ({ message }) => { + eventHub.$on('alert', ({ message }) => { expect(message).toBe('An error occurred while uploading the image. Please try again.'); done(); }); @@ -236,7 +244,7 @@ describe('content_editor/extensions/attachment', () => { it('emits an alert event that includes an error message', (done) => { tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); - tiptapEditor.on('alert', ({ message }) => { + eventHub.$on('alert', ({ message }) => { expect(message).toBe('An error occurred while uploading the file. Please try again.'); done(); }); diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js index e48687f1548..ac4f71a80cb 100644 --- a/spec/frontend/content_editor/services/content_editor_spec.js +++ b/spec/frontend/content_editor/services/content_editor_spec.js @@ -4,19 +4,21 @@ import { LOADING_ERROR_EVENT, } from '~/content_editor/constants'; import { ContentEditor } from '~/content_editor/services/content_editor'; - +import eventHubFactory from '~/helpers/event_hub_factory'; import { createTestEditor } from '../test_utils'; describe('content_editor/services/content_editor', () => { let contentEditor; let serializer; + let eventHub; beforeEach(() => { const tiptapEditor = createTestEditor(); jest.spyOn(tiptapEditor, 'destroy'); serializer = { deserialize: jest.fn() }; - contentEditor = new ContentEditor({ tiptapEditor, serializer }); + eventHub = eventHubFactory(); + contentEditor = new ContentEditor({ tiptapEditor, serializer, eventHub }); }); describe('.dispose', () => { @@ -34,13 +36,13 @@ describe('content_editor/services/content_editor', () => { serializer.deserialize.mockResolvedValueOnce(''); }); - it('emits loadingContent and loadingSuccess event', () => { + it('emits loadingContent and loadingSuccess event in the eventHub', () => { let loadingContentEmitted = false; - contentEditor.on(LOADING_CONTENT_EVENT, () => { + eventHub.$on(LOADING_CONTENT_EVENT, () => { loadingContentEmitted = true; }); - contentEditor.on(LOADING_SUCCESS_EVENT, () => { + eventHub.$on(LOADING_SUCCESS_EVENT, () => { expect(loadingContentEmitted).toBe(true); }); @@ -56,7 +58,7 @@ describe('content_editor/services/content_editor', () => { }); it('emits loadingError event', async () => { - contentEditor.on(LOADING_ERROR_EVENT, (e) => { + eventHub.$on(LOADING_ERROR_EVENT, (e) => { expect(e).toBe('error'); }); diff --git a/spec/frontend/jobs/components/job_log_controllers_spec.js b/spec/frontend/jobs/components/job_log_controllers_spec.js index 226322a2951..cd3ee734466 100644 --- a/spec/frontend/jobs/components/job_log_controllers_spec.js +++ b/spec/frontend/jobs/components/job_log_controllers_spec.js @@ -8,7 +8,6 @@ describe('Job log controllers', () => { afterEach(() => { if (wrapper?.destroy) { wrapper.destroy(); - wrapper = null; } }); @@ -34,7 +33,6 @@ describe('Job log controllers', () => { const findTruncatedInfo = () => wrapper.find('[data-testid="log-truncated-info"]'); const findRawLink = () => wrapper.find('[data-testid="raw-link"]'); const findRawLinkController = () => wrapper.find('[data-testid="job-raw-link-controller"]'); - const findEraseLink = () => wrapper.find('[data-testid="job-log-erase-link"]'); const findScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]'); const findScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]'); @@ -76,28 +74,6 @@ describe('Job log controllers', () => { expect(findRawLinkController().exists()).toBe(false); }); }); - - describe('when is erasable', () => { - beforeEach(() => { - createWrapper(); - }); - - it('renders erase job link', () => { - expect(findEraseLink().exists()).toBe(true); - }); - }); - - describe('when it is not erasable', () => { - beforeEach(() => { - createWrapper({ - erasePath: null, - }); - }); - - it('does not render erase button', () => { - expect(findEraseLink().exists()).toBe(false); - }); - }); }); describe('scroll buttons', () => { diff --git a/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js index 6914b8d4fa1..ad72b9be261 100644 --- a/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js +++ b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js @@ -1,5 +1,4 @@ -import { GlButton, GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import JobsSidebarRetryButton from '~/jobs/components/job_sidebar_retry_button.vue'; import createStore from '~/jobs/store'; import job from '../mock_data'; @@ -9,12 +8,12 @@ describe('Job Sidebar Retry Button', () => { let wrapper; const forwardDeploymentFailure = 'forward_deployment_failure'; - const findRetryButton = () => wrapper.find(GlButton); - const findRetryLink = () => wrapper.find(GlLink); + const findRetryButton = () => wrapper.findByTestId('retry-job-button'); + const findRetryLink = () => wrapper.findByTestId('retry-job-link'); const createWrapper = ({ props = {} } = {}) => { store = createStore(); - wrapper = shallowMount(JobsSidebarRetryButton, { + wrapper = shallowMountExtended(JobsSidebarRetryButton, { propsData: { href: job.retry_path, modalId: 'modal-id', @@ -27,7 +26,6 @@ describe('Job Sidebar Retry Button', () => { afterEach(() => { if (wrapper) { wrapper.destroy(); - wrapper = null; } }); @@ -44,7 +42,6 @@ describe('Job Sidebar Retry Button', () => { expect(findRetryButton().exists()).toBe(buttonExists); expect(findRetryLink().exists()).toBe(linkExists); - expect(wrapper.text()).toMatch('Retry'); }, ); @@ -55,6 +52,7 @@ describe('Job Sidebar Retry Button', () => { expect(findRetryButton().attributes()).toMatchObject({ category: 'primary', variant: 'confirm', + icon: 'retry', }); }); }); @@ -64,6 +62,7 @@ describe('Job Sidebar Retry Button', () => { expect(findRetryLink().attributes()).toMatchObject({ 'data-method': 'post', href: job.retry_path, + icon: 'retry', }); }); }); diff --git a/spec/frontend/jobs/components/sidebar_spec.js b/spec/frontend/jobs/components/sidebar_spec.js index 6e327725627..39c71986ce4 100644 --- a/spec/frontend/jobs/components/sidebar_spec.js +++ b/spec/frontend/jobs/components/sidebar_spec.js @@ -21,25 +21,54 @@ describe('Sidebar details block', () => { const findNewIssueButton = () => wrapper.findByTestId('job-new-issue'); const findRetryButton = () => wrapper.find(JobRetryButton); const findTerminalLink = () => wrapper.findByTestId('terminal-link'); + const findEraseLink = () => wrapper.findByTestId('job-log-erase-link'); - const createWrapper = ({ props = {} } = {}) => { + const createWrapper = (props) => { store = createStore(); store.state.job = job; wrapper = extendedWrapper( shallowMount(Sidebar, { - ...props, + propsData: { + ...props, + }, + store, }), ); }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); + }); + + describe('when job log is erasable', () => { + const path = '/root/ci-project/-/jobs/1447/erase'; + + beforeEach(() => { + createWrapper({ + erasePath: path, + }); + }); + + it('renders erase job link', () => { + expect(findEraseLink().exists()).toBe(true); + }); + + it('erase job link has correct path', () => { + expect(findEraseLink().attributes('href')).toBe(path); + }); + }); + + describe('when job log is not erasable', () => { + beforeEach(() => { + createWrapper(); + }); + + it('does not render erase button', () => { + expect(findEraseLink().exists()).toBe(false); + }); }); describe('when there is no retry path retry', () => { @@ -86,7 +115,7 @@ describe('Sidebar details block', () => { }); it('should render link to cancel job', () => { - expect(findCancelButton().text()).toMatch('Cancel'); + expect(findCancelButton().props('icon')).toBe('cancel'); expect(findCancelButton().attributes('href')).toBe(job.cancel_path); }); }); diff --git a/spec/frontend/pipeline_wizard/components/widgets/list_spec.js b/spec/frontend/pipeline_wizard/components/widgets/list_spec.js new file mode 100644 index 00000000000..796356634bc --- /dev/null +++ b/spec/frontend/pipeline_wizard/components/widgets/list_spec.js @@ -0,0 +1,212 @@ +import { GlFormGroup, GlFormInputGroup } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import ListWidget from '~/pipeline_wizard/components/widgets/list.vue'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +describe('Pipeline Wizard - List Widget', () => { + const defaultProps = { + label: 'This label', + description: 'some description', + placeholder: 'some placeholder', + pattern: '^[a-z]+$', + invalidFeedback: 'some feedback', + }; + let wrapper; + let addStepBtn; + + const findGlFormGroup = () => wrapper.findComponent(GlFormGroup); + const findGlFormGroupInvalidFeedback = () => findGlFormGroup().find('.invalid-feedback').text(); + const findFirstGlFormInputGroup = () => wrapper.findComponent(GlFormInputGroup); + const findAllGlFormInputGroups = () => wrapper.findAllComponents(GlFormInputGroup); + const findGlFormInputGroupByIndex = (index) => findAllGlFormInputGroups().at(index); + const setValueOnInputField = (value, atIndex = 0) => { + return findGlFormInputGroupByIndex(atIndex).vm.$emit('input', value); + }; + const findAddStepButton = () => wrapper.findByTestId('add-step-button'); + const addStep = () => findAddStepButton().vm.$emit('click'); + + const createComponent = (props = {}, mountFn = shallowMountExtended) => { + wrapper = mountFn(ListWidget, { + propsData: { + ...defaultProps, + ...props, + }, + }); + addStepBtn = findAddStepButton(); + }; + + describe('component setup and interface', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('prints the label inside the legend', () => { + createComponent(); + + expect(findGlFormGroup().attributes('label')).toBe(defaultProps.label); + }); + + it('prints the description inside the legend', () => { + createComponent(); + + expect(findGlFormGroup().attributes('labeldescription')).toBe(defaultProps.description); + }); + + it('sets the input field type attribute to "text"', async () => { + createComponent(); + + expect(findFirstGlFormInputGroup().attributes('type')).toBe('text'); + }); + + it('passes the placeholder to the first input field', () => { + createComponent(); + + expect(findFirstGlFormInputGroup().attributes('placeholder')).toBe(defaultProps.placeholder); + }); + + it('shows a delete button on all fields if there are more than one', async () => { + createComponent({}, mountExtended); + + await addStep(); + await addStep(); + const inputGroups = findAllGlFormInputGroups().wrappers; + + expect(inputGroups.length).toBe(3); + inputGroups.forEach((inputGroup) => { + const button = inputGroup.find('[data-testid="remove-step-button"]'); + expect(button.find('[data-testid="remove-icon"]').exists()).toBe(true); + expect(button.attributes('aria-label')).toBe('remove step'); + }); + }); + + it('null values do not cause an input event', async () => { + createComponent(); + + await addStep(); + + expect(wrapper.emitted('input')).toBe(undefined); + }); + + it('hides the delete button if there is only one', () => { + createComponent({}, mountExtended); + + const inputGroups = findAllGlFormInputGroups().wrappers; + + expect(inputGroups.length).toBe(1); + expect(wrapper.findByTestId('remove-step-button').exists()).toBe(false); + }); + + it('shows an "add step" button', () => { + createComponent(); + + expect(addStepBtn.attributes('icon')).toBe('plus'); + expect(addStepBtn.text()).toBe('add another step'); + }); + + it('the "add step" button increases the number of input fields', async () => { + createComponent(); + + expect(findAllGlFormInputGroups().wrappers.length).toBe(1); + await addStep(); + expect(findAllGlFormInputGroups().wrappers.length).toBe(2); + }); + + it('does not pass the placeholder on subsequent input fields', async () => { + createComponent(); + + await addStep(); + await addStep(); + const nullOrUndefined = [null, undefined]; + expect(nullOrUndefined).toContain(findAllGlFormInputGroups().at(1).attributes('placeholder')); + expect(nullOrUndefined).toContain(findAllGlFormInputGroups().at(2).attributes('placeholder')); + }); + + it('emits an update event on input', async () => { + createComponent(); + + const localValue = 'somevalue'; + await setValueOnInputField(localValue); + await nextTick(); + + expect(wrapper.emitted('input')).toEqual([[[localValue]]]); + }); + + it('only emits non-null values', async () => { + createComponent(); + + await addStep(); + await addStep(); + await setValueOnInputField('abc', 1); + await nextTick(); + + const events = wrapper.emitted('input'); + + expect(events.length).toBe(1); + expect(events[0]).toEqual([['abc']]); + }); + }); + + describe('form validation', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('does not show validation state when untouched', async () => { + createComponent({}, mountExtended); + expect(findGlFormGroup().classes()).not.toContain('is-valid'); + expect(findGlFormGroup().classes()).not.toContain('is-invalid'); + }); + + it('shows invalid state on blur', async () => { + createComponent({}, mountExtended); + expect(findGlFormGroup().classes()).not.toContain('is-invalid'); + const input = findFirstGlFormInputGroup().find('input'); + await input.setValue('invalid99'); + await input.trigger('blur'); + expect(input.classes()).toContain('is-invalid'); + expect(findGlFormGroup().classes()).toContain('is-invalid'); + }); + + it('shows invalid state when toggling `validate` prop', async () => { + createComponent({ required: true, validate: false }, mountExtended); + await setValueOnInputField(null); + expect(findGlFormGroup().classes()).not.toContain('is-invalid'); + await wrapper.setProps({ validate: true }); + expect(findGlFormGroup().classes()).toContain('is-invalid'); + }); + + it.each` + scenario | required | values | inputFieldClasses | inputGroupClass | feedback + ${'shows invalid if all inputs are empty'} | ${true} | ${[null, null]} | ${['is-invalid', null]} | ${'is-invalid'} | ${'At least one entry is required'} + ${'is valid if at least one field has a valid entry'} | ${true} | ${[null, 'abc']} | ${[null, 'is-valid']} | ${'is-valid'} | ${expect.anything()} + ${'is invalid if one field has an invalid entry'} | ${true} | ${['abc', '99']} | ${['is-valid', 'is-invalid']} | ${'is-invalid'} | ${defaultProps.invalidFeedback} + ${'is not invalid if its not required but all values are null'} | ${false} | ${[null, null]} | ${[null, null]} | ${'is-valid'} | ${expect.anything()} + ${'is invalid if pattern does not match even if its not required'} | ${false} | ${['99', null]} | ${['is-invalid', null]} | ${'is-invalid'} | ${defaultProps.invalidFeedback} + `('$scenario', async ({ required, values, inputFieldClasses, inputGroupClass, feedback }) => { + createComponent({ required, validate: true }, mountExtended); + + await Promise.all( + values.map(async (value, i) => { + if (i > 0) { + await addStep(); + } + await setValueOnInputField(value, i); + }), + ); + await nextTick(); + + inputFieldClasses.forEach((expected, i) => { + const inputWrapper = findGlFormInputGroupByIndex(i).find('input'); + if (expected === null) { + expect(inputWrapper.classes()).not.toContain('is-valid'); + expect(inputWrapper.classes()).not.toContain('is-invalid'); + } else { + expect(inputWrapper.classes()).toContain(expected); + } + }); + + expect(findGlFormGroup().classes()).toContain(inputGroupClass); + expect(findGlFormGroupInvalidFeedback()).toEqual(feedback); + }); + }); +}); diff --git a/spec/lib/gitlab/email/receiver_spec.rb b/spec/lib/gitlab/email/receiver_spec.rb index b1a04f0592a..9040731d8fd 100644 --- a/spec/lib/gitlab/email/receiver_spec.rb +++ b/spec/lib/gitlab/email/receiver_spec.rb @@ -32,12 +32,21 @@ RSpec.describe Gitlab::Email::Receiver do metadata = receiver.mail_metadata - expect(metadata.keys).to match_array(%i(mail_uid from_address to_address mail_key references delivered_to envelope_to x_envelope_to meta)) + expect(metadata.keys).to match_array(%i(mail_uid from_address to_address mail_key references delivered_to envelope_to x_envelope_to meta received_recipients)) expect(metadata[:meta]).to include(client_id: 'email/jake@example.com', project: project.full_path) expect(metadata[meta_key]).to eq(meta_value) end end + shared_examples 'failed receive' do + it 'adds metric event' do + expect(::Gitlab::Metrics::BackgroundTransaction).to receive(:current).and_return(metric_transaction) + expect(metric_transaction).to receive(:add_event).with('email_receiver_error', { error: expected_error.name }) + + expect { receiver.execute }.to raise_error(expected_error) + end + end + context 'when the email contains a valid email address in a header' do before do stub_incoming_email_setting(enabled: true, address: "incoming+%{key}@appmail.example.com") @@ -74,14 +83,25 @@ RSpec.describe Gitlab::Email::Receiver do it_behaves_like 'successful receive' end - end - shared_examples 'failed receive' do - it 'adds metric event' do - expect(::Gitlab::Metrics::BackgroundTransaction).to receive(:current).and_return(metric_transaction) - expect(metric_transaction).to receive(:add_event).with('email_receiver_error', { error: expected_error.name }) + context 'when all other headers are missing' do + let(:email_raw) { fixture_file('emails/missing_delivered_to_header.eml') } + let(:meta_key) { :received_recipients } + let(:meta_value) { ['incoming+gitlabhq/gitlabhq+auth_token@appmail.example.com', 'incoming+gitlabhq/gitlabhq@example.com'] } - expect { receiver.execute }.to raise_error(expected_error) + context 'when use_received_header_for_incoming_emails is enabled' do + it_behaves_like 'successful receive' + end + + context 'when use_received_header_for_incoming_emails is disabled' do + let(:expected_error) { Gitlab::Email::UnknownIncomingEmail } + + before do + stub_feature_flags(use_received_header_for_incoming_emails: false) + end + + it_behaves_like 'failed receive' + end end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 565a794b902..72da2c22f29 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -385,23 +385,43 @@ RSpec.describe Group do end end - before do - subject - reload_models(old_parent, new_parent, group) - end - context 'within the same hierarchy' do let!(:root) { create(:group).reload } let!(:old_parent) { create(:group, parent: root) } let!(:new_parent) { create(:group, parent: root) } - it 'updates traversal_ids' do - expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id] + context 'with FOR UPDATE lock' do + before do + stub_feature_flags(for_no_key_update_lock: false) + subject + reload_models(old_parent, new_parent, group) + end + + it 'updates traversal_ids' do + expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id] + end + + it_behaves_like 'hierarchy with traversal_ids' + it_behaves_like 'locked row', 'FOR UPDATE' do + let(:row) { root } + end end - it_behaves_like 'hierarchy with traversal_ids' - it_behaves_like 'locked row' do - let(:row) { root } + context 'with FOR NO KEY UPDATE lock' do + before do + stub_feature_flags(for_no_key_update_lock: true) + subject + reload_models(old_parent, new_parent, group) + end + + it 'updates traversal_ids' do + expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id] + end + + it_behaves_like 'hierarchy with traversal_ids' + it_behaves_like 'locked row', 'FOR NO KEY UPDATE' do + let(:row) { root } + end end end @@ -410,6 +430,11 @@ RSpec.describe Group do let!(:new_parent) { create(:group) } let!(:group) { create(:group, parent: old_parent) } + before do + subject + reload_models(old_parent, new_parent, group) + end + it 'updates traversal_ids' do expect(group.traversal_ids).to eq [new_parent.id, group.id] end @@ -435,6 +460,11 @@ RSpec.describe Group do let!(:old_parent) { nil } let!(:new_parent) { create(:group) } + before do + subject + reload_models(old_parent, new_parent, group) + end + it 'updates traversal_ids' do expect(group.traversal_ids).to eq [new_parent.id, group.id] end @@ -452,6 +482,11 @@ RSpec.describe Group do let!(:old_parent) { create(:group) } let!(:new_parent) { nil } + before do + subject + reload_models(old_parent, new_parent, group) + end + it 'updates traversal_ids' do expect(group.traversal_ids).to eq [group.id] end diff --git a/spec/models/namespace/traversal_hierarchy_spec.rb b/spec/models/namespace/traversal_hierarchy_spec.rb index 51932ab943c..eeea071d326 100644 --- a/spec/models/namespace/traversal_hierarchy_spec.rb +++ b/spec/models/namespace/traversal_hierarchy_spec.rb @@ -68,11 +68,24 @@ RSpec.describe Namespace::TraversalHierarchy, type: :model do end end - it_behaves_like 'locked row' do + it_behaves_like 'locked row', 'FOR UPDATE' do let(:recorded_queries) { ActiveRecord::QueryRecorder.new } let(:row) { root } before do + stub_feature_flags(for_no_key_update_lock: false) + + recorded_queries.record { subject } + end + end + + it_behaves_like 'locked row', 'FOR NO KEY UPDATE' do + let(:recorded_queries) { ActiveRecord::QueryRecorder.new } + let(:row) { root } + + before do + stub_feature_flags(for_no_key_update_lock: true) + recorded_queries.record { subject } end end diff --git a/spec/requests/api/error_tracking/collector_spec.rb b/spec/requests/api/error_tracking/collector_spec.rb index 573da862b57..771bab20b75 100644 --- a/spec/requests/api/error_tracking/collector_spec.rb +++ b/spec/requests/api/error_tracking/collector_spec.rb @@ -171,6 +171,12 @@ RSpec.describe API::ErrorTracking::Collector do it_behaves_like 'successful request' end + context 'when JSON key transaction is empty string' do + let_it_be(:raw_event) { fixture_file('error_tracking/php_empty_transaction.json') } + + it_behaves_like 'successful request' + end + context 'sentry_key as param and empty headers' do let(:url) { "/error_tracking/collector/api/#{project.id}/store?sentry_key=#{sentry_key}" } let(:headers) { {} } diff --git a/spec/services/error_tracking/collect_error_service_spec.rb b/spec/services/error_tracking/collect_error_service_spec.rb index 2b16612dac3..faca3c12a48 100644 --- a/spec/services/error_tracking/collect_error_service_spec.rb +++ b/spec/services/error_tracking/collect_error_service_spec.rb @@ -51,25 +51,30 @@ RSpec.describe ErrorTracking::CollectErrorService do end end - context 'unusual payload' do + context 'with unusual payload' do let(:modified_event) { parsed_event } + let(:event) { described_class.new(project, nil, event: modified_event).execute } - context 'missing transaction' do + context 'when transaction is missing' do it 'builds actor from stacktrace' do modified_event.delete('transaction') - event = described_class.new(project, nil, event: modified_event).execute - expect(event.error.actor).to eq 'find()' end end - context 'timestamp is numeric' do + context 'when transaction is an empty string' do \ + it 'builds actor from stacktrace' do + modified_event['transaction'] = '' + + expect(event.error.actor).to eq 'find()' + end + end + + context 'when timestamp is numeric' do it 'parses timestamp' do modified_event['timestamp'] = '1631015580.50' - event = described_class.new(project, nil, event: modified_event).execute - expect(event.occurred_at).to eq '2021-09-07T11:53:00.5' end end diff --git a/spec/support/shared_examples/row_lock_shared_examples.rb b/spec/support/shared_examples/row_lock_shared_examples.rb index 5e003172215..e7eec88ec42 100644 --- a/spec/support/shared_examples/row_lock_shared_examples.rb +++ b/spec/support/shared_examples/row_lock_shared_examples.rb @@ -4,10 +4,10 @@ # Ensure a transaction also occurred. # Be careful! This form of spec is not foolproof, but better than nothing. -RSpec.shared_examples 'locked row' do +RSpec.shared_examples 'locked row' do |lock_type| it "has locked row" do table_name = row.class.table_name - ids_regex = /SELECT.*FROM.*#{table_name}.*"#{table_name}"."id" = #{row.id}.+FOR UPDATE/m + ids_regex = /SELECT.*FROM.*#{table_name}.*"#{table_name}"."id" = #{row.id}.+#{lock_type}/m expect(recorded_queries.log).to include a_string_matching 'SAVEPOINT' expect(recorded_queries.log).to include a_string_matching ids_regex