diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2024-01-24 00:09:27 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2024-01-24 00:09:27 +0300 |
commit | 17bb9dd270c78fad45851c6cc6ec6e6fdb3d23bf (patch) | |
tree | aa7235893811d97055b3fc750d139a039ae95b0a /spec | |
parent | abd2c6b32aabff4654b6be9cb98b59dcd3193fc4 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
33 files changed, 1088 insertions, 727 deletions
diff --git a/spec/controllers/projects/deployments_controller_spec.rb b/spec/controllers/projects/deployments_controller_spec.rb index a696eb933e9..abf12f0c3bf 100644 --- a/spec/controllers/projects/deployments_controller_spec.rb +++ b/spec/controllers/projects/deployments_controller_spec.rb @@ -84,6 +84,49 @@ RSpec.describe Projects::DeploymentsController do end end + describe 'GET #show' do + let(:deployment) { create(:deployment, :success, environment: environment) } + + subject do + get :show, params: deployment_params(id: deployment.iid) + end + + context 'without feature flag' do + before do + stub_feature_flags(deployment_details_page: false) + end + + it 'renders a 404' do + is_expected.to have_gitlab_http_status(:not_found) + end + end + + context 'with feature flag' do + before do + stub_feature_flags(deployment_details_page: true) + end + + context 'as maintainer' do + it 'renders show with 200 status code' do + is_expected.to have_gitlab_http_status(:ok) + is_expected.to render_template(:show) + end + end + + context 'as anonymous user' do + let(:anonymous_user) { create(:user) } + + before do + sign_in(anonymous_user) + end + + it 'renders a 404' do + is_expected.to have_gitlab_http_status(:not_found) + end + end + end + end + describe 'GET #metrics' do let(:deployment) { create(:deployment, :success, project: project, environment: environment) } diff --git a/spec/dot_gitlab_ci/rules_spec.rb b/spec/dot_gitlab_ci/rules_spec.rb index 88e98fe95ba..8e13be25f4c 100644 --- a/spec/dot_gitlab_ci/rules_spec.rb +++ b/spec/dot_gitlab_ci/rules_spec.rb @@ -3,10 +3,16 @@ require 'fast_spec_helper' RSpec.describe '.gitlab/ci/rules.gitlab-ci.yml', feature_category: :tooling do - config = YAML.load_file( - File.expand_path('../../.gitlab/ci/rules.gitlab-ci.yml', __dir__), - aliases: true - ).freeze + begin + config = YAML.load_file( + File.expand_path('../../.gitlab/ci/rules.gitlab-ci.yml', __dir__), + aliases: true + ).freeze + rescue ArgumentError # Ruby 3.0 does not take `aliases: true` + config = YAML.load_file( + File.expand_path('../../.gitlab/ci/rules.gitlab-ci.yml', __dir__) + ).freeze + end context 'with changes' do config.each do |name, definition| diff --git a/spec/fixtures/gitlab/database/query_analyzers/large_query_with_in_list.txt b/spec/fixtures/gitlab/database/query_analyzers/large_query_with_in_list.txt new file mode 100644 index 00000000000..45d359ad457 --- /dev/null +++ b/spec/fixtures/gitlab/database/query_analyzers/large_query_with_in_list.txt @@ -0,0 +1 @@ +SELECT projects.id, projects.name FROM projects WHERE projects.namespace_id IN (SELECT DISTINCT namespaces.id FROM ((WITH base_ancestors_cte AS MATERIALIZED (SELECT namespaces.traversal_ids FROM ((WITH direct_groups AS MATERIALIZED (SELECT namespaces.id, namespaces.name, namespaces.path, namespaces.owner_id, namespaces.created_at, namespaces.updated_at, namespaces.type, namespaces.description, namespaces.avatar, namespaces.membership_lock, namespaces.share_with_group_lock, namespaces.visibility_level, namespaces.request_access_enabled, namespaces.ldap_sync_status, namespaces.ldap_sync_error, namespaces.ldap_sync_last_update_at, namespaces.ldap_sync_last_successful_update_at, namespaces.ldap_sync_last_sync_at, namespaces.lfs_enabled, namespaces.description_html, namespaces.parent_id, namespaces.shared_runners_minutes_limit, namespaces.repository_size_limit, namespaces.require_two_factor_authentication, namespaces.two_factor_grace_period, namespaces.cached_markdown_version, namespaces.project_creation_level, namespaces.runners_token, namespaces.file_template_project_id, namespaces.saml_discovery_token, namespaces.runners_token_encrypted, namespaces.custom_project_templates_group_id, namespaces.auto_devops_enabled, namespaces.extra_shared_runners_minutes_limit, namespaces.last_ci_minutes_notification_at, namespaces.last_ci_minutes_usage_notification_level, namespaces.subgroup_creation_level, namespaces.emails_disabled, namespaces.max_pages_size, namespaces.max_artifacts_size, namespaces.mentions_disabled, namespaces.default_branch_protection, namespaces.max_personal_access_token_lifetime, namespaces.push_rule_id, namespaces.shared_runners_enabled, namespaces.allow_descendants_override_disabled_shared_runners, namespaces.traversal_ids, namespaces.organization_id FROM ((SELECT namespaces.id, namespaces.name, namespaces.path, namespaces.owner_id, namespaces.created_at, namespaces.updated_at, namespaces.type, namespaces.description, namespaces.avatar, namespaces.membership_lock, namespaces.share_with_group_lock, namespaces.visibility_level, namespaces.request_access_enabled, namespaces.ldap_sync_status, namespaces.ldap_sync_error, namespaces.ldap_sync_last_update_at, namespaces.ldap_sync_last_successful_update_at, namespaces.ldap_sync_last_sync_at, namespaces.lfs_enabled, namespaces.description_html, namespaces.parent_id, namespaces.shared_runners_minutes_limit, namespaces.repository_size_limit, namespaces.require_two_factor_authentication, namespaces.two_factor_grace_period, namespaces.cached_markdown_version, namespaces.project_creation_level, namespaces.runners_token, namespaces.file_template_project_id, namespaces.saml_discovery_token, namespaces.runners_token_encrypted, namespaces.custom_project_templates_group_id, namespaces.auto_devops_enabled, namespaces.extra_shared_runners_minutes_limit, namespaces.last_ci_minutes_notification_at, namespaces.last_ci_minutes_usage_notification_level, namespaces.subgroup_creation_level, namespaces.emails_disabled, namespaces.max_pages_size, namespaces.max_artifacts_size, namespaces.mentions_disabled, namespaces.default_branch_protection, namespaces.max_personal_access_token_lifetime, namespaces.push_rule_id, namespaces.shared_runners_enabled, namespaces.allow_descendants_override_disabled_shared_runners, namespaces.traversal_ids, namespaces.organization_id FROM namespaces INNER JOIN members ON namespaces.id = members.source_id WHERE members.type = 'GroupMember' AND members.source_type = 'Namespace' AND namespaces.type = 'Group' AND members.user_id = 2167502 AND members.requested_at IS NULL AND (access_level >= 10)) UNION (SELECT namespaces.id, namespaces.name, namespaces.path, namespaces.owner_id, namespaces.created_at, namespaces.updated_at, namespaces.type, namespaces.description, namespaces.avatar, namespaces.membership_lock, namespaces.share_with_group_lock, namespaces.visibility_level, namespaces.request_access_enabled, namespaces.ldap_sync_status, namespaces.ldap_sync_error, namespaces.ldap_sync_last_update_at, namespaces.ldap_sync_last_successful_update_at, namespaces.ldap_sync_last_sync_at, namespaces.lfs_enabled, namespaces.description_html, namespaces.parent_id, namespaces.shared_runners_minutes_limit, namespaces.repository_size_limit, namespaces.require_two_factor_authentication, namespaces.two_factor_grace_period, namespaces.cached_markdown_version, namespaces.project_creation_level, namespaces.runners_token, namespaces.file_template_project_id, namespaces.saml_discovery_token, namespaces.runners_token_encrypted, namespaces.custom_project_templates_group_id, namespaces.auto_devops_enabled, namespaces.extra_shared_runners_minutes_limit, namespaces.last_ci_minutes_notification_at, namespaces.last_ci_minutes_usage_notification_level, namespaces.subgroup_creation_level, namespaces.emails_disabled, namespaces.max_pages_size, namespaces.max_artifacts_size, namespaces.mentions_disabled, namespaces.default_branch_protection, namespaces.max_personal_access_token_lifetime, namespaces.push_rule_id, namespaces.shared_runners_enabled, namespaces.allow_descendants_override_disabled_shared_runners, namespaces.traversal_ids, namespaces.organization_id FROM namespaces WHERE namespaces.type = 'Group' AND namespaces.id IN (SELECT projects.namespace_id FROM projects INNER JOIN project_authorizations ON projects.id = project_authorizations.project_id WHERE project_authorizations.user_id = 2167502))) namespaces WHERE namespaces.type = 'Group') SELECT namespaces.id, namespaces.name, namespaces.path, namespaces.owner_id, namespaces.created_at, namespaces.updated_at, namespaces.type, namespaces.description, namespaces.avatar, namespaces.membership_lock, namespaces.share_with_group_lock, namespaces.visibility_level, namespaces.request_access_enabled, namespaces.ldap_sync_status, namespaces.ldap_sync_error, namespaces.ldap_sync_last_update_at, namespaces.ldap_sync_last_successful_update_at, namespaces.ldap_sync_last_sync_at, namespaces.lfs_enabled, namespaces.description_html, namespaces.parent_id, namespaces.shared_runners_minutes_limit, namespaces.repository_size_limit, namespaces.require_two_factor_authentication, namespaces.two_factor_grace_period, namespaces.cached_markdown_version, namespaces.project_creation_level, namespaces.runners_token, namespaces.file_template_project_id, namespaces.saml_discovery_token, namespaces.runners_token_encrypted, namespaces.custom_project_templates_group_id, namespaces.auto_devops_enabled, namespaces.extra_shared_runners_minutes_limit, namespaces.last_ci_minutes_notification_at, namespaces.last_ci_minutes_usage_notification_level, namespaces.subgroup_creation_level, namespaces.emails_disabled, namespaces.max_pages_size, namespaces.max_artifacts_size, namespaces.mentions_disabled, namespaces.default_branch_protection, namespaces.max_personal_access_token_lifetime, namespaces.push_rule_id, namespaces.shared_runners_enabled, namespaces.allow_descendants_override_disabled_shared_runners, namespaces.traversal_ids, namespaces.organization_id FROM ((SELECT namespaces.id, namespaces.name, namespaces.path, namespaces.owner_id, namespaces.created_at, namespaces.updated_at, namespaces.type, namespaces.description, namespaces.avatar, namespaces.membership_lock, namespaces.share_with_group_lock, namespaces.visibility_level, namespaces.request_access_enabled, namespaces.ldap_sync_status, namespaces.ldap_sync_error, namespaces.ldap_sync_last_update_at, namespaces.ldap_sync_last_successful_update_at, namespaces.ldap_sync_last_sync_at, namespaces.lfs_enabled, namespaces.description_html, namespaces.parent_id, namespaces.shared_runners_minutes_limit, namespaces.repository_size_limit, namespaces.require_two_factor_authentication, namespaces.two_factor_grace_period, namespaces.cached_markdown_version, namespaces.project_creation_level, namespaces.runners_token, namespaces.file_template_project_id, namespaces.saml_discovery_token, namespaces.runners_token_encrypted, namespaces.custom_project_templates_group_id, namespaces.auto_devops_enabled, namespaces.extra_shared_runners_minutes_limit, namespaces.last_ci_minutes_notification_at, namespaces.last_ci_minutes_usage_notification_level, namespaces.subgroup_creation_level, namespaces.emails_disabled, namespaces.max_pages_size, namespaces.max_artifacts_size, namespaces.mentions_disabled, namespaces.default_branch_protection, namespaces.max_personal_access_token_lifetime, namespaces.push_rule_id, namespaces.shared_runners_enabled, namespaces.allow_descendants_override_disabled_shared_runners, namespaces.traversal_ids, namespaces.organization_id FROM direct_groups namespaces WHERE namespaces.type = 'Group') UNION (SELECT namespaces.id, namespaces.name, namespaces.path, namespaces.owner_id, namespaces.created_at, namespaces.updated_at, namespaces.type, namespaces.description, namespaces.avatar, namespaces.membership_lock, namespaces.share_with_group_lock, namespaces.visibility_level, namespaces.request_access_enabled, namespaces.ldap_sync_status, namespaces.ldap_sync_error, namespaces.ldap_sync_last_update_at, namespaces.ldap_sync_last_successful_update_at, namespaces.ldap_sync_last_sync_at, namespaces.lfs_enabled, namespaces.description_html, namespaces.parent_id, namespaces.shared_runners_minutes_limit, namespaces.repository_size_limit, namespaces.require_two_factor_authentication, namespaces.two_factor_grace_period, namespaces.cached_markdown_version, namespaces.project_creation_level, namespaces.runners_token, namespaces.file_template_project_id, namespaces.saml_discovery_token, namespaces.runners_token_encrypted, namespaces.custom_project_templates_group_id, namespaces.auto_devops_enabled, namespaces.extra_shared_runners_minutes_limit, namespaces.last_ci_minutes_notification_at, namespaces.last_ci_minutes_usage_notification_level, namespaces.subgroup_creation_level, namespaces.emails_disabled, namespaces.max_pages_size, namespaces.max_artifacts_size, namespaces.mentions_disabled, namespaces.default_branch_protection, namespaces.max_personal_access_token_lifetime, namespaces.push_rule_id, namespaces.shared_runners_enabled, namespaces.allow_descendants_override_disabled_shared_runners, namespaces.traversal_ids, namespaces.organization_id FROM namespaces INNER JOIN group_group_links ON group_group_links.shared_group_id = namespaces.id WHERE namespaces.type = 'Group' AND group_group_links.shared_with_group_id IN (SELECT namespaces.id FROM direct_groups namespaces WHERE namespaces.type = 'Group'))) namespaces WHERE namespaces.type = 'Group') UNION (SELECT namespaces.id, namespaces.name, namespaces.path, namespaces.owner_id, namespaces.created_at, namespaces.updated_at, namespaces.type, namespaces.description, namespaces.avatar, namespaces.membership_lock, namespaces.share_with_group_lock, namespaces.visibility_level, namespaces.request_access_enabled, namespaces.ldap_sync_status, namespaces.ldap_sync_error, namespaces.ldap_sync_last_update_at, namespaces.ldap_sync_last_successful_update_at, namespaces.ldap_sync_last_sync_at, namespaces.lfs_enabled, namespaces.description_html, namespaces.parent_id, namespaces.shared_runners_minutes_limit, namespaces.repository_size_limit, namespaces.require_two_factor_authentication, namespaces.two_factor_grace_period, namespaces.cached_markdown_version, namespaces.project_creation_level, namespaces.runners_token, namespaces.file_template_project_id, namespaces.saml_discovery_token, namespaces.runners_token_encrypted, namespaces.custom_project_templates_group_id, namespaces.auto_devops_enabled, namespaces.extra_shared_runners_minutes_limit, namespaces.last_ci_minutes_notification_at, namespaces.last_ci_minutes_usage_notification_level, namespaces.subgroup_creation_level, namespaces.emails_disabled, namespaces.max_pages_size, namespaces.max_artifacts_size, namespaces.mentions_disabled, namespaces.default_branch_protection, namespaces.max_personal_access_token_lifetime, namespaces.push_rule_id, namespaces.shared_runners_enabled, namespaces.allow_descendants_override_disabled_shared_runners, namespaces.traversal_ids, namespaces.organization_id FROM namespaces INNER JOIN members ON namespaces.id = members.source_id WHERE members.type = 'GroupMember' AND members.source_type = 'Namespace' AND namespaces.type = 'Group' AND members.user_id = 2167502 AND members.access_level = 5 AND (EXISTS (SELECT 1 FROM plans INNER JOIN gitlab_subscriptions ON gitlab_subscriptions.hosted_plan_id = plans.id WHERE plans.name IN ('silver', 'premium', 'premium_trial') AND (gitlab_subscriptions.namespace_id = namespaces.id))))) namespaces WHERE namespaces.type = 'Group') SELECT namespaces.id, namespaces.name, namespaces.path, namespaces.owner_id, namespaces.created_at, namespaces.updated_at, namespaces.type, namespaces.description, namespaces.avatar, namespaces.membership_lock, namespaces.share_with_group_lock, namespaces.visibility_level, namespaces.request_access_enabled, namespaces.ldap_sync_status, namespaces.ldap_sync_error, namespaces.ldap_sync_last_update_at, namespaces.ldap_sync_last_successful_update_at, namespaces.ldap_sync_last_sync_at, namespaces.lfs_enabled, namespaces.description_html, namespaces.parent_id, namespaces.shared_runners_minutes_limit, namespaces.repository_size_limit, namespaces.require_two_factor_authentication, namespaces.two_factor_grace_period, namespaces.cached_markdown_version, namespaces.project_creation_level, namespaces.runners_token, namespaces.file_template_project_id, namespaces.saml_discovery_token, namespaces.runners_token_encrypted, namespaces.custom_project_templates_group_id, namespaces.auto_devops_enabled, namespaces.extra_shared_runners_minutes_limit, namespaces.last_ci_minutes_notification_at, namespaces.last_ci_minutes_usage_notification_level, namespaces.subgroup_creation_level, namespaces.emails_disabled, namespaces.max_pages_size, namespaces.max_artifacts_size, namespaces.mentions_disabled, namespaces.default_branch_protection, namespaces.max_personal_access_token_lifetime, namespaces.push_rule_id, namespaces.shared_runners_enabled, namespaces.allow_descendants_override_disabled_shared_runners, namespaces.traversal_ids, namespaces.organization_id FROM namespaces INNER JOIN (SELECT DISTINCT unnest(base_ancestors_cte.traversal_ids) FROM base_ancestors_cte) AS ancestors(ancestor_id) ON namespaces.id = ancestors.ancestor_id WHERE namespaces.type = 'Group') UNION (WITH descendants_base_cte AS MATERIALIZED (SELECT namespaces.id, namespaces.traversal_ids FROM namespaces INNER JOIN members ON namespaces.id = members.source_id WHERE members.type = 'GroupMember' AND members.source_type = 'Namespace' AND namespaces.type = 'Group' AND members.user_id = 2167502 AND members.requested_at IS NULL AND (access_level >= 10)), superset AS (SELECT d1.traversal_ids FROM descendants_base_cte d1 WHERE NOT EXISTS ( SELECT 1 FROM descendants_base_cte d2 WHERE d2.id = ANY(d1.traversal_ids) AND d2.id <> d1.id ) ) SELECT DISTINCT namespaces.id, namespaces.name, namespaces.path, namespaces.owner_id, namespaces.created_at, namespaces.updated_at, namespaces.type, namespaces.description, namespaces.avatar, namespaces.membership_lock, namespaces.share_with_group_lock, namespaces.visibility_level, namespaces.request_access_enabled, namespaces.ldap_sync_status, namespaces.ldap_sync_error, namespaces.ldap_sync_last_update_at, namespaces.ldap_sync_last_successful_update_at, namespaces.ldap_sync_last_sync_at, namespaces.lfs_enabled, namespaces.description_html, namespaces.parent_id, namespaces.shared_runners_minutes_limit, namespaces.repository_size_limit, namespaces.require_two_factor_authentication, namespaces.two_factor_grace_period, namespaces.cached_markdown_version, namespaces.project_creation_level, namespaces.runners_token, namespaces.file_template_project_id, namespaces.saml_discovery_token, namespaces.runners_token_encrypted, namespaces.custom_project_templates_group_id, namespaces.auto_devops_enabled, namespaces.extra_shared_runners_minutes_limit, namespaces.last_ci_minutes_notification_at, namespaces.last_ci_minutes_usage_notification_level, namespaces.subgroup_creation_level, namespaces.emails_disabled, namespaces.max_pages_size, namespaces.max_artifacts_size, namespaces.mentions_disabled, namespaces.default_branch_protection, namespaces.max_personal_access_token_lifetime, namespaces.push_rule_id, namespaces.shared_runners_enabled, namespaces.allow_descendants_override_disabled_shared_runners, namespaces.traversal_ids, namespaces.organization_id FROM superset, namespaces WHERE namespaces.type = 'Group' AND next_traversal_ids_sibling(superset.traversal_ids) > namespaces.traversal_ids AND superset.traversal_ids <= namespaces.traversal_ids) UNION (SELECT namespaces.id, namespaces.name, namespaces.path, namespaces.owner_id, namespaces.created_at, namespaces.updated_at, namespaces.type, namespaces.description, namespaces.avatar, namespaces.membership_lock, namespaces.share_with_group_lock, namespaces.visibility_level, namespaces.request_access_enabled, namespaces.ldap_sync_status, namespaces.ldap_sync_error, namespaces.ldap_sync_last_update_at, namespaces.ldap_sync_last_successful_update_at, namespaces.ldap_sync_last_sync_at, namespaces.lfs_enabled, namespaces.description_html, namespaces.parent_id, namespaces.shared_runners_minutes_limit, namespaces.repository_size_limit, namespaces.require_two_factor_authentication, namespaces.two_factor_grace_period, namespaces.cached_markdown_version, namespaces.project_creation_level, namespaces.runners_token, namespaces.file_template_project_id, namespaces.saml_discovery_token, namespaces.runners_token_encrypted, namespaces.custom_project_templates_group_id, namespaces.auto_devops_enabled, namespaces.extra_shared_runners_minutes_limit, namespaces.last_ci_minutes_notification_at, namespaces.last_ci_minutes_usage_notification_level, namespaces.subgroup_creation_level, namespaces.emails_disabled, namespaces.max_pages_size, namespaces.max_artifacts_size, namespaces.mentions_disabled, namespaces.default_branch_protection, namespaces.max_personal_access_token_lifetime, namespaces.push_rule_id, namespaces.shared_runners_enabled, namespaces.allow_descendants_override_disabled_shared_runners, namespaces.traversal_ids, namespaces.organization_id FROM namespaces WHERE namespaces.type = 'Group' AND namespaces.visibility_level IN (10, 20))) namespaces INNER JOIN projects ON projects.namespace_id = namespaces.id WHERE namespaces.type = 'Group' AND namespaces.id IN (SELECT namespaces.custom_project_templates_group_id FROM namespaces WHERE namespaces.type = 'Group' AND (traversal_ids[1] IN (SELECT gitlab_subscriptions.namespace_id FROM gitlab_subscriptions WHERE gitlab_subscriptions.hosted_plan_id IN (%IN_LIST%))) AND namespaces.custom_project_templates_group_id IS NOT NULL)) AND projects.marked_for_deletion_at IS NULL AND projects.pending_delete = FALSE AND projects.archived = FALSE; diff --git a/spec/fixtures/gitlab/database/query_analyzers/small_query_with_in_list.txt b/spec/fixtures/gitlab/database/query_analyzers/small_query_with_in_list.txt new file mode 100644 index 00000000000..df920489294 --- /dev/null +++ b/spec/fixtures/gitlab/database/query_analyzers/small_query_with_in_list.txt @@ -0,0 +1 @@ +SELECT namespaces.id FROM namespaces WHERE namespaces.id IN (%IN_LIST%) diff --git a/spec/fixtures/gitlab/database/query_analyzers/small_query_without_in_list.txt b/spec/fixtures/gitlab/database/query_analyzers/small_query_without_in_list.txt new file mode 100644 index 00000000000..982159eb40c --- /dev/null +++ b/spec/fixtures/gitlab/database/query_analyzers/small_query_without_in_list.txt @@ -0,0 +1 @@ +SELECT 1 FROM namespaces; diff --git a/spec/frontend/__helpers__/mock_observability_client.js b/spec/frontend/__helpers__/mock_observability_client.js index a65b5233b73..571ee68f9bf 100644 --- a/spec/frontend/__helpers__/mock_observability_client.js +++ b/spec/frontend/__helpers__/mock_observability_client.js @@ -4,6 +4,7 @@ export function createMockClient() { const mockClient = buildClient({ provisioningUrl: 'provisioning-url', tracingUrl: 'tracing-url', + tracingAnalyticsUrl: 'tracing-analytics-url', servicesUrl: 'services-url', operationsUrl: 'operations-url', metricsUrl: 'metrics-url', diff --git a/spec/frontend/ci/runner/components/runner_cloud_form_spec.js b/spec/frontend/ci/runner/components/runner_cloud_form_spec.js new file mode 100644 index 00000000000..ae856631f60 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_cloud_form_spec.js @@ -0,0 +1,16 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerCloudConnectionForm from '~/ci/runner/components/runner_cloud_connection_form.vue'; + +describe('Runner Cloud Form', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(RunnerCloudConnectionForm); + }; + + it('default', () => { + createComponent(); + + expect(wrapper.exists()).toBe(true); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js b/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js index eddc1438fff..18aa722b94a 100644 --- a/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js +++ b/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js @@ -22,65 +22,108 @@ describe('RunnerPlatformsRadioGroup', () => { .filter((w) => w.text() === text) .at(0); - const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => { + const createComponent = ({ + props = {}, + mountFn = shallowMountExtended, + gcpRunner = false, + ...options + } = {}) => { wrapper = mountFn(RunnerPlatformsRadioGroup, { propsData: { value: null, ...props, }, + provide: { + glFeatures: { + gcpRunner, + }, + }, ...options, }); }; - beforeEach(() => { - createComponent(); - }); + describe('defaults', () => { + beforeEach(() => { + createComponent(); + }); - it('contains expected options with images', () => { - const labels = findFormRadios().map((w) => [w.text(), w.props('image')]); + it('contains expected options with images', () => { + const labels = findFormRadios().map((w) => [w.text(), w.props('image')]); - expect(labels).toEqual([ - ['Linux', expect.any(String)], - ['macOS', null], - ['Windows', null], - ['Docker', expect.any(String)], - ['Kubernetes', expect.any(String)], - ]); - }); + expect(labels).toStrictEqual([ + ['Linux', expect.any(String)], + ['macOS', null], + ['Windows', null], + ['Docker', expect.any(String)], + ['Kubernetes', expect.any(String)], + ]); + }); - it('allows users to use radio group', async () => { - findFormRadioGroup().vm.$emit('input', MACOS_PLATFORM); - await nextTick(); + it('allows users to use radio group', async () => { + findFormRadioGroup().vm.$emit('input', MACOS_PLATFORM); + await nextTick(); - expect(wrapper.emitted('input')[0]).toEqual([MACOS_PLATFORM]); - }); + expect(wrapper.emitted('input')[0]).toEqual([MACOS_PLATFORM]); + }); + + it.each` + text | value + ${'Linux'} | ${LINUX_PLATFORM} + ${'macOS'} | ${MACOS_PLATFORM} + ${'Windows'} | ${WINDOWS_PLATFORM} + `('user can select "$text"', async ({ text, value }) => { + const radio = findFormRadioByText(text); + expect(radio.props('value')).toBe(value); - it.each` - text | value - ${'Linux'} | ${LINUX_PLATFORM} - ${'macOS'} | ${MACOS_PLATFORM} - ${'Windows'} | ${WINDOWS_PLATFORM} - `('user can select "$text"', async ({ text, value }) => { - const radio = findFormRadioByText(text); - expect(radio.props('value')).toBe(value); + radio.vm.$emit('input', value); + await nextTick(); - radio.vm.$emit('input', value); - await nextTick(); + expect(wrapper.emitted('input')[0]).toEqual([value]); + }); + + it.each` + text | href + ${'Docker'} | ${DOCKER_HELP_URL} + ${'Kubernetes'} | ${KUBERNETES_HELP_URL} + `('provides link to "$text" docs', ({ text, href }) => { + const radio = findFormRadioByText(text); - expect(wrapper.emitted('input')[0]).toEqual([value]); + expect(radio.findComponent(GlLink).attributes()).toEqual({ + href, + target: '_blank', + }); + expect(radio.findComponent(GlIcon).props('name')).toBe('external-link'); + }); }); - it.each` - text | href - ${'Docker'} | ${DOCKER_HELP_URL} - ${'Kubernetes'} | ${KUBERNETES_HELP_URL} - `('provides link to "$text" docs', ({ text, href }) => { - const radio = findFormRadioByText(text); + describe('with gcpRunner flag enabled', () => { + it('contains expected options with images', () => { + createComponent({ props: {}, mountFn: shallowMountExtended, gcpRunner: true }); + + const labels = findFormRadios().map((w) => [w.text(), w.props('image')]); + + expect(labels).toStrictEqual([ + ['Linux', expect.any(String)], + ['macOS', null], + ['Windows', null], + ['Google Cloud', null], + ['Docker', expect.any(String)], + ['Kubernetes', expect.any(String)], + ]); + }); + + it('does not contain cloud option when admin prop is passed', () => { + createComponent({ props: { admin: true }, mountFn: shallowMountExtended, gcpRunner: true }); + + const labels = findFormRadios().map((w) => [w.text(), w.props('image')]); - expect(radio.findComponent(GlLink).attributes()).toEqual({ - href, - target: '_blank', + expect(labels).toStrictEqual([ + ['Linux', expect.any(String)], + ['macOS', null], + ['Windows', null], + ['Docker', expect.any(String)], + ['Kubernetes', expect.any(String)], + ]); }); - expect(radio.findComponent(GlIcon).props('name')).toBe('external-link'); }); }); diff --git a/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js b/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js index 623a8f1c5a1..4e1e8c0adde 100644 --- a/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js +++ b/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js @@ -1,4 +1,5 @@ import { GlSprintf } from '@gitlab/ui'; +import { nextTick } from 'vue'; import { s__ } from '~/locale'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -13,7 +14,9 @@ import { GROUP_TYPE, DEFAULT_PLATFORM, WINDOWS_PLATFORM, + GOOGLE_CLOUD_PLATFORM, } from '~/ci/runner/constants'; +import RunnerCloudConnectionForm from '~/ci/runner/components/runner_cloud_connection_form.vue'; import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; import { visitUrl } from '~/lib/utils/url_utility'; import { runnerCreateResult } from '../mock_data'; @@ -36,8 +39,9 @@ describe('GroupRunnerRunnerApp', () => { const findRegistrationCompatibilityAlert = () => wrapper.findComponent(RegistrationCompatibilityAlert); const findRunnerCreateForm = () => wrapper.findComponent(RunnerCreateForm); + const findRunnerCloudForm = () => wrapper.findComponent(RunnerCloudConnectionForm); - const createComponent = () => { + const createComponent = (gcpRunner = false) => { wrapper = shallowMountExtended(GroupRunnerRunnerApp, { propsData: { groupId: mockGroupId, @@ -45,74 +49,100 @@ describe('GroupRunnerRunnerApp', () => { stubs: { GlSprintf, }, + provide: { + glFeatures: { + gcpRunner, + }, + }, }); }; - beforeEach(() => { - createComponent(); - }); - - it('shows a registration compatibility alert', () => { - expect(findRegistrationCompatibilityAlert().props('alertKey')).toBe(mockGroupId); - }); + describe('defaults', () => { + beforeEach(() => { + createComponent(); + }); - describe('Platform', () => { - it('shows the platforms radio group', () => { - expect(findRunnerPlatformsRadioGroup().props('value')).toBe(DEFAULT_PLATFORM); + it('shows a registration compatibility alert', () => { + expect(findRegistrationCompatibilityAlert().props('alertKey')).toBe(mockGroupId); }); - }); - describe('Runner form', () => { - it('shows the runner create form for an instance runner', () => { - expect(findRunnerCreateForm().props()).toEqual({ - runnerType: GROUP_TYPE, - groupId: mockGroupId, - projectId: null, + describe('Platform', () => { + it('shows the platforms radio group', () => { + expect(findRunnerPlatformsRadioGroup().props('value')).toBe(DEFAULT_PLATFORM); }); }); - describe('When a runner is saved', () => { - beforeEach(() => { - findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); + describe('Runner form', () => { + it('shows the runner create form for an instance runner', () => { + expect(findRunnerCreateForm().props()).toEqual({ + runnerType: GROUP_TYPE, + groupId: mockGroupId, + projectId: null, + }); }); - it('pushes an alert to be shown after redirection', () => { - expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ - message: s__('Runners|Runner created.'), - variant: VARIANT_SUCCESS, + describe('When a runner is saved', () => { + beforeEach(() => { + findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); }); - }); - it('redirects to the registration page', () => { - const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`; + it('pushes an alert to be shown after redirection', () => { + expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ + message: s__('Runners|Runner created.'), + variant: VARIANT_SUCCESS, + }); + }); - expect(visitUrl).toHaveBeenCalledWith(url); - }); - }); + it('redirects to the registration page', () => { + const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`; - describe('When another platform is selected and a runner is saved', () => { - beforeEach(() => { - findRunnerPlatformsRadioGroup().vm.$emit('input', WINDOWS_PLATFORM); - findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); + expect(visitUrl).toHaveBeenCalledWith(url); + }); }); - it('redirects to the registration page with the platform', () => { - const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`; + describe('When another platform is selected and a runner is saved', () => { + beforeEach(() => { + findRunnerPlatformsRadioGroup().vm.$emit('input', WINDOWS_PLATFORM); + findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); + }); + + it('redirects to the registration page with the platform', () => { + const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`; - expect(visitUrl).toHaveBeenCalledWith(url); + expect(visitUrl).toHaveBeenCalledWith(url); + }); }); - }); - describe('When runner fails to save', () => { - const ERROR_MSG = 'Cannot save!'; + describe('When runner fails to save', () => { + const ERROR_MSG = 'Cannot save!'; - beforeEach(() => { - findRunnerCreateForm().vm.$emit('error', new Error(ERROR_MSG)); - }); + beforeEach(() => { + findRunnerCreateForm().vm.$emit('error', new Error(ERROR_MSG)); + }); - it('shows an error message', () => { - expect(createAlert).toHaveBeenCalledWith({ message: ERROR_MSG }); + it('shows an error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: ERROR_MSG }); + }); }); }); }); + + describe('Runner cloud form', () => { + it.each` + flagState | visible + ${true} | ${true} + ${false} | ${false} + `( + 'shows runner cloud form: $visible when flag is set to $flagState and platform is google', + async ({ flagState, visible }) => { + createComponent(flagState); + + findRunnerPlatformsRadioGroup().vm.$emit('input', GOOGLE_CLOUD_PLATFORM); + + await nextTick(); + + expect(findRunnerCloudForm().exists()).toBe(visible); + }, + ); + }); }); diff --git a/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js b/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js index 3e12f3911a0..e2cbe731032 100644 --- a/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js +++ b/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js @@ -1,4 +1,5 @@ import { GlSprintf } from '@gitlab/ui'; +import { nextTick } from 'vue'; import { s__ } from '~/locale'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -13,7 +14,9 @@ import { PROJECT_TYPE, DEFAULT_PLATFORM, WINDOWS_PLATFORM, + GOOGLE_CLOUD_PLATFORM, } from '~/ci/runner/constants'; +import RunnerCloudConnectionForm from '~/ci/runner/components/runner_cloud_connection_form.vue'; import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; import { visitUrl } from '~/lib/utils/url_utility'; import { runnerCreateResult, mockRegistrationToken } from '../mock_data'; @@ -36,8 +39,9 @@ describe('ProjectRunnerRunnerApp', () => { const findRegistrationCompatibilityAlert = () => wrapper.findComponent(RegistrationCompatibilityAlert); const findRunnerCreateForm = () => wrapper.findComponent(RunnerCreateForm); + const findRunnerCloudForm = () => wrapper.findComponent(RunnerCloudConnectionForm); - const createComponent = () => { + const createComponent = (gcpRunner = false) => { wrapper = shallowMountExtended(ProjectRunnerRunnerApp, { propsData: { projectId: mockProjectId, @@ -46,74 +50,100 @@ describe('ProjectRunnerRunnerApp', () => { stubs: { GlSprintf, }, + provide: { + glFeatures: { + gcpRunner, + }, + }, }); }; - beforeEach(() => { - createComponent(); - }); - - it('shows a registration compatibility alert', () => { - expect(findRegistrationCompatibilityAlert().props('alertKey')).toBe(mockProjectId); - }); + describe('defaults', () => { + beforeEach(() => { + createComponent(); + }); - describe('Platform', () => { - it('shows the platforms radio group', () => { - expect(findRunnerPlatformsRadioGroup().props('value')).toBe(DEFAULT_PLATFORM); + it('shows a registration compatibility alert', () => { + expect(findRegistrationCompatibilityAlert().props('alertKey')).toBe(mockProjectId); }); - }); - describe('Runner form', () => { - it('shows the runner create form for an instance runner', () => { - expect(findRunnerCreateForm().props()).toEqual({ - runnerType: PROJECT_TYPE, - projectId: mockProjectId, - groupId: null, + describe('Platform', () => { + it('shows the platforms radio group', () => { + expect(findRunnerPlatformsRadioGroup().props('value')).toBe(DEFAULT_PLATFORM); }); }); - describe('When a runner is saved', () => { - beforeEach(() => { - findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); + describe('Runner form', () => { + it('shows the runner create form for an instance runner', () => { + expect(findRunnerCreateForm().props()).toEqual({ + runnerType: PROJECT_TYPE, + projectId: mockProjectId, + groupId: null, + }); }); - it('pushes an alert to be shown after redirection', () => { - expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ - message: s__('Runners|Runner created.'), - variant: VARIANT_SUCCESS, + describe('When a runner is saved', () => { + beforeEach(() => { + findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); }); - }); - it('redirects to the registration page', () => { - const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`; + it('pushes an alert to be shown after redirection', () => { + expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ + message: s__('Runners|Runner created.'), + variant: VARIANT_SUCCESS, + }); + }); - expect(visitUrl).toHaveBeenCalledWith(url); - }); - }); + it('redirects to the registration page', () => { + const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`; - describe('When another platform is selected and a runner is saved', () => { - beforeEach(() => { - findRunnerPlatformsRadioGroup().vm.$emit('input', WINDOWS_PLATFORM); - findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); + expect(visitUrl).toHaveBeenCalledWith(url); + }); }); - it('redirects to the registration page with the platform', () => { - const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`; + describe('When another platform is selected and a runner is saved', () => { + beforeEach(() => { + findRunnerPlatformsRadioGroup().vm.$emit('input', WINDOWS_PLATFORM); + findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); + }); + + it('redirects to the registration page with the platform', () => { + const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`; - expect(visitUrl).toHaveBeenCalledWith(url); + expect(visitUrl).toHaveBeenCalledWith(url); + }); }); - }); - describe('When runner fails to save', () => { - const ERROR_MSG = 'Cannot save!'; + describe('When runner fails to save', () => { + const ERROR_MSG = 'Cannot save!'; - beforeEach(() => { - findRunnerCreateForm().vm.$emit('error', new Error(ERROR_MSG)); - }); + beforeEach(() => { + findRunnerCreateForm().vm.$emit('error', new Error(ERROR_MSG)); + }); - it('shows an error message', () => { - expect(createAlert).toHaveBeenCalledWith({ message: ERROR_MSG }); + it('shows an error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: ERROR_MSG }); + }); }); }); }); + + describe('Runner cloud form', () => { + it.each` + flagState | visible + ${true} | ${true} + ${false} | ${false} + `( + 'shows runner cloud form: $visible when flag is set to $flagState and platform is google', + async ({ flagState, visible }) => { + createComponent(flagState); + + findRunnerPlatformsRadioGroup().vm.$emit('input', GOOGLE_CLOUD_PLATFORM); + + await nextTick(); + + expect(findRunnerCloudForm().exists()).toBe(visible); + }, + ); + }); }); diff --git a/spec/frontend/observability/client_spec.js b/spec/frontend/observability/client_spec.js index 1bd0112746b..e3196861659 100644 --- a/spec/frontend/observability/client_spec.js +++ b/spec/frontend/observability/client_spec.js @@ -14,6 +14,7 @@ describe('buildClient', () => { let axiosMock; const tracingUrl = 'https://example.com/tracing'; + const tracingAnalyticsUrl = 'https://example.com/tracing/analytics'; const provisioningUrl = 'https://example.com/provisioning'; const servicesUrl = 'https://example.com/services'; const operationsUrl = 'https://example.com/services/$SERVICE_NAME$/operations'; @@ -23,6 +24,7 @@ describe('buildClient', () => { const apiConfig = { tracingUrl, + tracingAnalyticsUrl, provisioningUrl, servicesUrl, operationsUrl, @@ -389,6 +391,196 @@ describe('buildClient', () => { }); }); + describe('fetchTracesAnalytics', () => { + it('fetches analytics from the tracesAnalytics URL', async () => { + const mockResponse = { + results: [ + { + Interval: 1705039800, + count: 5, + p90_duration_nano: 50613502867, + p95_duration_nano: 50613502867, + p75_duration_nano: 49756727928, + p50_duration_nano: 41610120929, + error_count: 324, + trace_rate: 2.576111111111111, + error_rate: 0.09, + }, + ], + }; + + axiosMock.onGet(tracingAnalyticsUrl).reply(200, mockResponse); + + const result = await client.fetchTracesAnalytics(); + + expect(axios.get).toHaveBeenCalledTimes(1); + expect(axios.get).toHaveBeenCalledWith(tracingAnalyticsUrl, { + withCredentials: true, + params: expect.any(URLSearchParams), + }); + expect(result).toEqual(mockResponse.results); + }); + + it('returns empty array if analytics are missing', async () => { + axiosMock.onGet(tracingAnalyticsUrl).reply(200, {}); + + expect(await client.fetchTracesAnalytics()).toEqual([]); + }); + + describe('query filter', () => { + beforeEach(() => { + axiosMock.onGet(tracingAnalyticsUrl).reply(200, { + results: [], + }); + }); + + it('does not set any query param without filters', async () => { + await client.fetchTracesAnalytics(); + + expect(getQueryParam()).toBe(``); + }); + + it('converts filter to proper query params', async () => { + await client.fetchTracesAnalytics({ + filters: { + durationMs: [ + { operator: '>', value: '100' }, + { operator: '<', value: '1000' }, + ], + operation: [ + { operator: '=', value: 'op' }, + { operator: '!=', value: 'not-op' }, + ], + service: [ + { operator: '=', value: 'service' }, + { operator: '!=', value: 'not-service' }, + ], + period: [{ operator: '=', value: '5m' }], + status: [ + { operator: '=', value: 'ok' }, + { operator: '!=', value: 'error' }, + ], + traceId: [ + { operator: '=', value: 'trace-id' }, + { operator: '!=', value: 'not-trace-id' }, + ], + attribute: [{ operator: '=', value: 'name1=value1' }], + }, + }); + expect(getQueryParam()).toContain( + 'gt[duration_nano]=100000000<[duration_nano]=1000000000' + + '&operation=op¬[operation]=not-op' + + '&service_name=service¬[service_name]=not-service' + + '&period=5m' + + '&trace_id=trace-id¬[trace_id]=not-trace-id' + + '&attr_name=name1&attr_value=value1' + + '&status=ok¬[status]=error', + ); + }); + describe('date range time filter', () => { + it('handles custom date range period filter', async () => { + await client.fetchTracesAnalytics({ + filters: { + period: [{ operator: '=', value: '2023-01-01 - 2023-02-01' }], + }, + }); + expect(getQueryParam()).not.toContain('period='); + expect(getQueryParam()).toContain( + 'start_time=2023-01-01T00:00:00.000Z&end_time=2023-02-01T00:00:00.000Z', + ); + }); + + it.each([ + 'invalid - 2023-02-01', + '2023-02-01 - invalid', + 'invalid - invalid', + '2023-01-01 / 2023-02-01', + '2023-01-01 2023-02-01', + '2023-01-01 - 2023-02-01 - 2023-02-01', + ])('ignore invalid values', async (val) => { + await client.fetchTracesAnalytics({ + filters: { + period: [{ operator: '=', value: val }], + }, + }); + + expect(getQueryParam()).not.toContain('start_time='); + expect(getQueryParam()).not.toContain('end_time='); + expect(getQueryParam()).not.toContain('period='); + }); + }); + + it('handles repeated params', async () => { + await client.fetchTracesAnalytics({ + filters: { + operation: [ + { operator: '=', value: 'op' }, + { operator: '=', value: 'op2' }, + ], + }, + }); + expect(getQueryParam()).toContain('operation=op&operation=op2'); + }); + + it('ignores unsupported filters', async () => { + await client.fetchTracesAnalytics({ + filters: { + unsupportedFilter: [{ operator: '=', value: 'foo' }], + }, + }); + + expect(getQueryParam()).toBe(``); + }); + + it('ignores empty filters', async () => { + await client.fetchTracesAnalytics({ + filters: { + durationMs: null, + }, + }); + + expect(getQueryParam()).toBe(``); + }); + + it('ignores non-array filters', async () => { + await client.fetchTracesAnalytics({ + filters: { + traceId: { operator: '=', value: 'foo' }, + }, + }); + + expect(getQueryParam()).toBe(``); + }); + + it('ignores unsupported operators', async () => { + await client.fetchTracesAnalytics({ + filters: { + durationMs: [ + { operator: '*', value: 'foo' }, + { operator: '=', value: 'foo' }, + { operator: '!=', value: 'foo' }, + ], + operation: [ + { operator: '>', value: 'foo' }, + { operator: '<', value: 'foo' }, + ], + service: [ + { operator: '>', value: 'foo' }, + { operator: '<', value: 'foo' }, + ], + period: [{ operator: '!=', value: 'foo' }], + traceId: [ + { operator: '>', value: 'foo' }, + { operator: '<', value: 'foo' }, + ], + }, + }); + + expect(getQueryParam()).toBe(``); + }); + }); + }); + describe('fetchServices', () => { it('fetches services from the services URL', async () => { const mockResponse = { diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index 8414dfcf151..31337364dca 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -18,7 +18,6 @@ import { loadViewer } from '~/repository/components/blob_viewers'; import DownloadViewer from '~/repository/components/blob_viewers/download_viewer.vue'; import EmptyViewer from '~/repository/components/blob_viewers/empty_viewer.vue'; import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; -import SourceViewerNew from '~/vue_shared/components/source_viewer/source_viewer_new.vue'; import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql'; import projectInfoQuery from '~/repository/queries/project_info.query.graphql'; import CodeIntelligence from '~/code_navigation/components/app.vue'; @@ -60,6 +59,7 @@ const mockRouterPush = jest.fn(); const mockRouter = { push: mockRouterPush, }; +const highlightWorker = { postMessage: jest.fn() }; const legacyViewerUrl = '/some_file.js?format=json&viewer=simple'; @@ -74,7 +74,7 @@ const createComponent = async (mockData = {}, mountFn = shallowMount, mockRoute downloadCode = userPermissionsMock.downloadCode, createMergeRequestIn = userPermissionsMock.createMergeRequestIn, isBinary, - inject = {}, + inject = { highlightWorker }, } = mockData; const blobInfo = { @@ -136,9 +136,6 @@ const createComponent = async (mockData = {}, mountFn = shallowMount, mockRoute targetBranch: 'test', originalBranch: 'default-ref', ...inject, - glFeatures: { - highlightJsWorker: false, - }, }, }), ); @@ -158,7 +155,6 @@ describe('Blob content viewer component', () => { const findForkSuggestion = () => wrapper.findComponent(ForkSuggestion); const findCodeIntelligence = () => wrapper.findComponent(CodeIntelligence); const findSourceViewer = () => wrapper.findComponent(SourceViewer); - const findSourceViewerNew = () => wrapper.findComponent(SourceViewerNew); beforeEach(() => { jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); @@ -203,28 +199,28 @@ describe('Blob content viewer component', () => { }); it('adds blame param to the URL and passes `showBlame` to the SourceViewer', async () => { - loadViewer.mockReturnValueOnce(SourceViewerNew); + loadViewer.mockReturnValueOnce(SourceViewer); await createComponent({ blob: simpleViewerMock }); await triggerBlame(); expect(mockRouterPush).toHaveBeenCalledWith({ query: { blame: '1' } }); - expect(findSourceViewerNew().props('showBlame')).toBe(true); + expect(findSourceViewer().props('showBlame')).toBe(true); await triggerBlame(); expect(mockRouterPush).toHaveBeenCalledWith({ query: { blame: '0' } }); - expect(findSourceViewerNew().props('showBlame')).toBe(false); + expect(findSourceViewer().props('showBlame')).toBe(false); }); describe('when viewing rich content', () => { it('always shows the blame when clicking on the blame button', async () => { - loadViewer.mockReturnValueOnce(SourceViewerNew); + loadViewer.mockReturnValueOnce(SourceViewer); const query = { plain: '0', blame: '1' }; await createComponent({ blob: simpleViewerMock }, shallowMount, { query }); await triggerBlame(); - expect(findSourceViewerNew().props('showBlame')).toBe(true); + expect(findSourceViewer().props('showBlame')).toBe(true); }); }); }); @@ -435,7 +431,7 @@ describe('Blob content viewer component', () => { await waitForPromises(); - expect(loadViewer).toHaveBeenCalledWith(viewer, false, false, 'javascript'); + expect(loadViewer).toHaveBeenCalledWith(viewer, false); expect(wrapper.findComponent(loadViewerReturnValue).exists()).toBe(true); }); }); @@ -514,7 +510,7 @@ describe('Blob content viewer component', () => { }); it('is called with originalBranch value if the prop has a value', async () => { - await createComponent({ inject: { originalBranch: 'some-branch' } }); + await createComponent({ inject: { originalBranch: 'some-branch', highlightWorker } }); expect(blobInfoMockResolver).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/spec/frontend/repository/mixins/highlight_mixin_spec.js b/spec/frontend/repository/mixins/highlight_mixin_spec.js index c635c09d1aa..ccde41f62e5 100644 --- a/spec/frontend/repository/mixins/highlight_mixin_spec.js +++ b/spec/frontend/repository/mixins/highlight_mixin_spec.js @@ -41,7 +41,6 @@ describe('HighlightMixin', () => { mixins: [highlightMixin], inject: { highlightWorker: { default: workerMock }, - glFeatures: { default: { highlightJsWorker: true } }, }, template: '<div>{{chunks[0]?.highlightedContent}}</div>', created() { diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js deleted file mode 100644 index 745886161ce..00000000000 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js +++ /dev/null @@ -1,191 +0,0 @@ -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { setHTMLFixture } from 'helpers/fixtures'; -import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer_new.vue'; -import Chunk from '~/vue_shared/components/source_viewer/components/chunk_new.vue'; -import { - EVENT_ACTION, - EVENT_LABEL_VIEWER, - CODEOWNERS_FILE_NAME, -} from '~/vue_shared/components/source_viewer/constants'; -import Tracking from '~/tracking'; -import LineHighlighter from '~/blob/line_highlighter'; -import addBlobLinksTracking from '~/blob/blob_links_tracking'; -import waitForPromises from 'helpers/wait_for_promises'; -import blameDataQuery from '~/vue_shared/components/source_viewer/queries/blame_data.query.graphql'; -import Blame from '~/vue_shared/components/source_viewer/components/blame_info.vue'; -import * as utils from '~/vue_shared/components/source_viewer/utils'; -import CodeownersValidation from 'ee_component/blob/components/codeowners_validation.vue'; - -import { - BLOB_DATA_MOCK, - CHUNK_1, - CHUNK_2, - LANGUAGE_MOCK, - BLAME_DATA_QUERY_RESPONSE_MOCK, - SOURCE_CODE_CONTENT_MOCK, -} from './mock_data'; - -Vue.use(VueApollo); - -const lineHighlighter = new LineHighlighter(); -jest.mock('~/blob/line_highlighter', () => - jest.fn().mockReturnValue({ - highlightHash: jest.fn(), - }), -); -jest.mock('~/blob/blob_links_tracking'); - -describe('Source Viewer component', () => { - let wrapper; - let fakeApollo; - const CHUNKS_MOCK = [CHUNK_1, CHUNK_2]; - const projectPath = 'test'; - const currentRef = 'main'; - const hash = '#L142'; - - const blameDataQueryHandlerSuccess = jest.fn().mockResolvedValue(BLAME_DATA_QUERY_RESPONSE_MOCK); - const blameInfo = - BLAME_DATA_QUERY_RESPONSE_MOCK.data.project.repository.blobs.nodes[0].blame.groups; - - const createComponent = ({ showBlame = true, blob = {} } = {}) => { - fakeApollo = createMockApollo([[blameDataQuery, blameDataQueryHandlerSuccess]]); - - wrapper = shallowMountExtended(SourceViewer, { - apolloProvider: fakeApollo, - mocks: { $route: { hash } }, - propsData: { - blob: { ...blob, ...BLOB_DATA_MOCK }, - chunks: CHUNKS_MOCK, - projectPath, - currentRef, - showBlame, - }, - }); - }; - - const findChunks = () => wrapper.findAllComponents(Chunk); - const findBlameComponents = () => wrapper.findAllComponents(Blame); - const triggerChunkAppear = async (chunkIndex = 0) => { - findChunks().at(chunkIndex).vm.$emit('appear'); - await waitForPromises(); - }; - - beforeEach(() => { - jest.spyOn(Tracking, 'event'); - return createComponent(); - }); - - it('instantiates the lineHighlighter class', () => { - expect(LineHighlighter).toHaveBeenCalled(); - }); - - describe('event tracking', () => { - it('fires a tracking event when the component is created', () => { - const eventData = { label: EVENT_LABEL_VIEWER, property: LANGUAGE_MOCK }; - expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); - }); - - it('adds blob links tracking', () => { - expect(addBlobLinksTracking).toHaveBeenCalled(); - }); - }); - - describe('rendering', () => { - it('does not render a Blame component if the respective chunk for the blame has not appeared', async () => { - await waitForPromises(); - expect(findBlameComponents()).toHaveLength(0); - }); - - describe('DOM updates', () => { - it('adds the necessary classes to the DOM', async () => { - setHTMLFixture(SOURCE_CODE_CONTENT_MOCK); - jest.spyOn(utils, 'toggleBlameClasses'); - createComponent(); - await triggerChunkAppear(); - - expect(utils.toggleBlameClasses).toHaveBeenCalledWith(blameInfo, true); - }); - }); - - describe('Blame information', () => { - it('renders a Blame component when a chunk appears', async () => { - await triggerChunkAppear(); - - expect(findBlameComponents().at(0).exists()).toBe(true); - expect(findBlameComponents().at(0).props()).toMatchObject({ blameInfo }); - }); - - it('calls the blame data query', async () => { - await triggerChunkAppear(); - - expect(blameDataQueryHandlerSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - filePath: BLOB_DATA_MOCK.path, - fullPath: projectPath, - ref: currentRef, - }), - ); - }); - - it('calls the query only once per chunk', async () => { - // We trigger the `appear` event multiple times here in order to simulate the user scrolling past the chunk more than once. - // In this scenario we only want to query the backend once. - await triggerChunkAppear(); - await triggerChunkAppear(); - - expect(blameDataQueryHandlerSuccess).toHaveBeenCalledTimes(1); - }); - - it('requests blame information for overlapping chunk', async () => { - await triggerChunkAppear(1); - - expect(blameDataQueryHandlerSuccess).toHaveBeenCalledTimes(2); - expect(blameDataQueryHandlerSuccess).toHaveBeenCalledWith( - expect.objectContaining({ fromLine: 71, toLine: 110 }), - ); - expect(blameDataQueryHandlerSuccess).toHaveBeenCalledWith( - expect.objectContaining({ fromLine: 1, toLine: 70 }), - ); - - expect(findChunks().at(0).props('isHighlighted')).toBe(true); - }); - - it('does not render a Blame component when `showBlame: false`', async () => { - createComponent({ showBlame: false }); - await triggerChunkAppear(); - - expect(findBlameComponents()).toHaveLength(0); - }); - }); - - it('renders a Chunk component for each chunk', () => { - expect(findChunks().at(0).props()).toMatchObject(CHUNK_1); - expect(findChunks().at(1).props()).toMatchObject(CHUNK_2); - }); - }); - - describe('hash highlighting', () => { - it('calls highlightHash with expected parameter', () => { - expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash); - }); - }); - - describe('Codeowners validation', () => { - const findCodeownersValidation = () => wrapper.findComponent(CodeownersValidation); - - it('does not render codeowners validation when file is not CODEOWNERS', async () => { - await createComponent(); - await nextTick(); - expect(findCodeownersValidation().exists()).toBe(false); - }); - - it('renders codeowners validation when file is CODEOWNERS', async () => { - await createComponent({ blob: { name: CODEOWNERS_FILE_NAME } }); - await nextTick(); - expect(findCodeownersValidation().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js index 2043f36443d..1fa15b28cf1 100644 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js @@ -1,279 +1,175 @@ -import hljs from 'highlight.js/lib/core'; -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { setHTMLFixture } from 'helpers/fixtures'; import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; -import CodeownersValidation from 'ee_component/blob/components/codeowners_validation.vue'; -import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; -import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk_new.vue'; import { EVENT_ACTION, EVENT_LABEL_VIEWER, - EVENT_LABEL_FALLBACK, - ROUGE_TO_HLJS_LANGUAGE_MAP, - LINES_PER_CHUNK, - LEGACY_FALLBACKS, CODEOWNERS_FILE_NAME, - CODEOWNERS_LANGUAGE, - SVELTE_LANGUAGE, } from '~/vue_shared/components/source_viewer/constants'; -import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import waitForPromises from 'helpers/wait_for_promises'; -import LineHighlighter from '~/blob/line_highlighter'; -import eventHub from '~/notes/event_hub'; import Tracking from '~/tracking'; +import LineHighlighter from '~/blob/line_highlighter'; +import addBlobLinksTracking from '~/blob/blob_links_tracking'; +import waitForPromises from 'helpers/wait_for_promises'; +import blameDataQuery from '~/vue_shared/components/source_viewer/queries/blame_data.query.graphql'; +import Blame from '~/vue_shared/components/source_viewer/components/blame_info.vue'; +import * as utils from '~/vue_shared/components/source_viewer/utils'; +import CodeownersValidation from 'ee_component/blob/components/codeowners_validation.vue'; -const lineHighlighter = new LineHighlighter(); -jest.mock('~/blob/line_highlighter', () => jest.fn().mockReturnValue({ highlightHash: jest.fn() })); -jest.mock('highlight.js/lib/core'); -jest.mock('~/vue_shared/components/source_viewer/plugins/index'); -const mockAxios = new MockAdapter(axios); +import { + BLOB_DATA_MOCK, + CHUNK_1, + CHUNK_2, + LANGUAGE_MOCK, + BLAME_DATA_QUERY_RESPONSE_MOCK, + SOURCE_CODE_CONTENT_MOCK, +} from './mock_data'; -const generateContent = (content, totalLines = 1, delimiter = '\n') => { - let generatedContent = ''; - for (let i = 0; i < totalLines; i += 1) { - generatedContent += `Line: ${i + 1} = ${content}${delimiter}`; - } - return generatedContent; -}; +Vue.use(VueApollo); -const execImmediately = (callback) => callback(); +const lineHighlighter = new LineHighlighter(); +jest.mock('~/blob/line_highlighter', () => + jest.fn().mockReturnValue({ + highlightHash: jest.fn(), + }), +); +jest.mock('~/blob/blob_links_tracking'); describe('Source Viewer component', () => { let wrapper; - const language = 'docker'; - const selectedRangeHash = '#L1-2'; - const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language]; - const chunk1 = generateContent('// Some source code 1', 70); - const chunk2 = generateContent('// Some source code 2', 70); - const chunk3 = generateContent('// Some source code 3', 70, '\r\n'); - const chunk3Result = generateContent('// Some source code 3', 70, '\n'); - const content = chunk1 + chunk2 + chunk3; - const path = 'some/path.js'; - const blamePath = 'some/blame/path.js'; - const fileType = 'javascript'; - const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path, blamePath, fileType }; - const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`; + let fakeApollo; + const CHUNKS_MOCK = [CHUNK_1, CHUNK_2]; + const projectPath = 'test'; const currentRef = 'main'; - const projectPath = 'test/project'; + const hash = '#L142'; + + const blameDataQueryHandlerSuccess = jest.fn().mockResolvedValue(BLAME_DATA_QUERY_RESPONSE_MOCK); + const blameInfo = + BLAME_DATA_QUERY_RESPONSE_MOCK.data.project.repository.blobs.nodes[0].blame.groups; + + const createComponent = ({ showBlame = true, blob = {} } = {}) => { + fakeApollo = createMockApollo([[blameDataQuery, blameDataQueryHandlerSuccess]]); - const createComponent = async (blob = {}) => { wrapper = shallowMountExtended(SourceViewer, { - propsData: { blob: { ...DEFAULT_BLOB_DATA, ...blob }, currentRef, projectPath }, - mocks: { $route: { hash: selectedRangeHash } }, + apolloProvider: fakeApollo, + mocks: { $route: { hash } }, + propsData: { + blob: { ...blob, ...BLOB_DATA_MOCK }, + chunks: CHUNKS_MOCK, + projectPath, + currentRef, + showBlame, + }, }); - await waitForPromises(); }; const findChunks = () => wrapper.findAllComponents(Chunk); + const findBlameComponents = () => wrapper.findAllComponents(Blame); + const triggerChunkAppear = async (chunkIndex = 0) => { + findChunks().at(chunkIndex).vm.$emit('appear'); + await waitForPromises(); + }; beforeEach(() => { - hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); - hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); - jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); - jest.spyOn(eventHub, '$emit'); jest.spyOn(Tracking, 'event'); - return createComponent(); }); - describe('Displaying LFS blob', () => { - const rawPath = '/org/project/-/raw/file.xml'; - const externalStorageUrl = 'http://127.0.0.1:9000/lfs-objects/91/12/1341234'; - const rawTextBlob = 'This is the external content'; - const blob = { - storedExternally: true, - externalStorage: 'lfs', - simpleViewer: { fileType: 'text' }, - rawPath, - }; - - afterEach(() => { - mockAxios.reset(); - }); - - it('Uses externalStorageUrl to fetch content if present', async () => { - mockAxios.onGet(externalStorageUrl).replyOnce(HTTP_STATUS_OK, rawTextBlob); - - await createComponent({ ...blob, externalStorageUrl }); - - expect(mockAxios.history.get).toHaveLength(1); - expect(mockAxios.history.get[0].url).toBe(externalStorageUrl); - expect(wrapper.vm.$data.content).toBe(rawTextBlob); - }); - - it('Falls back to rawPath to fetch content', async () => { - mockAxios.onGet(rawPath).replyOnce(HTTP_STATUS_OK, rawTextBlob); - - await createComponent(blob); - - expect(mockAxios.history.get).toHaveLength(1); - expect(mockAxios.history.get[0].url).toBe(rawPath); - expect(wrapper.vm.$data.content).toBe(rawTextBlob); - }); + it('instantiates the lineHighlighter class', () => { + expect(LineHighlighter).toHaveBeenCalled(); }); describe('event tracking', () => { it('fires a tracking event when the component is created', () => { - const eventData = { label: EVENT_LABEL_VIEWER, property: language }; + const eventData = { label: EVENT_LABEL_VIEWER, property: LANGUAGE_MOCK }; expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); }); - it('does not emit an error event when the language is supported', () => { - expect(wrapper.emitted('error')).toBeUndefined(); + it('adds blob links tracking', () => { + expect(addBlobLinksTracking).toHaveBeenCalled(); }); - - it('fires a tracking event and emits an error when the language is not supported', () => { - const unsupportedLanguage = 'apex'; - const eventData = { label: EVENT_LABEL_FALLBACK, property: unsupportedLanguage }; - createComponent({ language: unsupportedLanguage }); - - expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); - expect(wrapper.emitted('error')).toHaveLength(1); - }); - }); - - describe('legacy fallbacks', () => { - it.each(LEGACY_FALLBACKS)( - 'tracks a fallback event and emits an error when viewing %s files', - (fallbackLanguage) => { - const eventData = { label: EVENT_LABEL_FALLBACK, property: fallbackLanguage }; - createComponent({ language: fallbackLanguage }); - - expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); - expect(wrapper.emitted('error')).toHaveLength(1); - }, - ); }); - describe('highlight.js', () => { - beforeEach(() => createComponent({ language: mappedLanguage })); - - it('registers our plugins for Highlight.js', () => { - expect(registerPlugins).toHaveBeenCalledWith(hljs, fileType, content); - }); - - it('registers the language definition', async () => { - const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`); - - expect(hljs.registerLanguage).toHaveBeenCalledWith( - mappedLanguage, - languageDefinition.default, - ); + describe('rendering', () => { + it('does not render a Blame component if the respective chunk for the blame has not appeared', async () => { + await waitForPromises(); + expect(findBlameComponents()).toHaveLength(0); }); - describe('sub-languages', () => { - const languageDefinition = { - subLanguage: 'xml', - contains: [{ subLanguage: 'javascript' }, { subLanguage: 'typescript' }], - }; - - beforeEach(async () => { - jest.spyOn(hljs, 'getLanguage').mockReturnValue(languageDefinition); + describe('DOM updates', () => { + it('adds the necessary classes to the DOM', async () => { + setHTMLFixture(SOURCE_CODE_CONTENT_MOCK); + jest.spyOn(utils, 'toggleBlameClasses'); createComponent(); - await waitForPromises(); - }); + await triggerChunkAppear(); - it('registers the primary sub-language', () => { - expect(hljs.registerLanguage).toHaveBeenCalledWith( - languageDefinition.subLanguage, - expect.any(Function), - ); + expect(utils.toggleBlameClasses).toHaveBeenCalledWith(blameInfo, true); }); - - it.each(languageDefinition.contains)( - 'registers the rest of the sub-languages', - ({ subLanguage }) => { - expect(hljs.registerLanguage).toHaveBeenCalledWith(subLanguage, expect.any(Function)); - }, - ); }); - it('registers json language definition if fileType is package_json', async () => { - await createComponent({ language: 'json', fileType: 'package_json' }); - const languageDefinition = await import(`highlight.js/lib/languages/json`); + describe('Blame information', () => { + it('renders a Blame component when a chunk appears', async () => { + await triggerChunkAppear(); - expect(hljs.registerLanguage).toHaveBeenCalledWith('json', languageDefinition.default); - }); - - it('correctly maps languages starting with uppercase', async () => { - await createComponent({ language: 'Ruby' }); - const languageDefinition = await import(`highlight.js/lib/languages/ruby`); - - expect(hljs.registerLanguage).toHaveBeenCalledWith('ruby', languageDefinition.default); - }); + expect(findBlameComponents().at(0).exists()).toBe(true); + expect(findBlameComponents().at(0).props()).toMatchObject({ blameInfo }); + }); - it('registers codeowners language definition if file name is CODEOWNERS', async () => { - await createComponent({ name: CODEOWNERS_FILE_NAME }); - const languageDefinition = await import( - '~/vue_shared/components/source_viewer/languages/codeowners' - ); + it('calls the blame data query', async () => { + await triggerChunkAppear(); - expect(hljs.registerLanguage).toHaveBeenCalledWith( - CODEOWNERS_LANGUAGE, - languageDefinition.default, - ); - }); + expect(blameDataQueryHandlerSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: BLOB_DATA_MOCK.path, + fullPath: projectPath, + ref: currentRef, + }), + ); + }); - it('registers svelte language definition if file name ends with .svelte', async () => { - await createComponent({ name: `component.${SVELTE_LANGUAGE}` }); - const languageDefinition = await import( - '~/vue_shared/components/source_viewer/languages/svelte' - ); + it('calls the query only once per chunk', async () => { + // We trigger the `appear` event multiple times here in order to simulate the user scrolling past the chunk more than once. + // In this scenario we only want to query the backend once. + await triggerChunkAppear(); + await triggerChunkAppear(); - expect(hljs.registerLanguage).toHaveBeenCalledWith( - SVELTE_LANGUAGE, - languageDefinition.default, - ); - }); + expect(blameDataQueryHandlerSuccess).toHaveBeenCalledTimes(1); + }); - it('highlights the first chunk', () => { - expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); - expect(findChunks().at(0).props('isFirstChunk')).toBe(true); - }); + it('requests blame information for overlapping chunk', async () => { + await triggerChunkAppear(1); - describe('auto-detects if a language cannot be loaded', () => { - beforeEach(() => createComponent({ language: 'some_unknown_language' })); + expect(blameDataQueryHandlerSuccess).toHaveBeenCalledTimes(2); + expect(blameDataQueryHandlerSuccess).toHaveBeenCalledWith( + expect.objectContaining({ fromLine: 71, toLine: 110 }), + ); + expect(blameDataQueryHandlerSuccess).toHaveBeenCalledWith( + expect.objectContaining({ fromLine: 1, toLine: 70 }), + ); - it('highlights the content with auto-detection', () => { - expect(hljs.highlightAuto).toHaveBeenCalledWith(chunk1.trim()); + expect(findChunks().at(0).props('isHighlighted')).toBe(true); }); - }); - }); - describe('rendering', () => { - it.each` - chunkIndex | chunkContent | totalChunks - ${0} | ${chunk1} | ${0} - ${1} | ${chunk2} | ${3} - ${2} | ${chunk3Result} | ${3} - `('renders chunk $chunkIndex', ({ chunkIndex, chunkContent, totalChunks }) => { - const chunk = findChunks().at(chunkIndex); + it('does not render a Blame component when `showBlame: false`', async () => { + createComponent({ showBlame: false }); + await triggerChunkAppear(); - expect(chunk.props('content')).toContain(chunkContent.trim()); - - expect(chunk.props()).toMatchObject({ - totalLines: LINES_PER_CHUNK, - startingFrom: LINES_PER_CHUNK * chunkIndex, - totalChunks, + expect(findBlameComponents()).toHaveLength(0); }); }); - it('emits showBlobInteractionZones on the eventHub when chunk appears', () => { - findChunks().at(0).vm.$emit('appear'); - expect(eventHub.$emit).toHaveBeenCalledWith('showBlobInteractionZones', path); + it('renders a Chunk component for each chunk', () => { + expect(findChunks().at(0).props()).toMatchObject(CHUNK_1); + expect(findChunks().at(1).props()).toMatchObject(CHUNK_2); }); }); - describe('LineHighlighter', () => { - it('instantiates the lineHighlighter class', () => { - expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); - }); - - it('highlights the range when chunk appears', () => { - findChunks().at(0).vm.$emit('appear'); - const scrollEnabled = false; - expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(selectedRangeHash, scrollEnabled); + describe('hash highlighting', () => { + it('calls highlightHash with expected parameter', () => { + expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash); }); }); @@ -282,11 +178,13 @@ describe('Source Viewer component', () => { it('does not render codeowners validation when file is not CODEOWNERS', async () => { await createComponent(); + await nextTick(); expect(findCodeownersValidation().exists()).toBe(false); }); it('renders codeowners validation when file is CODEOWNERS', async () => { - await createComponent({ name: CODEOWNERS_FILE_NAME }); + await createComponent({ blob: { name: CODEOWNERS_FILE_NAME } }); + await nextTick(); expect(findCodeownersValidation().exists()).toBe(true); }); }); diff --git a/spec/lib/gitlab/background_migration/backfill_project_import_level_spec.rb b/spec/lib/gitlab/background_migration/backfill_project_import_level_spec.rb deleted file mode 100644 index 73661a3da1f..00000000000 --- a/spec/lib/gitlab/background_migration/backfill_project_import_level_spec.rb +++ /dev/null @@ -1,122 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -# rubocop:disable Layout/HashAlignment -RSpec.describe Gitlab::BackgroundMigration::BackfillProjectImportLevel do - let(:migration) do - described_class.new( - start_id: table(:namespaces).minimum(:id), - end_id: table(:namespaces).maximum(:id), - batch_table: :namespaces, - batch_column: :id, - sub_batch_size: 2, - pause_ms: 0, - connection: ApplicationRecord.connection - ) - end - # rubocop:enable Layout/HashAlignment - - let(:namespaces_table) { table(:namespaces) } - let(:namespace_settings_table) { table(:namespace_settings) } - - let!(:user_namespace) do - namespaces_table.create!( - name: 'user_namespace', - path: 'user_namespace', - type: 'User', - project_creation_level: 100 - ) - end - - let!(:group_namespace_nil) do - namespaces_table.create!( - name: 'group_namespace_nil', - path: 'group_namespace_nil', - type: 'Group', - project_creation_level: nil - ) - end - - let!(:group_namespace_0) do - namespaces_table.create!( - name: 'group_namespace_0', - path: 'group_namespace_0', - type: 'Group', - project_creation_level: 0 - ) - end - - let!(:group_namespace_1) do - namespaces_table.create!( - name: 'group_namespace_1', - path: 'group_namespace_1', - type: 'Group', - project_creation_level: 1 - ) - end - - let!(:group_namespace_2) do - namespaces_table.create!( - name: 'group_namespace_2', - path: 'group_namespace_2', - type: 'Group', - project_creation_level: 2 - ) - end - - let!(:group_namespace_9999) do - namespaces_table.create!( - name: 'group_namespace_9999', - path: 'group_namespace_9999', - type: 'Group', - project_creation_level: 9999 - ) - end - - subject(:perform_migration) { migration.perform } - - before do - namespace_settings_table.create!(namespace_id: user_namespace.id) - namespace_settings_table.create!(namespace_id: group_namespace_nil.id) - namespace_settings_table.create!(namespace_id: group_namespace_0.id) - namespace_settings_table.create!(namespace_id: group_namespace_1.id) - namespace_settings_table.create!(namespace_id: group_namespace_2.id) - namespace_settings_table.create!(namespace_id: group_namespace_9999.id) - end - - describe 'Groups' do - using RSpec::Parameterized::TableSyntax - - where(:namespace_id, :prev_level, :new_level) do - lazy { group_namespace_0.id } | ::Gitlab::Access::OWNER | ::Gitlab::Access::NO_ACCESS - lazy { group_namespace_1.id } | ::Gitlab::Access::OWNER | ::Gitlab::Access::MAINTAINER - lazy { group_namespace_2.id } | ::Gitlab::Access::OWNER | ::Gitlab::Access::DEVELOPER - end - - with_them do - it 'backfills the correct project_import_level of Group namespaces' do - expect { perform_migration } - .to change { namespace_settings_table.find_by(namespace_id: namespace_id).project_import_level } - .from(prev_level).to(new_level) - end - end - - it 'does not update `User` namespaces or values outside range' do - expect { perform_migration } - .not_to change { namespace_settings_table.find_by(namespace_id: user_namespace.id).project_import_level } - - expect { perform_migration } - .not_to change { namespace_settings_table.find_by(namespace_id: group_namespace_9999.id).project_import_level } - end - - it 'maintains default import_level if creation_level is nil' do - project_import_level = namespace_settings_table.find_by(namespace_id: group_namespace_nil.id).project_import_level - - expect { perform_migration } - .not_to change { project_import_level } - - expect(project_import_level).to eq(::Gitlab::Access::OWNER) - end - end -end diff --git a/spec/lib/gitlab/ci/config/external/file/base_spec.rb b/spec/lib/gitlab/ci/config/external/file/base_spec.rb index bcfab620bd9..6100974bbe8 100644 --- a/spec/lib/gitlab/ci/config/external/file/base_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/base_spec.rb @@ -145,7 +145,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base, feature_category: :pipe it 'surfaces interpolation errors' do expect(valid?).to be_falsy expect(file.errors) - .to include('`some-location.yml`: interpolation interrupted by errors, unknown interpolation key: `abcd`') + .to include('`some-location.yml`: unknown interpolation key: `abcd`') end end diff --git a/spec/lib/gitlab/ci/config/interpolation/text_interpolator_spec.rb b/spec/lib/gitlab/ci/config/interpolation/text_interpolator_spec.rb index 8655d3fb0b7..2e59b6982dd 100644 --- a/spec/lib/gitlab/ci/config/interpolation/text_interpolator_spec.rb +++ b/spec/lib/gitlab/ci/config/interpolation/text_interpolator_spec.rb @@ -34,17 +34,6 @@ RSpec.describe Gitlab::Ci::Config::Interpolation::TextInterpolator, feature_cate end end - context 'when the header has an error while being parsed' do - let(:header) { ::Gitlab::Config::Loader::Yaml.new('_!@malformedyaml:&') } - - it 'surfaces the error' do - interpolator.interpolate! - - expect(interpolator).not_to be_valid - expect(interpolator.error_message).to eq('Invalid configuration format') - end - end - context 'when spec header is missing but inputs are specified' do let(:documents) { ::Gitlab::Ci::Config::Yaml::Documents.new([content]) } diff --git a/spec/lib/gitlab/ci/config/yaml/documents_spec.rb b/spec/lib/gitlab/ci/config/yaml/documents_spec.rb index babdea6623b..424fa4858a4 100644 --- a/spec/lib/gitlab/ci/config/yaml/documents_spec.rb +++ b/spec/lib/gitlab/ci/config/yaml/documents_spec.rb @@ -5,24 +5,6 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Config::Yaml::Documents, feature_category: :pipeline_composition do let(:documents) { described_class.new(yaml_documents) } - describe '#valid?' do - context 'when there are no errors' do - let(:yaml_documents) { [::Gitlab::Config::Loader::Yaml.new('job:')] } - - it 'returns true' do - expect(documents).to be_valid - end - end - - context 'when there are errors' do - let(:yaml_documents) { [::Gitlab::Config::Loader::Yaml.new('_!@malformedyaml:&')] } - - it 'returns false' do - expect(documents).not_to be_valid - end - end - end - describe '#header' do context 'when there are at least 2 documents and the first document has a `spec` keyword' do let(:yaml_documents) { [::Gitlab::Config::Loader::Yaml.new('spec:'), ::Gitlab::Config::Loader::Yaml.new('job:')] } diff --git a/spec/lib/gitlab/ci/config/yaml/loader_spec.rb b/spec/lib/gitlab/ci/config/yaml/loader_spec.rb index 684da1df43b..045cdf37037 100644 --- a/spec/lib/gitlab/ci/config/yaml/loader_spec.rb +++ b/spec/lib/gitlab/ci/config/yaml/loader_spec.rb @@ -7,6 +7,7 @@ RSpec.describe ::Gitlab::Ci::Config::Yaml::Loader, feature_category: :pipeline_c let_it_be(:project) { create(:project) } let(:inputs) { { test_input: 'hello test' } } + let(:variables) { [] } let(:yaml) do <<~YAML @@ -21,7 +22,7 @@ RSpec.describe ::Gitlab::Ci::Config::Yaml::Loader, feature_category: :pipeline_c YAML end - subject(:result) { described_class.new(yaml, inputs: inputs).load } + subject(:result) { described_class.new(yaml, inputs: inputs, variables: variables).load } it 'loads and interpolates CI config YAML' do expected_config = { test_job: { script: ['echo "hello test"'] } } @@ -57,6 +58,240 @@ RSpec.describe ::Gitlab::Ci::Config::Yaml::Loader, feature_category: :pipeline_c expect(result.error).to eq('`test_input` input: required value has not been provided') end end + + context 'when interpolating into a YAML key' do + let(:yaml) do + <<~YAML + --- + spec: + inputs: + test_input: + --- + "$[[ inputs.test_input ]]_job": + script: + - echo "test" + YAML + end + + it 'loads and interpolates CI config YAML' do + expected_config = { 'hello test_job': { script: ['echo "test"'] } } + + expect(result).to be_valid + expect(result).to be_interpolated + expect(result.content).to eq(expected_config) + end + end + + context 'when interpolating values of different types' do + let(:inputs) do + { + test_boolean: true, + test_number: 8, + test_string: 'test' + } + end + + let(:yaml) do + <<~YAML + --- + spec: + inputs: + test_string: + type: string + test_boolean: + type: boolean + test_number: + type: number + --- + "$[[ inputs.test_string ]]_job": + allow_failure: $[[ inputs.test_boolean ]] + parallel: $[[ inputs.test_number ]] + YAML + end + + it 'loads and interpolates CI config YAML' do + expected_config = { test_job: { allow_failure: true, parallel: 8 } } + + expect(result).to be_valid + expect(result).to be_interpolated + expect(result.content).to eq(expected_config) + end + end + + context 'when interpolating and expanding variables' do + let(:inputs) { { test_input: '$TEST_VAR' } } + + let(:variables) do + Gitlab::Ci::Variables::Collection.new([ + { key: 'TEST_VAR', value: 'test variable', masked: false } + ]) + end + + let(:yaml) do + <<~YAML + --- + spec: + inputs: + test_input: + --- + "test_job": + script: + - echo "$[[ inputs.test_input | expand_vars ]]" + YAML + end + + it 'loads and interpolates CI config YAML' do + expected_config = { test_job: { script: ['echo "test variable"'] } } + + expect(result).to be_valid + expect(result).to be_interpolated + expect(result.content).to eq(expected_config) + end + end + + context 'when using !reference' do + let(:yaml) do + <<~YAML + --- + spec: + inputs: + test_input: + job_name: + default: .example_ref + --- + .example_ref: + script: + - echo "$[[ inputs.test_input ]]" + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + + build_job: + script: echo "build" + rules: + - !reference ["$[[ inputs.job_name ]]", "rules"] + + test_job: + script: + - !reference [.example_ref, script] + YAML + end + + it 'loads and interpolates CI config YAML' do + expect(result).to be_valid + expect(result).to be_interpolated + expect(result.content).to include('.example_ref': { + rules: [{ if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' }], + script: ['echo "hello test"'] + }) + expect(result.content.dig(:build_job, :rules).first.data[:seq]).to eq(['.example_ref', 'rules']) + expect(result.content).to include( + test_job: { script: [an_instance_of(::Gitlab::Ci::Config::Yaml::Tags::Reference)] } + ) + end + end + + context 'when there are too many interpolation blocks' do + let(:inputs) { { first_input: 'first', second_input: 'second' } } + + let(:yaml) do + <<~YAML + --- + spec: + inputs: + first_input: + second_input: + --- + test_job: + script: + - echo "$[[ inputs.first_input ]]" + - echo "$[[ inputs.second_input ]]" + YAML + end + + it 'returns an error result' do + stub_const('::Gitlab::Ci::Config::Interpolation::TextTemplate::MAX_BLOCKS', 1) + + expect(result).not_to be_valid + expect(result.error).to eq('too many interpolation blocks') + end + end + + context 'when a block is invalid' do + let(:yaml) do + <<~YAML + --- + spec: + inputs: + test_input: + --- + test_job: + script: + - echo "$[[ inputs.test_input | expand_vars | truncate(0,1) ]]" + YAML + end + + it 'returns an error result' do + stub_const('::Gitlab::Ci::Config::Interpolation::Block::MAX_FUNCTIONS', 1) + + expect(result).not_to be_valid + expect(result.error).to eq('too many functions in interpolation block') + end + end + + context 'when the YAML file is too large' do + it 'returns an error result' do + stub_application_setting(ci_max_total_yaml_size_bytes: 1) + + expect(result).not_to be_valid + expect(result.error).to eq('config too large') + end + end + + context 'when given an empty YAML file' do + let(:inputs) { {} } + let(:yaml) { '' } + + it 'returns an error result' do + expect(result).not_to be_valid + expect(result.error).to eq('Invalid configuration format') + end + end + + context 'when ci_text_interpolation is disabled' do + before do + stub_feature_flags(ci_text_interpolation: false) + end + + it 'loads and interpolates CI config YAML' do + expected_config = { test_job: { script: ['echo "hello test"'] } } + + expect(result).to be_valid + expect(result).to be_interpolated + expect(result.content).to eq(expected_config) + end + + context 'when hash interpolation fails' do + let(:yaml) do + <<~YAML + --- + spec: + inputs: + test_input: + --- + test_job: + script: + - echo "$[[ inputs.test_input | expand_vars | truncate(0,1) ]]" + YAML + end + + it 'returns an error result' do + stub_const('::Gitlab::Ci::Config::Interpolation::Block::MAX_FUNCTIONS', 1) + + expect(result).not_to be_valid + expect(result.error).to eq('interpolation interrupted by errors, too many functions in interpolation block') + end + end + end end describe '#load_uninterpolated_yaml' do diff --git a/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb index 4017076d29f..967cd1693a9 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb @@ -56,6 +56,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Validate::External, feature_category let(:validation_service_url) { 'https://validation-service.external/' } before do + stub_feature_flags(external_pipeline_validation_migration: false) stub_env('EXTERNAL_VALIDATION_SERVICE_URL', validation_service_url) allow(Labkit::Correlation::CorrelationId).to receive(:current_id).and_return('correlation-id') end @@ -84,6 +85,42 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Validate::External, feature_category end end + context 'when :external_pipeline_validation_migration feature flag is enabled' do + let(:migrated_validation_service_url) { 'https://runway.validation-service.external/' } + + before do + stub_feature_flags(external_pipeline_validation_migration: project) + end + + context 'when EXTERNAL_VALIDATION_SERVICE_RUNWAY_URL is NOT present' do + before do + stub_env('EXTERNAL_VALIDATION_SERVICE_RUNWAY_URL', nil) + end + + it 'fallbacks to existing validation service URL' do + expect(::Gitlab::HTTP).to receive(:post) do |url, _params| + expect(url).to eq(validation_service_url) + end + + perform! + end + end + + context 'when EXTERNAL_VALIDATION_SERVICE_RUNWAY_URL is present' do + before do + stub_env('EXTERNAL_VALIDATION_SERVICE_RUNWAY_URL', migrated_validation_service_url) + end + + it 'uses migrated validation service URL' do + expect(::Gitlab::HTTP).to receive(:post) do |url, _params| + expect(url).to eq(migrated_validation_service_url) + end + + perform! + end + end + end + it 'respects the defined payload schema' do expect(::Gitlab::HTTP).to receive(:post) do |_url, params| expect(params[:body]).to match_schema('/external_validation') diff --git a/spec/lib/gitlab/database/query_analyzer_spec.rb b/spec/lib/gitlab/database/query_analyzer_spec.rb index 0b849063562..20599bb89b6 100644 --- a/spec/lib/gitlab/database/query_analyzer_spec.rb +++ b/spec/lib/gitlab/database/query_analyzer_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Gitlab::Database::QueryAnalyzer, query_analyzers: false do + using RSpec::Parameterized::TableSyntax + let(:analyzer) { double(:query_analyzer) } let(:user_analyzer) { double(:user_query_analyzer) } let(:disabled_analyzer) { double(:disabled_query_analyzer) } @@ -181,12 +183,34 @@ RSpec.describe Gitlab::Database::QueryAnalyzer, query_analyzers: false do expect { process_sql("SELECT 1 FROM projects") }.not_to raise_error end - def process_sql(sql) + def process_sql(sql, event_name = 'load') described_class.instance.within do ApplicationRecord.load_balancer.read_write do |connection| - described_class.instance.send(:process_sql, sql, connection) + described_class.instance.send(:process_sql, sql, connection, event_name) end end end end + + describe '#normalize_event_name' do + where(:event, :parsed_event) do + 'Project Load' | 'load' + 'Namespaces::UserNamespace Create' | 'create' + 'Project Update' | 'update' + 'Project Destroy' | 'destroy' + 'Project Pluck' | 'pluck' + 'Project Insert' | 'insert' + 'Project Delete All' | 'delete_all' + 'Project Exists?' | 'exists?' + nil | '' + 'TRANSACTION' | 'transaction' + 'SCHEMA' | 'schema' + end + + with_them do + it 'parses event name correctly' do + expect(described_class.instance.send(:normalize_event_name, event)).to eq(parsed_event) + end + end + end end diff --git a/spec/lib/gitlab/database/query_analyzers/ci/partitioning_id_analyzer_spec.rb b/spec/lib/gitlab/database/query_analyzers/ci/partitioning_id_analyzer_spec.rb index 0fe19041b6d..0c1c694a3e3 100644 --- a/spec/lib/gitlab/database/query_analyzers/ci/partitioning_id_analyzer_spec.rb +++ b/spec/lib/gitlab/database/query_analyzers/ci/partitioning_id_analyzer_spec.rb @@ -115,7 +115,7 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::Ci::PartitioningIdAnalyzer, que def process_sql(model, sql) Gitlab::Database::QueryAnalyzer.instance.within do # Skip load balancer and retrieve connection assigned to model - Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, model.retrieve_connection) + Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, model.retrieve_connection, 'load') end end end diff --git a/spec/lib/gitlab/database/query_analyzers/ci/partitioning_routing_analyzer_spec.rb b/spec/lib/gitlab/database/query_analyzers/ci/partitioning_routing_analyzer_spec.rb index 1f86c2ccbb0..8b053fa0291 100644 --- a/spec/lib/gitlab/database/query_analyzers/ci/partitioning_routing_analyzer_spec.rb +++ b/spec/lib/gitlab/database/query_analyzers/ci/partitioning_routing_analyzer_spec.rb @@ -64,7 +64,7 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::Ci::PartitioningRoutingAnalyzer def process_sql(model, sql) Gitlab::Database::QueryAnalyzer.instance.within do # Skip load balancer and retrieve connection assigned to model - Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, model.retrieve_connection) + Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, model.retrieve_connection, 'load') end end end diff --git a/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb index 1909e134e66..fb00fbe27ba 100644 --- a/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb +++ b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb @@ -99,7 +99,7 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana def process_sql(model, sql) Gitlab::Database::QueryAnalyzer.instance.within do # Skip load balancer and retrieve connection assigned to model - Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, model.retrieve_connection) + Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, model.retrieve_connection, 'load') end end end diff --git a/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_validate_connection_spec.rb b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_validate_connection_spec.rb index 0664508fa8d..6a36db1870a 100644 --- a/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_validate_connection_spec.rb +++ b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_validate_connection_spec.rb @@ -97,7 +97,7 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasValidateConnection def process_sql(model, sql) Gitlab::Database::QueryAnalyzer.instance.within([analyzer]) do # Skip load balancer and retrieve connection assigned to model - Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, model.retrieve_connection) + Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, model.retrieve_connection, 'load') end end end diff --git a/spec/lib/gitlab/database/query_analyzers/log_large_in_lists_spec.rb b/spec/lib/gitlab/database/query_analyzers/log_large_in_lists_spec.rb new file mode 100644 index 00000000000..5646c3ff3b6 --- /dev/null +++ b/spec/lib/gitlab/database/query_analyzers/log_large_in_lists_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::QueryAnalyzers::LogLargeInLists, query_analyzers: false, feature_category: :database do + let(:analyzer) { described_class } + let(:fixture) { fixture_file("gitlab/database/query_analyzers/#{file}") } + let(:sql) { fixture.gsub('%IN_LIST%', arguments) } + + # Reduce the in list size to 5 to help with testing + # Reduce the min query size to 50 to help with testing + before do + stub_const("#{described_class}::IN_SIZE_LIMIT", 5) + stub_const("#{described_class}::MIN_QUERY_SIZE", 50) + allow(analyzer).to receive(:backtrace).and_return([]) + allow(analyzer).to receive(:suppressed?).and_return(true) # bypass suppressed? method to avoid false positives + end + + after do + # Clears analyzers list after each test to reload the state of `enabled?` method + Thread.current[:query_analyzer_enabled_analyzers] = [] + end + + context 'when feature flag is enabled' do + before do + stub_feature_flags(log_large_in_list_queries: true) + Gitlab::Database::QueryAnalyzer.instance.begin!([analyzer]) + end + + context 'when conditions are satisfied for logging' do + where(:file, :arguments, :result, :event_name) do + [ + [ + 'small_query_with_in_list.txt', + '1, 2, 3, 4, 5, 6', + { message: 'large_in_list_found', matches: 1, in_list_size: "6", stacktrace: [], event_name: 'load' }, + 'load' + ], + [ + 'small_query_with_in_list.txt', + '1,2,3,4,5,6', + { message: 'large_in_list_found', matches: 1, in_list_size: "6", stacktrace: [], event_name: 'pluck' }, + 'pluck' + ], + [ + 'small_query_with_in_list.txt', + 'SELECT id FROM projects where id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)', + { message: 'large_in_list_found', matches: 1, in_list_size: "10", stacktrace: [], event_name: 'load' }, + 'load' + ], + [ + 'large_query_with_in_list.txt', + '1,2,3,4,5,6', + { message: 'large_in_list_found', matches: 1, in_list_size: "6", stacktrace: [], event_name: 'load' }, + 'load' + ], + [ + 'large_query_with_in_list.txt', + '1, 2, 3, 4, 5, 6', + { message: 'large_in_list_found', matches: 1, in_list_size: "6", stacktrace: [], event_name: 'pluck' }, + 'pluck' + ], + [ + 'large_query_with_in_list.txt', + 'SELECT id FROM projects where id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)', + { message: 'large_in_list_found', matches: 1, in_list_size: "10", stacktrace: [], event_name: 'load' }, + 'load' + ] + ] + end + + with_them do + it 'logs all the occurrences' do + expect(Gitlab::AppLogger).to receive(:warn).with(result) + + process_sql(sql, event_name) + end + end + end + + context 'when conditions are not satisfied for logging' do + where(:file, :arguments, :event_name) do + [ + ['small_query_with_in_list.txt', '1, 2, 3, 4, 5', 'load'], + ['small_query_with_in_list.txt', '$1, $2, $3, $4, $5', 'load'], + ['small_query_with_in_list.txt', 'SELECT id FROM projects WHERE id IN (1, 2, 3, 4, 5)', 'load'], + ['small_query_with_in_list.txt', 'SELECT id FROM projects WHERE id IN (SELECT id FROM namespaces)', 'load'], + ['small_query_with_in_list.txt', '1, 2, 3, 4, 5', 'schema'], + ['large_query_with_in_list.txt', '1, 2, 3, 4, 5', 'load'], + ['large_query_with_in_list.txt', 'SELECT id FROM projects WHERE id IN (1, 2, 3, 4, 5)', 'load'], + ['large_query_with_in_list.txt', 'SELECT id FROM projects WHERE id IN (SELECT id FROM namespaces)', 'load'], + ['large_query_with_in_list.txt', '1, 2, 3, 4, 5', 'schema'], + ['small_query_without_in_list.txt', '', 'load'], + ['small_query_without_in_list.txt', '', 'schema'] + ] + end + + with_them do + it 'skips logging the occurrences' do + expect(Gitlab::AppLogger).not_to receive(:warn) + + process_sql(sql, event_name) + end + end + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(log_large_in_list_queries: false) + Gitlab::Database::QueryAnalyzer.instance.begin!([analyzer]) + end + + where(:file, :arguments, :event_name) do + [ + ['small_query_with_in_list.txt', '1, 2, 3, 4, 5, 6', 'load'], + ['small_query_with_in_list.txt', '$1, $2, $3, $4, $5, $6', 'load'], + ['small_query_with_in_list.txt', 'SELECT id FROM projects WHERE id IN (1, 2, 3, 4, 5, 6)', 'load'], + ['small_query_with_in_list.txt', 'SELECT id FROM projects WHERE id IN (1, 2, 3, 4, 5, 6)', 'load'], + ['small_query_with_in_list.txt', 'SELECT id FROM projects WHERE id IN (SELECT id FROM namespaces)', 'load'], + ['small_query_with_in_list.txt', '1, 2, 3, 4, 5, 6', 'schema'], + ['large_query_with_in_list.txt', '1, 2, 3, 4, 5, 6', 'load'], + ['large_query_with_in_list.txt', 'SELECT id FROM projects WHERE id IN (1, 2, 3, 4, 5, 6, 7, 8)', 'load'], + ['large_query_with_in_list.txt', 'SELECT id FROM projects WHERE id IN ($1, $2, $3, $4, $5, $6, $7)', 'load'], + ['large_query_with_in_list.txt', 'SELECT id FROM projects WHERE id IN (SELECT id FROM namespaces)', 'load'], + ['large_query_with_in_list.txt', '1, 2, 3, 4, 5, 6', 'schema'], + ['small_query_without_in_list.txt', '', 'load'], + ['small_query_without_in_list.txt', '', 'schema'] + ] + end + + with_them do + it 'skips logging the occurrences' do + expect(Gitlab::AppLogger).not_to receive(:warn) + + process_sql(sql, event_name) + end + end + end + + private + + def process_sql(sql, event_name) + Gitlab::Database::QueryAnalyzer.instance.within do + Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, ActiveRecord::Base.connection, event_name) + end + end +end diff --git a/spec/lib/gitlab/database/query_analyzers/prevent_set_operator_mismatch_spec.rb b/spec/lib/gitlab/database/query_analyzers/prevent_set_operator_mismatch_spec.rb index 7fcdc59b691..00b16faab01 100644 --- a/spec/lib/gitlab/database/query_analyzers/prevent_set_operator_mismatch_spec.rb +++ b/spec/lib/gitlab/database/query_analyzers/prevent_set_operator_mismatch_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::PreventSetOperatorMismatch, que def process_sql(sql, model = ApplicationRecord) Gitlab::Database::QueryAnalyzer.instance.within([analyzer]) do # Skip load balancer and retrieve connection assigned to model - Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, model.retrieve_connection) + Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, model.retrieve_connection, 'load') end end diff --git a/spec/lib/gitlab/database/query_analyzers/restrict_allowed_schemas_spec.rb b/spec/lib/gitlab/database/query_analyzers/restrict_allowed_schemas_spec.rb index b90f60e0301..8054743c9a9 100644 --- a/spec/lib/gitlab/database/query_analyzers/restrict_allowed_schemas_spec.rb +++ b/spec/lib/gitlab/database/query_analyzers/restrict_allowed_schemas_spec.rb @@ -180,7 +180,7 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas, yield if block_given? # Skip load balancer and retrieve connection assigned to model - Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, model.retrieve_connection) + Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, model.retrieve_connection, 'load') end end end diff --git a/spec/lib/gitlab/metrics/exporter/base_exporter_spec.rb b/spec/lib/gitlab/metrics/exporter/base_exporter_spec.rb index 4184c674823..844c3b54587 100644 --- a/spec/lib/gitlab/metrics/exporter/base_exporter_spec.rb +++ b/spec/lib/gitlab/metrics/exporter/base_exporter_spec.rb @@ -167,9 +167,9 @@ RSpec.describe Gitlab::Metrics::Exporter::BaseExporter, feature_category: :cloud describe '#start' do it "doesn't start running server" do - expect_any_instance_of(::WEBrick::HTTPServer).not_to receive(:start) + expect(::WEBrick::HTTPServer).not_to receive(:new) - expect { exporter.start }.not_to change { exporter.thread? } + exporter.start end end diff --git a/spec/models/packages/protection/rule_spec.rb b/spec/models/packages/protection/rule_spec.rb index 03d0440f0d9..995f0035879 100644 --- a/spec/models/packages/protection/rule_spec.rb +++ b/spec/models/packages/protection/rule_spec.rb @@ -187,7 +187,7 @@ RSpec.describe Packages::Protection::Rule, type: :model, feature_category: :pack end end - describe '.push_protected_from?' do + describe '.for_push_exists?' do let_it_be(:project_with_ppr) { create(:project) } let_it_be(:project_without_ppr) { create(:project) } @@ -230,7 +230,7 @@ RSpec.describe Packages::Protection::Rule, type: :model, feature_category: :pack subject do project .package_protection_rules - .push_protected_from?( + .for_push_exists?( access_level: access_level, package_name: package_name, package_type: package_type @@ -270,8 +270,11 @@ RSpec.describe Packages::Protection::Rule, type: :model, feature_category: :pack ref(:project_with_ppr) | Gitlab::Access::NO_ACCESS | '@my-scope/my-package-prod' | :npm | true # Edge cases - ref(:project_with_ppr) | 0 | '' | nil | true - ref(:project_with_ppr) | nil | nil | nil | true + ref(:project_with_ppr) | nil | '@my-scope/my-package-stage-sha-1234' | :npm | false + ref(:project_with_ppr) | :developer | nil | :npm | false + ref(:project_with_ppr) | :developer | '' | :npm | false + ref(:project_with_ppr) | :developer | '@my-scope/my-package-stage-sha-1234' | nil | false + ref(:project_with_ppr) | nil | nil | nil | false # For projects that have no package protection rules ref(:project_without_ppr) | :developer | '@my-scope/my-package-prod' | :npm | false diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index 7dea50ba270..8e4e90ae962 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -1838,7 +1838,7 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes expect(pipeline).to be_persisted expect(pipeline.yaml_errors) - .to include 'interpolation interrupted by errors, unknown interpolation key: `suite`' + .to include 'unknown interpolation key: `suite`' end end @@ -2001,7 +2001,7 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes expect(pipeline).to be_persisted expect(pipeline.yaml_errors) - .to include 'interpolation interrupted by errors, unknown interpolation key: `suite`' + .to include 'unknown interpolation key: `suite`' end end diff --git a/spec/support/rspec_order_todo.yml b/spec/support/rspec_order_todo.yml index 872523e8d16..4be24d43363 100644 --- a/spec/support/rspec_order_todo.yml +++ b/spec/support/rspec_order_todo.yml @@ -5314,7 +5314,6 @@ - './spec/lib/gitlab/background_migration/backfill_namespace_id_of_vulnerability_reads_spec.rb' - './spec/lib/gitlab/background_migration/backfill_note_discussion_id_spec.rb' - './spec/lib/gitlab/background_migration/backfill_project_feature_package_registry_access_level_spec.rb' -- './spec/lib/gitlab/background_migration/backfill_project_import_level_spec.rb' - './spec/lib/gitlab/background_migration/backfill_project_repositories_spec.rb' - './spec/lib/gitlab/background_migration/backfill_snippet_repositories_spec.rb' - './spec/lib/gitlab/background_migration/backfill_topics_title_spec.rb' |